Message Status Tracking

Track the delivery status of your messages in real-time using webhooks, the API, or the Sent Dashboard.

Overview

After sending a message, it progresses through several statuses:

Tracking Methods

Receive real-time status updates via HTTP callbacks to your server.

Advantages:

  • Real-time updates (within seconds)
  • No polling required
  • Scalable for high volume

Setup:

  1. Create a webhook endpoint in your app
  2. Configure the webhook URL in your Sent Dashboard
  3. Handle incoming events
import express from 'express';

const app = express();
app.use(express.json());

app.post('/webhooks/sent', async (req, res) => {
  res.sendStatus(200); // Acknowledge quickly

  const { field, event, payload } = req.body;

  if (field === 'message') {
    if (event === 'message.received') {
      // Inbound message — see the Two-Way Conversations guide
      await handleInboundMessage(payload);
      return;
    }

    // Outbound message status update
    const { message_id, message_status, channel } = payload;

    // Update your database
    await db.messages.update(message_id, {
      status: message_status,
      channel: channel,
      updatedAt: new Date()
    });

    // Trigger business logic
    if (message_status === 'DELIVERED') {
      await handleDeliveryConfirmation(message_id);
    } else if (message_status === 'FAILED') {
      await handleDeliveryFailure(message_id);
    } else if (message_status === 'FILTERED' || message_status === 'BLOCKED') {
      // Terminal, but not a delivery failure: no carrier attempt was made
      await handleSuppressed(message_id, message_status);
    } else if (message_status === 'SCHEDULED') {
      // Held until the recipient's quiet hours end, then released automatically
      await markHeld(message_id);
    }
  }
});
from flask import Flask, request

app = Flask(__name__)

@app.route('/webhooks/sent', methods=['POST'])
def handle_webhook():
    data = request.json
    field = data['field']
    event = data.get('event')

    if field == 'message':
        p = data['payload']

        if event == 'message.received':
            # Inbound message — see the Two-Way Conversations guide
            handle_inbound_message(p)
            return '', 200

        # Outbound message status update
        message_id     = p['message_id']
        message_status = p['message_status']
        channel        = p['channel']

        # Update database
        db.messages.update(message_id, status=message_status, channel=channel)

        # Business logic
        if message_status == 'DELIVERED':
            handle_delivery_confirmation(message_id)
        elif message_status == 'FAILED':
            handle_delivery_failure(message_id)
        elif message_status in ('FILTERED', 'BLOCKED'):
            # Terminal, but not a delivery failure
            handle_suppressed(message_id, message_status)
        elif message_status == 'SCHEDULED':
            # Held until quiet hours end, then released automatically
            mark_held(message_id)

    return '', 200
func webhookHandler(w http.ResponseWriter, r *http.Request) {
    var event WebhookEvent
    json.NewDecoder(r.Body).Decode(&event)

    w.WriteHeader(http.StatusOK) // Acknowledge quickly

    if event.Field == "message" {
        if event.Event == "message.received" {
            // Inbound message — see the Two-Way Conversations guide
            handleInboundMessage(event.Payload)
            return
        }

        // Outbound message status update
        messageID     := event.Payload.MessageID
        messageStatus := event.Payload.MessageStatus
        channel       := event.Payload.Channel

        // Update database
        db.Messages.Update(messageID, messageStatus, channel)

        // Business logic
        if messageStatus == "DELIVERED" {
            handleDeliveryConfirmation(messageID)
        } else if messageStatus == "FAILED" {
            handleDeliveryFailure(messageID)
        } else if messageStatus == "FILTERED" || messageStatus == "BLOCKED" {
            // Terminal, but not a delivery failure
            handleSuppressed(messageID, messageStatus)
        } else if messageStatus == "SCHEDULED" {
            // Held until quiet hours end, then released automatically
            markHeld(messageID)
        }
    }
}

Webhook Event Structure:

{
  "field": "message",
  "event": "message.delivered",
  "timestamp": "2025-01-15T08:30:15Z",
  "payload": {
    "updated_at": "2025-01-15T08:30:15Z",
    "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8",
    "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8",
    "template_name": "order_confirmation",
    "outbound_number": "+1987654321",
    "message_status": "DELIVERED",
    "channel": "sms"
  }
}

See the Webhooks Guide for complete setup instructions.

Method 2: API Polling

Query message status via the API. Useful for one-off checks or debugging.

curl "https://api.sent.dm/v3/messages/8ba7b830-9dad-11d1-80b4-00c04fd430c8" \
  -H "x-api-key: $SENT_API_KEY"
