Webhooks
Webhooks are how you learn about delivery receipts, inbound messages, opt-outs, and registration status changes. Register up to 10 endpoints per account, each subscribed to the events it cares about.
Events
| Event | Fires when |
|---|---|
message.sent |
Handed to the carrier |
message.delivered |
Carrier confirmed handset delivery |
message.undelivered |
Carrier accepted then failed to deliver |
message.failed |
Rejected before reaching the carrier |
message.received |
An inbound message arrived |
message.opted_out |
An opt-out keyword was received and suppressed |
message.help_requested |
A HELP keyword was received |
campaign.status_changed |
A 10DLC campaign changed carrier status |
number.released |
A number left your account |
Payload
{
"id": "evt_01J8ZQ5B2F7T4V6W",
"event": "message.delivered",
"occurred_at": "2026-07-27T18:02:14Z",
"data": {
"id": "msg_01J8ZQ4X7K2M9N3P",
"status": "delivered",
"from": "+13155550142",
"to": "+13155550188",
"carrier": "Verizon Wireless",
"segments": 1,
"reference": "appt-88213"
}
}
Verifying the signature
Every delivery carries two headers:
AO-Signature: t=1785168134,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
AO-Event-Id: evt_01J8ZQ5B2F7T4V6W
Compute HMAC-SHA256 over {timestamp}.{raw_body} using your endpoint’s signing secret and compare in constant time.
import crypto from "node:crypto";
export function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const age = Math.floor(Date.now() / 1000) - Number(parts.t);
if (Number.isNaN(age) || Math.abs(age) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(int(time.time()) - int(parts["t"])) > tolerance:
return False
expected = hmac.new(
secret.encode(),
f"{parts['t']}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
Verify against the raw request body. Re-serializing the parsed JSON changes the bytes and the signature will not match.
Responding
Return any 2xx status within 10 seconds. We do not read the response body. Do your processing asynchronously: acknowledge first, work afterwards. An endpoint that blocks on downstream work is the most common cause of retry storms.
Retries
Non-2xx responses and timeouts are retried five times with exponential backoff over roughly 30 minutes: 10 seconds, 1 minute, 5 minutes, 15 minutes, 30 minutes. After the final attempt the event is marked undelivered and remains available for replay.
An endpoint failing continuously for 24 hours is automatically disabled and the account administrators are emailed.
Idempotency on your side
Retries and rare network duplicates mean the same AO-Event-Id can arrive more than once. Store processed event IDs and discard duplicates. Events can also arrive out of order; occurred_at is authoritative for sequencing, not arrival time.
Replay
curl "https://api.anthonyoliva.com/v1/events?delivered=false&limit=100" \
-u "$AO_KEY_ID:$AO_KEY_SECRET"
curl -X POST https://api.anthonyoliva.com/v1/events/evt_01J8ZQ5B2F7T4V6W/replay \
-u "$AO_KEY_ID:$AO_KEY_SECRET"
Events are retained for 30 days and can be replayed individually or in bulk by time range.
Local development
Test keys deliver webhooks exactly like live keys, so you can point an endpoint at a tunnel during development and exercise the full flow without sending a real message or spending anything.