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 application
  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, sub_type, payload } = req.body;

  if (field === 'message') {
    if (sub_type === 'message.received') {
      // Inbound message from a contact
      const { from, to, text, channel, received_at } = payload;
      await db.inboundMessages.insert({ from, to, text, channel, receivedAt: received_at });
      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);
    }
  }
});
from flask import Flask, request

app = Flask(__name__)

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

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

        if sub_type == 'message.received':
            # Inbound message from a contact
            db.inbound_messages.insert(
                from_number=p['from'], to=p['to'],
                text=p.get('text'), channel=p['channel'],
                received_at=p['received_at'])
            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)

    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.SubType == "message.received" {
            // Inbound message from a contact
            db.InboundMessages.Insert(event.Payload.From, event.Payload.To, event.Payload.Text)
            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)
        }
    }
}

Webhook Event Structure:

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

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/msg_1234567890" \
  -H "x-api-key: $SENT_API_KEY"
const message = await client.messages.retrieveStatus('msg_1234567890');
console.log(`Status: ${message.data.status}`);
console.log(`Events:`, message.data.events);
message = client.messages.retrieve_status("msg_1234567890")
print(f"Status: {message.data.status}")
print(f"Events: {message.data.events}")
message, err := client.Messages.RetrieveStatus(context.Background(), "msg_1234567890")
fmt.Printf("Status: %s\n", message.Data.Status)
fmt.Printf("Events: %v\n", message.Data.Events)
var message = client.messages().retrieveStatus("msg_1234567890");
System.out.println("Status: " + message.data().status());
System.out.println("Events: " + message.data().events());
var message = await client.Messages.RetrieveStatus("msg_1234567890");
Console.WriteLine($"Status: {message.Data.Status}");
Console.WriteLine($"Events: {message.Data.Events}");
$message = $client->messages->retrieveStatus("msg_1234567890");
echo "Status: {$message->data->status}\n";
echo "Events: " . json_encode($message->data->events) . "\n";
message = sent_dm.messages.retrieve_status("msg_1234567890")
puts "Status: #{message.data.status}"
puts "Events: #{message.data.events}"

Response:

{
  "success": true,
  "data": {
    "id": "msg_1234567890",
    "customer_id": "cust_abc123",
    "contact_id": "contact_def456",
    "phone": "+1234567890",
    "phone_international": "+1 234-567-890",
    "region_code": "US",
    "template_id": "tmpl_123",
    "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",
    "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

StatusDirectionDescriptionNext States
QUEUEDOutboundMessage accepted, awaiting processingROUTED, FAILED
ROUTEDOutboundMessage assigned to a carrier or providerSENT, FAILED
SENTOutboundDispatched to channel providerDELIVERED, FAILED
DELIVEREDOutboundConfirmed delivery to deviceREAD (WhatsApp only)
READOutboundRecipient opened message (WhatsApp only)-
FAILEDOutboundDelivery failed-
RECEIVEDInboundInbound message received from a contact-

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 (e.g. a reply, or STOP/START/HELP opt-out keywords)
const status = await client.messages.retrieveStatus('msg-uuid');
console.log(status.data.direction); // "OUTBOUND" | "INBOUND"

Inbound messages include opt-out/opt-in keyword responses (STOP/START/HELP) and general SMS 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.

Handling Failed Messages

When a message fails, the webhook event includes the FAILED status. Fetch the message via the API for full error details:

{
  "field": "message",
  "sub_type": "message.failed",
  "timestamp": "2025-01-15T08:30:15Z",
  "payload": {
    "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8",
    "message_status": "FAILED",
    "channel": "sms",
    "inbound_number": "+1234567890",
    "outbound_number": "+1987654321",
    "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8"
  }
}

Common Failure Reasons

Error CodeDescriptionAction
VALIDATION_002Phone number format issueVerify E.164 format
BUSINESS_007Recipient opted outRemove from list
BUSINESS_008Carrier rejected messageCheck content compliance
BUSINESS_005Template not approvedWait for approval or use different template
BUSINESS_003Account balance lowAdd funds
BUSINESS_002Rate limit exceededImplement backoff (200 req/min std)

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 (e.g., device offline):

if (message_status === 'DELIVERED') {
  const sentAt = new Date(eventData.created_at || message.sent_at);
  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

Track your delivery performance:

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

const delivered = stats.find(s => s._id === 'DELIVERED')?.count || 0;
const total = stats.reduce((sum, s) => sum + s.count, 0);
const deliveryRate = (delivered / total) * 100;

console.log(`Delivery rate: ${deliveryRate}%`);

Read Receipts (WhatsApp)

WhatsApp supports read receipts when the recipient opens the message:

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

Read receipts are only available for WhatsApp and only when the recipient has read receipts enabled in their privacy settings.

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 with a different payload shape from outbound status events:

{
  "field": "message",
  "sub_type": "message.received",
  "timestamp": "2025-01-15T08:35:00Z",
  "payload": {
    "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    "from": "+1234567890",
    "to": "+1987654321",
    "text": "Yes, I'd like to know more",
    "channel": "sms",
    "provider": "Telnyx",
    "received_at": "2025-01-15T08:34:58Z"
  }
}

Subscribe to message.received events when creating your webhook:

{
  "event_types": ["message"],
  "event_filters": {
    "message": ["received", "delivered", "failed"]
  }
}

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 support if stuck > 5 minutes

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