const message = await client.messages.retrieveStatus('8ba7b830-9dad-11d1-80b4-00c04fd430c8');
console.log(`Status: ${message.data.status}`);
console.log(`Events:`, message.data.events);
message = client.messages.retrieve_status("8ba7b830-9dad-11d1-80b4-00c04fd430c8")
print(f"Status: {message.data.status}")
print(f"Events: {message.data.events}")
message, err := client.Messages.RetrieveStatus(context.Background(), "8ba7b830-9dad-11d1-80b4-00c04fd430c8")
fmt.Printf("Status: %s\n", message.Data.Status)
fmt.Printf("Events: %v\n", message.Data.Events)
var message = client.messages().retrieveStatus("8ba7b830-9dad-11d1-80b4-00c04fd430c8");
System.out.println("Status: " + message.data().status());
System.out.println("Events: " + message.data().events());
var message = await client.Messages.RetrieveStatus("8ba7b830-9dad-11d1-80b4-00c04fd430c8");
Console.WriteLine($"Status: {message.Data.Status}");
Console.WriteLine($"Events: {message.Data.Events}");
$message = $client->messages->retrieveStatus("8ba7b830-9dad-11d1-80b4-00c04fd430c8");
echo "Status: {$message->data->status}\n";
echo "Events: " . json_encode($message->data->events) . "\n";
message = sent_dm.messages.retrieve_status("8ba7b830-9dad-11d1-80b4-00c04fd430c8")
puts "Status: #{message.data.status}"
puts "Events: #{message.data.events}"

Response:

{
  "success": true,
  "data": {
    "id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8",
    "customer_id": "5ba7b800-9dad-11d1-80b4-00c04fd430c8",
    "contact_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
    "phone": "+1234567890",
    "phone_international": "+1 234-567-890",
    "region_code": "US",
    "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8",
    "template_name": "order_confirmation",
    "template_category": "UTILITY",
    "channel": "sms",
    "message_body": {
      "header": null,
      "content": "Your order #12345 has been shipped!",
      "footer": null,
      "buttons": null
    },
    "status": "DELIVERED",
    "direction": "OUTBOUND",
    "created_at": "2025-01-15T08:30:00Z",
    "price": 0.0055,
    "active_contact_price": 0.001,
    "events": [
      { "status": "QUEUED", "timestamp": "2025-01-15T08:30:00Z", "description": "Message queued for sending" },
      { "status": "SENT", "timestamp": "2025-01-15T08:30:01Z", "description": "Message sent via SMS" },
      { "status": "DELIVERED", "timestamp": "2025-01-15T08:30:15Z", "description": "Message delivered to recipient" }
    ]
  },
  "error": null,
  "meta": {
    "request_id": "req_xyz789",
    "timestamp": "2025-01-15T08:30:16Z",
    "version": "v3"
  }
}

Don't poll for status updates in production. Use webhooks instead. Polling is only recommended for debugging or one-off checks.

Method 3: Dashboard

View message status in the Sent Dashboard:

  1. Go to the Activities page
  2. Filter by message status, date range, or template
  3. Click on any message for detailed information
  4. View delivery timeline and any errors

Status Reference

A status is terminal when no further status events follow it. The one exception is READ, which can still arrive after a terminal DELIVERED on WhatsApp and RCS.

StatusDirectionTerminalDescriptionNext States
QUEUEDOutboundNoMessage accepted, awaiting processingROUTED, SCHEDULED, FILTERED, BLOCKED, FAILED
ROUTEDOutboundNoMessage assigned to a carrier or providerSENT, FAILED
SENTOutboundNoDispatched to channel providerDELIVERED, FAILED
DELIVEREDOutboundYesConfirmed delivery to deviceREAD (WhatsApp and RCS)
READOutboundYesRecipient opened the message (WhatsApp and RCS). Follows DELIVEREDNone
FAILEDOutboundYesA send was attempted and failed downstream: carrier reject, network error, invalid number, or no route matched. The only status that counts against your deliverability rate.None
FILTEREDOutboundYesA policy gate suppressed the message before dispatch: the recipient opted out, the number is on your suppression list, or a routing rule denied the send. Expected behavior, not a failure, so it does not count against your deliverability rate.None
BLOCKEDOutboundYesA precondition stopped the message before send evaluation: insufficient balance, an unmet onboarding quota, a template that is not approved for sending, or a free-form send to a contact with no open conversation. Does not count against your deliverability rate.None
SCHEDULEDOutboundNoMessage is held until a future release time. Triggered by a scheduled_at value on the send request, or when the send time falls inside the recipient's quiet hours. Sent releases the message automatically when the release instant arrives or the quiet-hours window closes.ROUTED
RECEIVEDInboundYesInbound message received from a contactNone

Every status change fires a matching webhook event, including message.filtered, message.blocked, and message.scheduled. See the Events Reference for the full status catalog and every webhook payload shape.

