Blog

Engineering

Message status webhooks: sent, delivered, failed

July 23, 2026·6 min read

Messages don't always arrive. Carriers drop them, devices stay offline, phone numbers get flagged, and an iMessage can quietly fall back to SMS. Each of these failures is rare on its own and constant in aggregate. At any real volume, some fraction of your sends is failing right now.

The send call cannot tell you this. When you POST a message to the Linq API, the response comes back before the message has gone anywhere. A 200 means we accepted your request and queued it for processing. It does not mean the message reached a device, and it does not mean it ever will.

Message status webhooks are how we report what happened after that. In this post we'll walk through the lifecycle Linq emits over webhooks, why sent, delivered, and failed each carry information you can't get any other way, and how to build an endpoint that holds up in production.

The send response is an acknowledgement

A send is asynchronous. When we receive your request, we return quickly and queue the work: dispatch over iMessage, RCS, or SMS, negotiate the protocol, and retry transient upstream errors. The response confirms we have the request. The delivery outcome is decided later, over the following seconds to minutes.

We report that outcome as a small set of webhook event types. Each one sets a different timestamp on the message.

EventMeaningTimestamp set
message.sentWe sent the message from your number. delivered_at and read_at are still null.sent_at
message.deliveredThe message reached the recipient's device.delivered_at
message.readThe recipient opened it (requires read receipts enabled).read_at
message.failedHard delivery failure, with an error code and reason.failed_at

Every event arrives in a common envelope: an event_type, a unique event_id, a trace_id for debugging, and an event-specific data block. Here is a message.sent payload.

{
  "api_version": "v3",
  "webhook_version": "2026-02-03",
  "event_type": "message.sent",
  "event_id": "e20feb41-7f67-43f0-89c8-a985cff3b568",
  "trace_id": "2eff5df5c6f688733c007523c4d61cd9",
  "data": {
    "id": "347d62c2-2170-4754-8d30-c76d0c727d96",
    "direction": "outbound",
    "sent_at": "2026-02-05T19:52:17.219Z",
    "delivered_at": null,
    "read_at": null,
    "service": "iMessage"
  }
}

The state machine matters more than any single event. sent to delivered to read is the path a healthy message follows. A sent that turns into failed, or a sent that never advances to delivered, is where your logic should react.

What each status tells you

message.sent confirms dispatch and gives you a latency baseline. It means we got the message out of the queue and onto the network. Its sent_at timestamp is the anchor for everything downstream. For time-sensitive traffic like one-time passcodes, fraud alerts, and appointment confirmations, the gap between your API call and message.sent is where a latency regression first shows up. Without this event, you are inferring dispatch from the absence of an error, which is a weaker signal.

message.delivered confirms the message reached a device. This is the event to build delivery-rate dashboards on, broken out per number, per campaign, and per protocol. It is also the correct trigger for anything that should not fire early: start a "check your phone" countdown, escalate to a fallback channel, or mark a reminder as delivered in your CRM only once delivered_at is set. Treating sent as if it were delivered is a common and expensive mistake, because it hides real delivery failures behind an optimistic UI.

message.failed tells you a send failed and why. The event carries a code that maps to our error code list, a human-readable reason, and a failed_at.

{
  "event_type": "message.failed",
  "data": {
    "chat_id": "550e8400-e29b-41d4-a716-446655440000",
    "message_id": "550e8400-e29b-41d4-a716-446655440001",
    "code": 4001,
    "reason": "Delivery failed",
    "failed_at": "2025-11-23T17:35:00.000Z"
  }
}

The code is what makes the failure actionable. A transient timeout (4001) is worth an automatic retry or a protocol fallback. An invalid-recipient error is not, and retrying it wastes throughput and reputation. Route failures into a dead-letter queue, alert on failure-rate spikes, and feed the reasons back into list hygiene. A failed event you drop is a customer who never got their code and a support ticket you pay for later.

Not every protocol emits every status

