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 & RCS)
READOutboundRecipient opened message (WhatsApp & RCS)-
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 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.

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_004All recipients opted out (synchronous send rejection)Remove opted-out contacts and retry
ERR_CONSENT_BLOCKEDPer-message consent block — recipient has opt_out = true or is on the phone-channel suppression listSuppress 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

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 & 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",
  "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": "rcs",
    "inbound_number": "+1234567890",
    "outbound_number": "+1987654321",
    "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8"
  }
}

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 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": "<provider-name>",
    "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 Sent 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