Direction Field

Every message retrieved via retrieveStatus includes a direction field:

ValueMeaning
OUTBOUNDMessage sent by you to a contact
INBOUNDMessage received from an end user (such as a reply, or STOP/START/HELP opt-out keywords)
const status = await client.messages.retrieveStatus('8ba7b830-9dad-11d1-80b4-00c04fd430c8');
console.log(status.data.direction); // "OUTBOUND" | "INBOUND"

Inbound messages include opt-out/opt-in keyword responses (STOP/START/HELP on SMS), general SMS replies, and WhatsApp replies. They have a direction of "INBOUND" and a status of "RECEIVED". Subscribe to message.received webhooks to be notified in real time when a contact sends you a message on any channel. See Two-Way Conversations for inbound handling.

Handling Failed Messages

When a send fails downstream, Sent fires message.failed. The payload reports the status but not the cause:

{
  "field": "message",
  "event": "message.failed",
  "timestamp": "2025-01-15T08:30:15Z",
  "payload": {
    "updated_at": "2025-01-15T08:30:15Z",
    "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8",
    "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8",
    "template_name": "order_confirmation",
    "outbound_number": "+1987654321",
    "message_status": "FAILED",
    "channel": "sms",
    "body": "Hi Jane, your order has been confirmed.",
    "reason_code": "DELIVERY_013",
    "reason": "The recipient could not be reached: their phone was switched off, out of coverage, busy or out of message storage, or their line was not active on the network when delivery was attempted"
  }
}

Common Failure Reasons

message.failed, message.filtered and message.blocked carry payload.reason_code and payload.reason, and GET /v3/messages/{id} reports the same pair. Branch on reason_code; show reason to a person. Every code is in the Error Catalog.

reason_codeStatusDescriptionAction
VALIDATION_002FAILEDThe recipient's number is invalid or not in serviceVerify the number; stop sending to it
BUSINESS_004FILTEREDThe recipient opted out or is on your phone-channel suppression listSuppress the contact until renewed consent
BUSINESS_003BLOCKEDThe account balance is too lowAdd funds
BUSINESS_020BLOCKEDThe account reached its onboarding-stage message limitComplete the next onboarding step
BUSINESS_005BLOCKED or FAILEDThe template is not approved for sendingWait for approval or use a different template
DELIVERY_004FILTEREDA routing rule denied the destination, or its sender is still being set upCheck the market with GET /v3/channels
DELIVERY_012FAILEDCarrier screening blocked the messageRevise the content; check the sender's registration
DELIVERY_013FAILEDThe recipient's handset couldn't be reachedResend later
BUSINESS_002FAILEDSending was throttledImplement backoff

Suppressed and Held Messages

FILTERED and BLOCKED are terminal non-delivery outcomes. Unlike FAILED, no send attempt ever reached a carrier, so neither counts against your deliverability rate and neither is retried:

  • FILTERED fires message.filtered. A policy gate suppressed the send: the recipient opted out, the number is on your phone-channel suppression list, or routing rules denied every candidate route.
  • BLOCKED fires message.blocked. A precondition stopped the send before evaluation: insufficient balance, an unmet onboarding quota, a template that is not approved for sending, or a free-form send to a contact who has never replied and has no template inside the 7-day window (see Conversation Windows).

SCHEDULED is a hold rather than an outcome. It is triggered in two ways: by a scheduled_at value on the send request (the message parks until that instant), or when the send lands inside the recipient's quiet hours (Sent defers it until the window closes). In both cases, message.scheduled fires when the message is first held, the message continues through the normal pipeline once released, and message.routed, message.sent, and message.delivered follow. If a quiet-hours window shifts the release time of a scheduled_at message, message.scheduled fires again with the updated release instant.

All three use the same payload shape as any other outbound status event:

{
  "field": "message",
  "event": "message.filtered",
  "timestamp": "2025-01-15T08:30:02Z",
  "payload": {
    "updated_at": "2025-01-15T08:30:02Z",
    "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8",
    "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8",
    "template_name": "order_confirmation",
    "outbound_number": "+1987654321",
    "message_status": "FILTERED",
    "channel": "sms",
    "body": "Hi Jane, your order has been confirmed.",
    "reason_code": "BUSINESS_004",
    "reason": "The contact has opted out: they unsubscribed or replied STOP to this sender, or they stopped marketing messages from this business on WhatsApp"
  }
}

A filtered, blocked, or failed message says why in reason_code and reason, on the webhook and on GET /v3/messages/{id}. Both are omitted on every other status.

Best Practices

1. Implement Idempotency

Webhooks may be delivered multiple times. Handle this gracefully:

