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
BLOCKEDOutboundYesAn account precondition stopped the message before send evaluation: insufficient balance, an unmet onboarding quota, or a template that is not approved for sending. Does not count against your deliverability rate.None
SCHEDULEDOutboundNoThe send landed inside the recipient's quiet hours, so it is held instead of failed. Sent releases it automatically when the 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"
  }
}

Common Failure Reasons

Error CodeDescriptionAction
VALIDATION_002Phone number format issueVerify E.164 format
ERR_CONSENT_BLOCKEDPer-message consent block: recipient has opt_out = true or is on the phone-channel suppression list. The message is finalized as FILTERED (asynchronous, surfaces on message.filtered)Suppress the contact and stop further sends until renewed consent
BUSINESS_007Channel not available for this contactSwitch channel or rely on the configured channel fallback
BUSINESS_008Carrier rejected messageCheck content compliance
BUSINESS_005Template not approvedWait for approval or use a different template
BUSINESS_003Account balance lowAdd funds
BUSINESS_002Rate limit exceededImplement 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. An account precondition stopped the send before evaluation: insufficient balance, an unmet onboarding quota, or a template that is not approved for sending.

SCHEDULED is a hold rather than an outcome. The send landed inside the recipient's quiet hours, so Sent defers it, fires message.scheduled, and releases it automatically when the window closes. The message then continues through the normal pipeline, so message.routed, message.sent, and message.delivered follow.

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"
  }
}

Sent records the internal error code and reason for a filtered, blocked, or failed message, but does not return either one in API responses or webhook payloads, and it does not expose the release time of a scheduled message. Open the message in the Sent Dashboard, or contact support@sent.dm with the message ID, if you need the exact cause.

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?

  • 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
  • The release time is not exposed through the API, so wait for the follow-on message.sent and message.delivered events

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