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 →

Overview

When a contact sends a STOP keyword to your channel, the platform automatically:

  1. Blacklists the contact immediately
  2. Sends webhook notification to your endpoint
  3. Sends confirmation SMS to the contact
  4. 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

  • STOP
  • UNSUBSCRIBE
  • CANCEL
  • END
  • QUIT
  • OPTOUT
  • OPT-OUT
  • OPT OUT
  • REMOVE
  • STOPALL
  • UNSUB

French Keywords

  • ARRET
  • ARRETER

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

  1. Webhook Response (Highest Priority)
    • If your endpoint returns 200 OK with plain text body
    • That message will be sent to the contact
    • HTML tags are automatically stripped
  2. Configured Message (Medium Priority)
    • If webhook returns empty or fails
    • Uses "Blacklist Response" from web portal configuration
  3. 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

  1. Log in to your SMSDESK portal (e.g., https://desk.sms.com.na for Namibia deployment)
  2. Navigate to Account → SMS Channels
  3. Click on your channel to edit
  4. 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)
  5. 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

  1. Contact Blacklisted: Contact is immediately blacklisted in database (status = 0)
  2. Status Description Set: "OPTED OUT - 2026-07-11 08:23:45 UTC - SMS ID: 123456789"
  3. Webhook Sent: Opt-out webhook is sent to your endpoint
  4. Confirmation SMS: Confirmation message sent to contact (webhook response, configured message, or default)
  5. 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!