This is the detail that separates a robust integration from a brittle one. SMS and MMS have no delivery-receipt or read-receipt mechanism at the protocol level. An SMS send produces message.sent when we accept it for delivery and message.failed on a hard failure. It never produces message.delivered or message.read, because those receipts do not exist on the protocol. iMessage and RCS do emit delivered, and emit read when the recipient has read receipts enabled.

The failure mode here is subtle. If your logic waits for delivered before treating a message as successful, every SMS fallback in your system will look permanently stuck. Read the service field on the event, and enforce a delivery SLA only on the protocols that can report one. We publish the full protocol capability matrix so you can encode this up front rather than discover it in production.

Consuming webhooks in production

You can poll GET message details to check is_delivered and is_read, but polling at scale is wasteful, slow, and rate-limited. Webhooks push the state change the moment it happens. In return, your endpoint is now receiving traffic from a distributed system, and it has to be built for that. Our delivery guarantees state the contract, and each clause has a consequence for your handler.

Delivery is at-least-once, so duplicates are possible. Every event carries an event_id. Use it as an idempotency key and deduplicate. Processing the same delivered twice should be a no-op.

The response timeout is 10 seconds, after which we retry. Return 200 immediately and do the real work asynchronously. If you run business logic inline and it takes 11 seconds, we treat the delivery as failed and retry, and now you are processing duplicates and timing out at once.

We retry up to 10 times over roughly 25 minutes with exponential backoff on 5xx, 429, and connection errors. A brief outage on your side will not drop events. We do not retry 4xx responses other than 429. If your handler rejects a valid event with a 400, that event is gone.

Events can arrive out of order. Do not assume delivered reaches you after sent in wall-clock time. Order your state machine by the timestamps on the events themselves (sent_at, delivered_at, read_at), not by the order you received them.

Because these events can trigger money movement and customer-facing actions, verify signatures. We sign every request using the Standard Webhooks scheme: an HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{body}, a whsec_-prefixed secret, and a 5-minute replay window. The SDKs reduce it to one call.

import LinqAPIV3 from '@linqapp/sdk';

const client = new LinqAPIV3(); // reads LINQ_WEBHOOK_SECRET from env

// In your webhook handler. Pass the RAW body, not parsed JSON:
const event = client.webhooks.unwrap(rawBody, { headers: req.headers });
// Throws on an invalid signature. `event` is a fully typed, discriminated union.

One more thing worth doing up front: pin your webhook version with ?version=YYYY-MM-DD on the subscription URL. Payload shapes change over time. The 2026-02-03 version added message.edited and a health_status block that older subscriptions do not receive. Pinning keeps a platform update from reshaping the JSON your parser depends on.

What this unlocks

Once status events are flowing, a set of features becomes straightforward. You can show accurate "Sent, Delivered, Read" state in your own UI instead of guessing. You can compute delivery rates per phone number and catch a flagged or unhealthy number before it degrades a campaign; we push phone_number.status_updated events for exactly that case. You can drive fallback logic, close the loop on two-factor flows, and attach a trace_id to every failure for end-to-end debugging.

A few principles to consume these events well:

  • Treat sent and delivered as different states. One means we dispatched the message; the other means it arrived. Only the second is delivery.
  • Be protocol-aware. Expect delivered and read on iMessage and RCS, and expect their absence on SMS and MMS.
  • Make the handler idempotent. Deduplicate on event_id and order by event timestamps, because delivery is at-least-once and events can arrive out of order.
  • Acknowledge fast, process later. Return 200 inside the timeout and move the work off the request path.
  • Act on failed. Branch on the error code, retry what is transient, and drop what is not.

The send call tells you we received your request. The status webhooks tell you whether the message reached the person on the other end, which is the only outcome your users experience.


Reference: Linq Partner API: Webhooks, Webhook Events, and Message Details.

Your Cart
Your cart's looking a little light.Looks like your cart is empty—it's time to add your
gears and make it unforgettable.
Shop our best sellers
Digital Card
Digital Card$14.99
Hub
Hub$29.99
Badge
Badge$19.99
Mini Card
Mini Card$12.99