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. Check number_type for the market on GET /v3/channels: ALPHANUMERIC is 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 '', 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 runs over one of Sent's SMPP connections, which has no inbound pathRead 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 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