Opt-Out (STOP) Webhooks
Receive notifications when contacts unsubscribe from your SMS lists by sending STOP keywords.
Alphanumeric Channels: This webhook only works for
numeric channels (e.g., short codes like "44880") that can receive SMS replies.
If you use alphanumeric sender IDs (e.g., "SchoolSMS", "BankAlert") that cannot receive replies, use our Web-Based Subscription API instead.
View Web-Based Subscription API →
If you use alphanumeric sender IDs (e.g., "SchoolSMS", "BankAlert") that cannot receive replies, use our Web-Based Subscription API instead.
View Web-Based Subscription API →
Overview
When a contact sends a STOP keyword to your channel, the platform automatically:
- Blacklists the contact immediately
- Sends webhook notification to your endpoint
- Sends confirmation SMS to the contact
- Blocks future SMS to that contact
New: Your webhook can return a custom confirmation
message that will be sent to the
contact instead of the default message!
Opt-Out Keywords
The following keywords trigger opt-out (case-insensitive):
English Keywords
STOPUNSUBSCRIBECANCELENDQUITOPTOUTOPT-OUTOPT OUTREMOVESTOPALLUNSUB
French Keywords
ARRETARRETER
Payload Format
POST
https://your-api.com/webhooks/opt-out
Request Headers
Content-Type: application/json
X-Signature: sha256=<hmac-sha256-signature>
User-Agent: SMSDesk-Webhook/1.0
Request Payload
{
"type": "opt_out",
"timestamp": "2026-07-11T08:23:45.0000000Z",
"data": {
"from": "+264812182326",
"to": "44880",
"message": "STOP",
"keyword": "STOP",
"messageId": "123456789",
"receivedAt": "2026-07-11T08:23:44.0000000Z",
"clientId": 1059
}
}
Payload Fields
| Field | Type | Description |
|---|---|---|
type |
string | Always "opt_out" |
timestamp |
string (ISO 8601) | UTC timestamp when webhook was sent |
data.from |
string | Mobile number that sent the opt-out (international format) |
data.to |
string | Channel/shortcode that received the message |
data.message |
string | Full message text received |
data.keyword |
string | Detected opt-out keyword (uppercase) |
data.messageId |
string | Unique ID of the incoming SMS |
data.receivedAt |
string (ISO 8601) | UTC timestamp when message was received |
data.clientId |
integer | Your client ID |
Custom Confirmation Message
Your webhook endpoint can return a custom confirmation message that will be sent to the contact.
Response Priority
- Webhook Response (Highest Priority)
- If your endpoint returns
200 OKwith plain text body - That message will be sent to the contact
- HTML tags are automatically stripped
- If your endpoint returns
- Configured Message (Medium Priority)
- If webhook returns empty or fails
- Uses "Blacklist Response" from web portal configuration
- Default Message (Lowest Priority)
- If no webhook response and no configured message
- System generates generic message
Example Response with Custom Message
HTTP/1.1 200 OK
Content-Type: text/plain
You have successfully unsubscribed from Lexna Insurance. You will no longer receive marketing messages. Reply JOIN to re-subscribe.
Example Response Using Default
HTTP/1.1 200 OK
Content-Type: text/plain
Default Message Format
You, {mobile}, have successfully opted out of {clientName} contact lists. You will no longer receive any messages. You can opt back in by sending JOIN to {channel}.
Configuration
Web Portal Setup
- Log in to your SMSDESK portal (e.g.,
https://desk.sms.com.nafor Namibia deployment) - Navigate to Account → SMS Channels
- Click on your channel to edit
- In Webhooks section, configure:
- Opt-out Webhook URL: Your webhook endpoint
- Opt-out Shared Secret: Your HMAC secret (use "Generate" button)
- Blacklist Response: Fallback confirmation message (optional)
- Save changes
Code Examples
Python / Flask
from flask import Flask, request, jsonify
import hmac
import hashlib
app = Flask(__name__)
SECRET = 'your-secret-here'
@app.route('/webhooks/opt-out', methods=['POST'])
def opt_out():
# Get raw body
payload = request.get_data(as_text=True)
# Verify signature
signature = request.headers.get('X-Signature', '')
if not verify_signature(payload, SECRET, signature):
return jsonify({'error': 'Invalid signature'}), 401
# Parse JSON
webhook = request.get_json()
process_opt_out(webhook['data'])
# Return custom confirmation message
return 'You have successfully unsubscribed. Reply JOIN to re-subscribe.', 200
def verify_signature(payload, secret, sig):
if not sig or not sig.startswith('sha256='):
return False
expected = hmac.new(
secret.encode('utf-8'),
payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
received = sig[7:]
return hmac.compare_digest(expected, received)
def process_opt_out(data):
# Update CRM/database
unsubscribe_contact(data['from'])
# Log opt-out
print(f"Contact {data['from']} opted out via {data['keyword']} on channel {data['to']}")
# Notify team
notify_team(f"Contact {data['from']} unsubscribed")
if __name__ == '__main__':
app.run(port=3000)
JavaScript / Node.js / Express
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET = 'your-secret-here';
app.post('/webhooks/opt-out',
express.text({ type: 'application/json' }),
async (req, res) => {
// Verify signature
const signature = req.headers['x-signature'];
if (!verifySignature(req.body, SECRET, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Parse payload
const webhook = JSON.parse(req.body);
await processOptOut(webhook.data);
// Return custom confirmation message
res.send('You have successfully unsubscribed. Reply JOIN to re-subscribe.');
}
);
function verifySignature(payload, secret, sig) {
if (!sig || !sig.startsWith('sha256=')) return false;
const hmac = crypto.createHmac('sha256', secret);
hmac.update(payload, 'utf8');
const expected = hmac.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(sig.substring(7)),
Buffer.from(expected)
);
}
async function processOptOut(data) {
// Update CRM
await crm.unsubscribeContact(data.from);
// Log opt-out
console.log(`Contact ${data.from} opted out via ${data.keyword} on channel ${data.to}`);
// Notify team
await notifications.notifyTeam(`Contact ${data.from} unsubscribed`);
}
app.listen(3000);
C# / ASP.NET Core
using Microsoft.AspNetCore.Mvc;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
[ApiController]
[Route("webhooks/opt-out")]
public class OptOutWebhookController : ControllerBase
{
private readonly string _secret = "your-secret-here";
[HttpPost]
public async Task<IActionResult> ReceiveOptOut()
{
using var reader = new StreamReader(Request.Body);
string payload = await reader.ReadToEndAsync();
// Verify signature
string signature = Request.Headers["X-Signature"];
if (!VerifySignature(payload, _secret, signature))
return Unauthorized(new { error = "Invalid signature" });
// Parse payload
var webhook = JsonSerializer.Deserialize<OptOutWebhook>(payload);
await ProcessOptOut(webhook.Data);
// Return custom confirmation message
return Ok("You have successfully unsubscribed. Reply JOIN to re-subscribe.");
}
private bool VerifySignature(string payload, string secret, string sig)
{
if (string.IsNullOrEmpty(sig) || !sig.StartsWith("sha256="))
return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
var expected = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
return sig.Substring(7) == expected;
}
private async Task ProcessOptOut(OptOutData data)
{
// Update CRM/database
await _crm.UnsubscribeContact(data.From);
// Log opt-out
_logger.LogInformation(
"Contact {Mobile} opted out via {Keyword} on channel {Channel}",
data.From, data.Keyword, data.To
);
// Notify team
await _notifications.NotifyTeam($"Contact {data.From} unsubscribed");
}
}
PHP
<?php
$secret = 'your-secret-here';
// Get raw POST body
$payload = file_get_contents('php://input');
// Get signature header
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
// Verify signature
if (!verifySignature($payload, $secret, $signature)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
// Parse JSON
$webhook = json_decode($payload, true);
processOptOut($webhook['data']);
// Return custom confirmation message
http_response_code(200);
echo 'You have successfully unsubscribed. Reply JOIN to re-subscribe.';
function verifySignature($payload, $secret, $sig) {
if (empty($sig) || strpos($sig, 'sha256=') !== 0) {
return false;
}
$expected = hash_hmac('sha256', $payload, $secret);
$received = substr($sig, 7);
return hash_equals($expected, $received);
}
function processOptOut($data) {
// Update CRM/database
unsubscribeContact($data['from']);
// Log opt-out
error_log("Contact {$data['from']} opted out via {$data['keyword']} on channel {$data['to']}");
// Notify team
notifyTeam("Contact {$data['from']} unsubscribed");
}
?>
Java / Spring Boot
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@RestController
@RequestMapping("/webhooks/opt-out")
public class OptOutWebhookController {
private static final String SECRET = "your-secret-here";
@PostMapping
public ResponseEntity<String> receiveOptOut(
@RequestBody String payload,
@RequestHeader("X-Signature") String signature) {
// Verify signature
if (!verifySignature(payload, SECRET, signature)) {
return ResponseEntity.status(401)
.body("{\"error\": \"Invalid signature\"}");
}
// Parse JSON
ObjectMapper mapper = new ObjectMapper();
OptOutWebhook webhook = mapper.readValue(payload, OptOutWebhook.class);
processOptOut(webhook.getData());
// Return custom confirmation message
return ResponseEntity.ok("You have successfully unsubscribed. Reply JOIN to re-subscribe.");
}
private boolean verifySignature(String payload, String secret, String sig) {
if (sig == null || !sig.startsWith("sha256=")) {
return false;
}
try {
Mac hmac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
hmac.init(secretKey);
byte[] hash = hmac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
String expected = bytesToHex(hash);
String received = sig.substring(7);
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
received.getBytes(StandardCharsets.UTF_8)
);
} catch (Exception e) {
return false;
}
}
private String bytesToHex(byte[] bytes) {
StringBuilder result = new StringBuilder();
for (byte b : bytes) {
result.append(String.format("%02x", b));
}
return result.toString();
}
private void processOptOut(OptOutData data) {
// Update CRM/database
unsubscribeContact(data.getFrom());
// Log opt-out
System.out.println("Contact " + data.getFrom() + " opted out via " +
data.getKeyword() + " on channel " + data.getTo());
// Notify team
notifyTeam("Contact " + data.getFrom() + " unsubscribed");
}
}
What Happens After Opt-Out
- Contact Blacklisted:
Contact is immediately blacklisted in database
(
status = 0) - Status Description Set:
"OPTED OUT - 2026-07-11 08:23:45 UTC - SMS ID: 123456789" - Webhook Sent: Opt-out webhook is sent to your endpoint
- Confirmation SMS: Confirmation message sent to contact (webhook response, configured message, or default)
- Future Messages Blocked: All
future SMS to this contact will be blocked with
error:
"Contact has opted out"
Important: Once a contact opts out, they cannot
receive any SMS from your account
until they opt back in by sending a JOIN keyword.
Expected Response Codes
| Response | Action | Description |
|---|---|---|
200 OK |
Success | Webhook processed, use response as confirmation message |
204 No Content |
Success | Webhook processed, use default confirmation |
4xx Client Error |
No Retry | Use default confirmation, no retry |
5xx Server Error |
Retry | Retry webhook, use default if all fail |
Ready to Start? Configure your opt-out webhook in
your SMSDESK portal and handle unsubscribe requests professionally!
