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 message events; 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 '', 200
package 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

SymptomLikely causeFix
No message.received events arriveThe sender is an alphanumeric sender ID or the route uses an SMPP provider; both are send-onlyUse a long code or short code on an MO-capable provider; see SMS Provider Support
Events stopped arriving on one numberThe number is no longer provisioned or assigned, so inbounds cannot be matched to your account and are droppedVerify the provider and number under your channel settings
The same reply is processed twiceRedelivered eventDeduplicate on payload.message_id before processing
Follow-up flow fires for STOPKeyword filter does not match Sent's ruleCompare the trimmed, case-normalized text against the full keyword list, defaults plus custom

On this page