Pager API
Version 1.0.0. The machine-readable contract is at /v1/openapi.json.
Quick start
Send a message. Every one of these makes the same request against this deployment, reading your key from a PAGER_API_KEY environment variable. Create a key in the console.
curl -X POST https://lunarsoft.dev/v1/messages \
-H "Authorization: Bearer $PAGER_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"from": "+18288888202",
"to": "+12395550147",
"body": "Your verification code is 481920."
}'
Each of these makes the same request. The response carries the message id, its status and how many segments it was billed as.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.
Destinations and pricing
Price book 2026-09-11 covers the mainland United States, Canada, and Bulgaria. Other international destinations and separately priced NANP territories remain unsupported.
- payg: United States and Canada: $0.0148; Bulgaria: $0.1500 per SMS segment (USD). - growth: United States and Canada: $0.0133; Bulgaria: $0.1485 per SMS segment (USD). - committed: United States and Canada: $0.0113; Bulgaria: $0.1465 per SMS segment (USD).
Rates apply to every encoded SMS segment, including concatenated and UCS-2 messages. The response records priceMicros and priceVersion; retries retain the original charge. Bulgaria includes a published $0.1390 provider portion plus the tier fee, not an additional US carrier surcharge.
Bulgaria requires an explicitly configured international route for your assigned sending number. Keep that E.164 number in from; the carrier uses its configured branded sender instead. Message records retain your assigned number for ownership and filtering. Branded international SMS is one-way: recipients cannot reply, and some carriers may rewrite the displayed sender. Provide a non-SMS opt-out/contact method rather than asking recipients to reply STOP. The hosted US 10DLC opt-in form is not an international signup form.
An unpriced destination or an unconfigured international sender is refused with 403 destination_not_supported before billing or queueing. Account ownership, sandbox verification, consent, balances and spending caps still apply.
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.
Versioning
/v1 is supported for the life of the product. It has no sunset date, and an integration written against it today keeps working. There are no Deprecation or Sunset headers, because there is nothing for them to announce.
Additive changes ship into /v1 without notice: new endpoints, new optional request fields, new response fields, new values in a field whose published schema says it may gain them, new error types for conditions that could not previously arise, and relaxed validation. message.status and the type in a failure body are the fields that carry that warning today. Parse responses permissively and treat an unrecognised enumerated value as one you do not handle rather than as an error; a client that rejects unknown fields, or that turns a published enumeration into a closed type, will break on a change this policy considers safe.
Nothing is ever removed, renamed, retyped or given a new meaning inside /v1, and no enumeration that does not advertise itself as open gains a value. An existing condition keeps its status and its error type — including against being subdivided into a finer one, which would stop the value a client matches on from arriving. A change that cannot be made additively arrives as /v2, served alongside /v1 rather than instead of it. Nobody is migrated.
The major component of info.version is the version this document describes and matches the prefix its paths are served under. It does not report the running build — GET /v1/health does that.
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, encoding and price, because carriers bill per segment and a single emoji can turn a one-segment message into three. The price is in micro-dollars, since a segment costs less than a cent.
Sending is prepaid. A request that would overdraw the account fails with insufficient_balance and 402, records nothing, and succeeds unchanged once credit is added.
An account may also cap its own sending per day or per calendar month, in the console. A request past a cap fails with spend_cap_exceeded and 402, and records nothing. It is not a balance problem: the money is there, and topping up will not help. The message names which cap stopped it; the ways through are to raise it or to wait for the period to roll over.
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.
Read-only. Keys are issued and revoked from the console, which authenticates a person: a key able to mint another key could not be revoked, because whoever took it would issue a replacement first.
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