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 →
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:
- Activates the contact (removes from blacklist)
- Sends webhook notification to your endpoint
- Sends confirmation SMS to the contact
- 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
JOINSTARTSUBSCRIBEYESOPTINOPT-INOPT INUNSTOP
French Keywords
REJOINDRECOMMENCEROUI
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
- 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 "Whitelist 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
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
- 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-in Webhook URL: Your webhook endpoint
- Opt-in Shared Secret: Your HMAC secret (use "Generate" button)
- Whitelist Response: Fallback welcome 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-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
- Contact Activated:
Contact is activated in database (
status = 1) - Status Description Set:
"OPTED IN - 2026-07-11 08:30:15 UTC - SMS ID: 123456790" - Webhook Sent: Opt-in webhook is sent to your endpoint
- Welcome SMS: Welcome message sent to contact (webhook response, configured message, or default)
- 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
- Re-subscription: Previously opted-out contacts can opt back in
- New subscribers: New contacts joining your SMS list
- Campaign enrollment: Contacts joining specific campaigns
- Consent management: Explicit consent tracking for compliance
Ready to Start? Configure your opt-in webhook in
your SMSDESK portal and welcome new subscribers professionally!
