Receiving Inbound Messages via the message.received Webhook
This guide shows you how to process contact replies in real time: identify message.received events, acknowledge and deduplicate deliveries, store the inbound message, and trigger a follow-up flow for non-keyword replies.
Prerequisites:
- A webhook endpoint registered in the Sent Dashboard and subscribed to
messageevents; see Webhook Setup - A channel that can receive inbound messages. Check
number_typefor the market onGET /v3/channels:ALPHANUMERICis send-only, and no inbound event will ever arrive for it. For the other sender types, whether the market has an inbound path depends on the provider Sent routes it through, which the API does not report, so ask support@sent.dm if you need it confirmed for a market. Per-channel behavior is in the channel support table
Build the Inbound Handler
Identify inbound events
Sent fires message.received for every inbound message, free-text replies and compliance keywords alike. Filter on the envelope's field and event values:
{
"field": "message",
"event": "message.received",
"timestamp": "2025-01-15T08:35:00Z",
"payload": {
"message_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"updated_at": "2025-01-15T08:34:58Z",
"account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
"inbound_number": "+1234567890",
"outbound_number": "+1987654321",
"text": "Yes, tell me more",
"channel": "sms",
"received_at": "2025-01-15T08:34:58Z"
}
}inbound_number is the contact's phone number; outbound_number is your provisioned number that received the message. The Events Reference documents every payload field.
Acknowledge, then deduplicate
Respond with a 2xx status before doing any processing; slow handlers cause redeliveries, and Sent retries failed deliveries anyway (see Handling Retries). If your endpoint verifies signatures, verify before acknowledging; see Signature Verification.
Use payload.message_id as your idempotency key. The same event can be delivered more than once, so deduplicate on message_id before processing to avoid double inserts or duplicate follow-up flows.
Store the message and trigger follow-ups
Store every inbound for your CRM or conversation view, then branch: if the text is a compliance keyword, do nothing, because Sent's consent engine already handles it. If it is a real reply, trigger your follow-up flow.
import express from 'express';
const app = express();
app.use(express.json());
app.post('/webhooks/sent', async (req, res) => {
res.sendStatus(200); // Always acknowledge quickly
const { field, event, payload } = req.body;
if (field === 'message' && event === 'message.received') {
const { message_id, inbound_number, outbound_number, text, channel, received_at } = payload;
// Store inbound for your CRM / conversation view
await db.inbound.insert({ messageId: message_id, from: inbound_number, to: outbound_number, text, channel, receivedAt: received_at });
// Optionally trigger a follow-up flow
const COMPLIANCE_KEYWORDS = ['stop', 'cancel', 'unsubscribe', 'quit', 'end', 'start', 'unstop', 'subscribe', 'help', 'info'];
if (text && !COMPLIANCE_KEYWORDS.includes(text.toLowerCase().trim())) {
await triggerResponseFlow({ from: inbound_number, channel });
}
}
});from flask import Flask, request
app = Flask(__name__)
@app.route('/webhooks/sent', methods=['POST'])
def handle_webhook():
data = request.json
if data['field'] == 'message' and data.get('event') == 'message.received':
p = data['payload']
db.inbound.insert(
message_id=p['message_id'],
from_number=p['inbound_number'], to=p['outbound_number'],
text=p.get('text'), channel=p['channel'],
received_at=p['received_at']
)
compliance_keywords = {'STOP', 'CANCEL', 'UNSUBSCRIBE', 'QUIT', 'END', 'START', 'UNSTOP', 'SUBSCRIBE', 'HELP', 'INFO'}
text = p.get('text')
if text and text.strip().upper() not in compliance_keywords:
trigger_response_flow(from_number=p['inbound_number'], channel=p['channel'])
return '', 200package main
import (
"encoding/json"
"net/http"
"strings"
)
type Payload struct {
MessageID string `json:"message_id"`
InboundNumber string `json:"inbound_number"`
OutboundNumber string `json:"outbound_number"`
Text string `json:"text"`
Channel string `json:"channel"`
ReceivedAt string `json:"received_at"`
}
type WebhookEvent struct {
Field string `json:"field"`
Event string `json:"event"`
Payload Payload `json:"payload"`
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
var event WebhookEvent
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
if event.Field == "message" && event.Event == "message.received" {
p := event.Payload
db.Inbound.Insert(p.MessageID, p.InboundNumber, p.OutboundNumber, p.Text, p.Channel, p.ReceivedAt)
complianceKeywords := map[string]bool{
"STOP": true, "CANCEL": true, "UNSUBSCRIBE": true, "QUIT": true, "END": true,
"START": true, "UNSTOP": true, "SUBSCRIBE": true, "HELP": true, "INFO": true,
}
if p.Text != "" && !complianceKeywords[strings.ToUpper(strings.TrimSpace(p.Text))] {
triggerResponseFlow(p.InboundNumber, p.Channel)
}
}
}[HttpPost("/webhooks/sent")]
public async Task<IActionResult> HandleWebhook([FromBody] JsonElement body)
{
var field = body.GetProperty("field").GetString();
var evt = body.GetProperty("event").GetString();
if (field == "message" && evt == "message.received")
{
var payload = body.GetProperty("payload");
var messageId = payload.GetProperty("message_id").GetString();
var from = payload.GetProperty("inbound_number").GetString();
var to = payload.GetProperty("outbound_number").GetString();
var text = payload.TryGetProperty("text", out var t) ? t.GetString() : null;
var channel = payload.GetProperty("channel").GetString();
var receivedAt = payload.GetProperty("received_at").GetString();
await _db.Inbound.InsertAsync(new InboundMessage
{
MessageId = messageId, From = from, To = to, Text = text,
Channel = channel, ReceivedAt = receivedAt
});
var complianceKeywords = new HashSet<string>
{ "STOP", "CANCEL", "UNSUBSCRIBE", "QUIT", "END", "START", "UNSTOP", "SUBSCRIBE", "HELP", "INFO" };
if (text != null && !complianceKeywords.Contains(text.Trim().ToUpper()))
{
await _flowService.TriggerResponseFlowAsync(from, channel);
}
}
return Ok();
}If you configured custom keywords in the dashboard, add them to the keyword set. Match them the way Sent does (the entire trimmed text equals the keyword, case-insensitive); see the matching rules.
Verify the handler
From a test phone, text a free-form reply to your provisioned number: a message.received event arrives, your store gains a row, and your follow-up flow triggers. Then text STOP: the event still arrives, but your follow-up flow must not trigger; Sent records the opt-out on its own. Delivery attempts, HTTP status codes, and response bodies for each event are visible per webhook in the Sent Dashboard.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
No message.received events arrive | The sender is an alphanumeric sender ID, or the route runs over one of Sent's SMPP connections, which has no inbound path | Read number_type for the market on GET /v3/channels. ALPHANUMERIC can never receive; for the other sender types, ask support@sent.dm whether that market has an inbound path |
| Events stopped arriving on one number | The number is no longer provisioned or assigned, so inbounds cannot be matched to your account and are dropped | Verify the provider and number under your channel settings |
| The same reply is processed twice | Redelivered event | Deduplicate on payload.message_id before processing |
Follow-up flow fires for STOP | Keyword filter does not match Sent's rule | Compare the trimmed, case-normalized text against the full keyword list, defaults plus custom |
Related pages
- Two-Way Conversations: how inbound matching, storage, and keyword handling work
- Events Reference: the full
message.receivedpayload, per channel - Two-Way Messaging Reference: keyword list, matching rules, and channel support
- Handling Opt-Outs and Consent: mirror keyword opt-outs into your own database