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
Method 1: Webhooks (Recommended)
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:
- Create a webhook endpoint in your app
- Configure the webhook URL in your Sent Dashboard
- 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 '', 200func 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:
- Go to the Activities page
- Filter by message status, date range, or template
- Click on any message for detailed information
- 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.
| Status | Direction | Terminal | Description | Next States |
|---|---|---|---|---|
QUEUED | Outbound | No | Message accepted, awaiting processing | ROUTED, SCHEDULED, FILTERED, BLOCKED, FAILED |
ROUTED | Outbound | No | Message assigned to a carrier or provider | SENT, FAILED |
SENT | Outbound | No | Dispatched to channel provider | DELIVERED, FAILED |
DELIVERED | Outbound | Yes | Confirmed delivery to device | READ (WhatsApp and RCS) |
READ | Outbound | Yes | Recipient opened the message (WhatsApp and RCS). Follows DELIVERED | None |
FAILED | Outbound | Yes | A 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 |
FILTERED | Outbound | Yes | A 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 |
BLOCKED | Outbound | Yes | An 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 |
SCHEDULED | Outbound | No | The send landed inside the recipient's quiet hours, so it is held instead of failed. Sent releases it automatically when the window closes. | ROUTED |
RECEIVED | Inbound | Yes | Inbound message received from a contact | None |
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:
| Value | Meaning |
|---|---|
OUTBOUND | Message sent by you to a contact |
INBOUND | Message 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 Code | Description | Action |
|---|---|---|
VALIDATION_002 | Phone number format issue | Verify E.164 format |
ERR_CONSENT_BLOCKED | Per-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_007 | Channel not available for this contact | Switch channel or rely on the configured channel fallback |
BUSINESS_008 | Carrier rejected message | Check content compliance |
BUSINESS_005 | Template not approved | Wait for approval or use a different template |
BUSINESS_003 | Account balance low | Add funds |
BUSINESS_002 | Rate limit exceeded | Implement 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:
FILTEREDfiresmessage.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.BLOCKEDfiresmessage.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.sentandmessage.deliveredevents
Missing status updates?
- Ensure webhook endpoint is responding quickly (< 5 seconds)
- Check for duplicate event handling (idempotency)
- Review failed webhook deliveries in dashboard
SMS Length, Segments & Cost
How Sent counts SMS segments, how segments map to billing, and practical tips to keep messages short and predictable
Two-Way Conversations
How Sent handles two-way conversations, from inbound matching and storage to keyword detection, opt-out handling, auto-replies, and the WhatsApp 24-hour window.