Pager API
Version 1.0.0. The machine-readable contract is at /v1/openapi.json.
The Pager API sends and receives SMS, manages the numbers that carry it, and records the consent that makes sending it lawful.
Authentication
Every endpoint except /v1/health requires an API key, presented as a bearer token:
Authorization: Bearer pk_live_...
A key belongs to exactly one account and every request is scoped to it. A resource belonging to another account is reported as 404, not 403: answering 403 would confirm the resource exists and let one tenant enumerate another's message and number ids by watching the status code.
The key is shown once, when it is issued, and stored only as a hash. A key that is lost cannot be recovered, only replaced.
Errors
Every failure has the same shape, so a client can write one error path:
{ "error": { "type": "invalid_request", "message": "...", "details": [ ... ] } }
Match on type, which is stable. message is for humans and may change without notice. details names the offending fields when the failure is attributable to input.
recipient_opted_out is its own type rather than a bare 403 because a caller has to be able to tell "this person unsubscribed, remove them from your list" apart from "your key may not do that". Retrying the first will never succeed.
Every response carries X-Correlation-Id. Quoting it in a support report identifies the exact request in the platform logs.
Pagination
Collections are cursor-paginated, not offset-paginated: an offset silently skips or repeats records when the underlying set changes between pages, which it does constantly here.
Read data, and if hasMore is true, pass nextCursor back as cursor. nextCursor is null on the last page, so a client can loop on its presence rather than comparing cursors.
Idempotency
POST /v1/messages accepts an Idempotency-Key header. A repeat of the same key returns the original message with 200 instead of sending a second one and returning 202. The distinction tells a caller retrying after a timeout that its first attempt did land.
Keys are at most 128 characters of letters, digits and ., :, _, -. An over-long key is rejected rather than truncated, because truncating would silently merge two distinct requests into one message.
Rate limits
Two buckets are charged: one for requests generally, and a narrower one for sends. A send costs carrier capacity shared with every other tenant, which is scarcer than the database query behind a GET; one bucket for both would let a client polling its message list starve its own sending.
Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset. A client that reads them never has to be refused at all. A 429 also carries Retry-After.
A send refused by validation or replayed from an idempotency key is refunded, so a client retrying safely is not punished for it.
Webhooks
Events are delivered to the account webhook, or to a per-number override, as a POST with two headers:
Pager-Timestamp: 1735689600
Pager-Signature: v1=<hex hmac-sha256>
The signature covers ${timestamp}.${rawBody} under the account's own signing secret, which GET /v1/account returns. Verify over the raw body before parsing it, compare in constant time, and reject a timestamp outside a five-minute window — without that check a captured request can be replayed forever.
Each account has its own secret. Verification is symmetric, so a shared secret would let any customer mint signatures for any other customer's endpoint.
Failed deliveries are retried on a backoff of 1s, 5s, 30s and 120s — five attempts in total. 5xx, 429, 408 and network errors are retried; every other 4xx is final, since a 404 at a mistyped path will still be a 404. Redirects are never followed. Every attempt is recorded and readable at GET /v1/webhook_deliveries.
Endpoints
Messages
Sending and reading SMS.
GET /v1/messages
List messages
Both directions, newest first. Filters combine, so direction=outbound&status=failed is everything that did not get through. An inbound message is stored delivered: it has already arrived, so there is no later outcome to wait for.
Parameters
Responses
Response body
POST /v1/messages
Send an SMS
Returns 202, not 200. The message is recorded and queued; the carrier has not seen it yet. Delivery is reported later through status and through webhooks.
The response carries the computed segment count and encoding, because carriers bill per segment and a single emoji can turn a one-segment message into three.
Sending to a number that has opted out fails with recipient_opted_out; that is final, and the number must be removed from the list rather than retried.
Parameters
Request body
Responses
Response body
GET /v1/messages/{id}
Fetch a message
Parameters
Responses
Response body
Numbers
The numbers an account can send from.
GET /v1/numbers
List numbers
The inventory this account may send from. Which carrier a number rides on is deliberately not reported: it is a routing decision the platform makes and changes.
Parameters
Responses
Response body
GET /v1/numbers/{phoneNumber}
Fetch a number
Parameters
Responses
Response body
PATCH /v1/numbers/{phoneNumber}
Update a number
Supply at least one of inboundEnabled or inboundWebhookUrl; an empty patch is rejected. Setting inboundWebhookUrl to null clears the override and returns the number to the account default, which is the only way a PATCH can express "remove this".
Parameters
Request body
Responses
Response body
Consent
Who may be messaged, and the evidence for it. Carriers require this to be answerable on demand.
GET /v1/consent
List consent records
The current state for every number this account knows.
Parameters
Responses
Response body
POST /v1/consent
Record consent
Returns 200, not 201: there is one record per number and this either created or replaced it, so a caller cannot read 201 as "was not already known".
Supply ipAddress and consentText for an opt-in. An opt-out never needs justifying, but an opt-in without evidence is an assertion with nothing behind it — which is what a carrier will ask you to produce.
Consent recorded here never overrides a STOP received by keyword; a handset always wins.
Request body
Responses
Response body
GET /v1/consent/{phoneNumber}
Consent history for a number
Every change, newest first, with what caused it. This is the answer to a carrier asking why a number was messaged.
Parameters
Responses
Response body
Webhooks
Delivery of events, and its audit.
GET /v1/webhook_deliveries
List webhook deliveries
What was sent to your endpoint, and what it answered. Each record carries the full attempt log, because the question this answers is "why did my endpoint not get this", and the answer is always in the attempts.
Parameters
Responses
Response body
Account
The account and its API keys.
GET /v1/account
The account this key belongs to
The cheapest way to confirm a key works and to discover which account it acts as, which matters when an operator holds keys for several tenants. Also where a customer collects the secret their webhooks are signed with.
Responses
Response body
GET /v1/api-keys
List API keys
Every key ever issued to this account, including revoked ones, so an audit can account for all of them. Secrets are never returned; only a hint of the last few characters.
Responses
Response body
POST /v1/api-keys
Issue an API key
The secret is returned exactly once, in this response. It is stored only as a hash, so a caller that loses it must issue another key.
Request body
Responses
Response body
DELETE /v1/api-keys/{id}
Revoke an API key
Takes effect immediately. Revoking is not deleting: the record stays so an audit can still see the key existed and when it stopped working.
Parameters
Responses
Response body
System
Liveness.
GET /v1/health
Liveness probe
Reports only that the process is serving requests. Requires no authentication, so it can be used by a load balancer.
Responses
Response body