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. Alphanumeric sender IDs and SMPP providers are send-only; refer to the channel support tables
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 uses an SMPP provider; both are send-only | Use a long code or short code on an MO-capable provider; see SMS Provider Support |
| 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