Opt-In (JOIN) Webhooks

Receive notifications when contacts subscribe or re-subscribe to your SMS lists by sending JOIN 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 JOIN keyword to your channel, the platform automatically:

  1. Activates the contact (removes from blacklist)
  2. Sends webhook notification to your endpoint
  3. Sends confirmation SMS to the contact
  4. Allows future SMS to that contact
New: Your webhook can return a custom welcome message that will be sent to the contact instead of the default message!

Opt-In Keywords

The following keywords trigger opt-in (case-insensitive):

English Keywords

  • JOIN
  • START
  • SUBSCRIBE
  • YES
  • OPTIN
  • OPT-IN
  • OPT IN
  • UNSTOP

French Keywords

  • REJOINDRE
  • COMMENCER
  • OUI

Payload Format

POST https://your-api.com/webhooks/opt-in

Request Headers

Content-Type: application/json
X-Signature: sha256=<hmac-sha256-signature>
User-Agent: SMSDesk-Webhook/1.0

Request Payload

{
  "type": "opt_in",
  "timestamp": "2026-07-11T08:30:15.0000000Z",
  "data": {
    "from": "+264812182326",
    "to": "44880",
    "message": "JOIN",
    "keyword": "JOIN",
    "messageId": "123456790",
    "receivedAt": "2026-07-11T08:30:14.0000000Z",
    "clientId": 1059
  }
}

Payload Fields

Field Type Description
type string Always "opt_in"
timestamp string (ISO 8601) UTC timestamp when webhook was sent
data.from string Mobile number that sent the opt-in (international format)
data.to string Channel/shortcode that received the message
data.message string Full message text received
data.keyword string Detected opt-in 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 Welcome Message

Your webhook endpoint can return a custom welcome 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 "Whitelist 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

Welcome to Lexna Insurance! You are now subscribed to receive important updates and offers. Reply STOP to unsubscribe anytime.

Example Response Using Default

HTTP/1.1 200 OK
Content-Type: text/plain

Default Message Format

You, {mobile}, have successfully registered as a contact of {clientName}. To opt-out, send STOP 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-in Webhook URL: Your webhook endpoint
    • Opt-in Shared Secret: Your HMAC secret (use "Generate" button)
    • Whitelist Response: Fallback welcome 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-in', methods=['POST'])
def opt_in():
    # 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_in(webhook['data'])
    
    # Return custom welcome message
    return 'Welcome! You are now subscribed. Reply STOP to unsubscribe.', 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_in(data):
    # Update CRM/database
    subscribe_contact(data['from'])
    
    # Log opt-in
    print(f"Contact {data['from']} opted in via {data['keyword']} on channel {data['to']}")
    
    # Trigger welcome campaign
    trigger_welcome_campaign(data['from'])
    
    # Notify team
    notify_team(f"New subscriber: {data['from']}")

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-in',
    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 processOptIn(webhook.data);

        // Return custom welcome message
        res.send('Welcome! You are now subscribed. Reply STOP to unsubscribe.');
    }
);

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 processOptIn(data) {
    // Update CRM
    await crm.subscribeContact(data.from);

    // Log opt-in
    console.log(`Contact ${data.from} opted in via ${data.keyword} on channel ${data.to}`);

    // Trigger welcome campaign
    await campaigns.triggerWelcome(data.from);

    // Notify team
    await notifications.notifyTeam(`New subscriber: ${data.from}`);
}

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-in")]
public class OptInWebhookController : ControllerBase
{
    private readonly string _secret = "your-secret-here";

    [HttpPost]
    public async Task<IActionResult> ReceiveOptIn()
    {
        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<OptInWebhook>(payload);
        await ProcessOptIn(webhook.Data);

        // Return custom welcome message
        return Ok("Welcome! You are now subscribed. Reply STOP to unsubscribe.");
    }

    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 ProcessOptIn(OptInData data)
    {
        // Update CRM/database
        await _crm.SubscribeContact(data.From);

        // Log opt-in
        _logger.LogInformation(
            "Contact {Mobile} opted in via {Keyword} on channel {Channel}",
            data.From, data.Keyword, data.To
        );

        // Trigger welcome campaign
        await _campaigns.TriggerWelcomeSeries(data.From);

        // Notify team
        await _notifications.NotifyTeam($"New subscriber: {data.From}");
    }
}

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);
processOptIn($webhook['data']);

// Return custom welcome message
http_response_code(200);
echo 'Welcome! You are now subscribed. Reply STOP to unsubscribe.';

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 processOptIn($data) {
    // Update CRM/database
    subscribeContact($data['from']);
    
    // Log opt-in
    error_log("Contact {$data['from']} opted in via {$data['keyword']} on channel {$data['to']}");
    
    // Trigger welcome campaign
    triggerWelcomeCampaign($data['from']);
    
    // Notify team
    notifyTeam("New subscriber: {$data['from']}");
}
?>

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-in")
public class OptInWebhookController {
    
    private static final String SECRET = "your-secret-here";
    
    @PostMapping
    public ResponseEntity<String> receiveOptIn(
            @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();
        OptInWebhook webhook = mapper.readValue(payload, OptInWebhook.class);
        processOptIn(webhook.getData());
        
        // Return custom welcome message
        return ResponseEntity.ok("Welcome! You are now subscribed. Reply STOP to unsubscribe.");
    }
    
    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 processOptIn(OptInData data) {
        // Update CRM/database
        subscribeContact(data.getFrom());
        
        // Log opt-in
        System.out.println("Contact " + data.getFrom() + " opted in via " + 
            data.getKeyword() + " on channel " + data.getTo());
        
        // Trigger welcome campaign
        triggerWelcomeCampaign(data.getFrom());
        
        // Notify team
        notifyTeam("New subscriber: " + data.getFrom());
    }
}

What Happens After Opt-In

  1. Contact Activated: Contact is activated in database (status = 1)
  2. Status Description Set: "OPTED IN - 2026-07-11 08:30:15 UTC - SMS ID: 123456790"
  3. Webhook Sent: Opt-in webhook is sent to your endpoint
  4. Welcome SMS: Welcome message sent to contact (webhook response, configured message, or default)
  5. Future Messages Allowed: Contact can now receive SMS from your account
Note: If a contact was previously opted out, the opt-in will reactivate them and they will start receiving messages again.

Expected Response Codes

Response Action Description
200 OK Success Webhook processed, use response as welcome message
204 No Content Success Webhook processed, use default welcome
4xx Client Error No Retry Use default welcome, no retry
5xx Server Error Retry Retry webhook, use default if all fail

Use Cases

Ready to Start? Configure your opt-in webhook in your SMSDESK portal and welcome new subscribers professionally!