async function handleWebhook(eventData: any) {
  const { message_id, message_status } = eventData.payload;

  // Check if already processed
  const existing = await db.messages.findById(message_id);
  if (existing?.status === message_status) {
    return; // Already at this status — skip
  }

  // Process update
  await db.messages.update(message_id, { status: message_status });
}

2. Queue Webhook Processing

Don't do heavy work in the webhook handler:

app.post('/webhooks/sent', async (req, res) => {
  // Acknowledge immediately
  res.sendStatus(200);

  // Queue for background processing
  await queue.add('process-webhook', req.body);
});

// Worker processes in background
queue.process('process-webhook', async (job) => {
  await processWebhookEvent(job.data);
});

3. Handle Late Deliveries

Some messages may be delivered hours later (for example, when the device is offline):

if (message_status === 'DELIVERED') {
  const sentAt = new Date(message.sent_at); // Your stored send time
  const deliveredAt = new Date(eventData.timestamp);
  const delayHours = (deliveredAt - sentAt) / (1000 * 60 * 60);

  if (delayHours > 1) {
    console.log(`Late delivery: ${delayHours} hours`);
  }
}

4. Monitor Delivery Rates

Leave FILTERED and BLOCKED out of the deliverability denominator. They represent policy suppression, not delivery failures, so only FAILED counts against the rate. SCHEDULED messages have not reached an outcome yet, so leave those out too until they are released.

// Daily delivery rate: exclude FILTERED, BLOCKED, SCHEDULED, RECEIVED from the denominator
const stats = await db.messages.aggregate([
  {
    $match: {
      createdAt: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) },
      direction: 'OUTBOUND'
    }
  },
  {
    $group: {
      _id: '$status',
      count: { $sum: 1 }
    }
  }
]);

const byStatus = Object.fromEntries(stats.map(s => [s._id, s.count]));

const delivered = byStatus['DELIVERED'] || 0;
// Denominator: only statuses that represent a send attempt
const denominator = ['QUEUED', 'ROUTED', 'SENT', 'DELIVERED', 'READ', 'FAILED']
  .reduce((sum, s) => sum + (byStatus[s] || 0), 0);

const deliveryRate = denominator > 0 ? (delivered / denominator) * 100 : 0;

console.log(`Delivery rate: ${deliveryRate.toFixed(1)}%`);
console.log(`Filtered (policy suppressed): ${byStatus['FILTERED'] || 0}`);
console.log(`Blocked (account-level gate): ${byStatus['BLOCKED'] || 0}`);

Read Receipts (WhatsApp & RCS)

WhatsApp and RCS both support read receipts when the recipient opens the message. The message.read event is fired for both channels. Check the channel field in the payload to distinguish them:

{
  "field": "message",
  "event": "message.read",
  "timestamp": "2025-01-15T09:15:30Z",
  "payload": {
    "updated_at": "2025-01-15T09:15:30Z",
    "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8",
    "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8",
    "template_name": "order_confirmation",
    "outbound_number": "+1987654321",
    "message_status": "READ",
    "channel": "rcs"
  }
}

Read receipts are available for WhatsApp (when the recipient has read receipts enabled in privacy settings) and for RCS. SMS has no read receipt equivalent.

Inbound Messages (message.received)

When a contact sends a message to one of your provisioned numbers (a reply, or a keyword like STOP/START/HELP), Sent fires a message.received webhook whose payload shape differs from outbound status events. The Two-Way Conversations guide owns inbound handling: storage, keyword and opt-out processing, auto-replies, and handler examples. The inbound payload shape is in the Events Reference.

The inbound message is also stored in your message log with direction: "INBOUND" and status: "RECEIVED". You can retrieve it via GET /v3/messages/{id} like any outbound message.

Troubleshooting

Webhook not receiving events?

  • Verify webhook URL is accessible from the internet
  • Check that your endpoint returns 2xx status
  • Review webhook delivery logs in the dashboard
  • Verify the webhook is configured for the correct event types

Status stuck in QUEUED?

  • Normal for first few seconds
  • Check if account has sufficient balance
  • Verify KYC is approved
  • Contact Sent if stuck > 5 minutes

Status stuck in SCHEDULED?

  • If triggered by quiet hours: the send landed inside the recipient's quiet hours and is held, not lost. Sent releases it automatically when the window closes; no action is required.
  • If triggered by scheduled_at: the message is intentionally parked until the release instant you specified. Check the scheduled_at field via GET /v3/messages/{id} to confirm the release time.
  • The follow-on message.sent and message.delivered events confirm dispatch after release.

Missing status updates?

  • Ensure webhook endpoint is responding quickly (< 5 seconds)
  • Check for duplicate event handling (idempotency)
  • Review failed webhook deliveries in dashboard

On this page