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
NameInTypeNotes
statusqueryqueued | sent | delivered | failed | undelivered
directionqueryinbound | outbound
toquerystringAn E.164 phone number, including the leading plus.
fromquerystringAn E.164 phone number, including the leading plus.
cursorquerystringThe `nextCursor` from the previous page.
limitqueryintegerHow many records to return, from 1 to 100.
Responses
StatusDescription
200A page of messages.
401Standard error envelope.
422Standard error envelope.
429Standard error envelope.
500Standard error envelope.
Response body
FieldTypeNotes
id requiredstring
direction requiredinbound | outbound
from requiredstring
to requiredstring
body requiredstring
status requiredqueued | sent | delivered | failed | undeliveredMay gain values without notice — treat an unrecognised status as one you do not handle rather than as an error. A client that rejects it will break on a change the versioning policy considers additive.
encoding requiredGSM7 | UCS2 | nullNull on records that predate stored encoding.
segments requiredintegerHow many segments the carrier billed for.
priceMicros requiredinteger | nullWhat this message was priced at, in micro-dollars: 14800 is $0.0148. Denominated below a cent because a segment costs less than one, and a price rounded to cents would be wrong by up to a third. Null on inbound messages and on records that predate metered billing.
priceVersion requiredstring | nullThe rate card this message was priced by. Recorded so a charge can be explained later without assuming current rates applied to it.
errorCode requiredstring | null
errorMessage requiredstring | null
providerMessageId requiredstring | nullThe upstream provider's own id for this message. Null until the message is submitted. Quote it when raising a delivery problem with the provider.
provider requiredstring | nullWhich configured provider carried the message. Reported so the id above can be taken to the right support desk. Null on records that predate this field.
createdAt requiredstring
updatedAt requiredstring

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
NameInTypeNotes
Idempotency-KeyheaderstringRepeat a request safely. A replay returns the original message with `200` rather than sending again. At most 128 characters of letters, digits and `.`, `:`, `_`, `-`.
Request body
FieldTypeNotes
from requiredstringAn SMS-capable E.164 number assigned to your account. For an approved international route, the carrier uses its configured branded sender while this remains the logical sender in message records.
to requiredstringDestination in E.164 format. Must have an approved price; international sending also requires a configured route for your sending number.
body requiredstringThe text to send.
Responses
StatusDescription
200A replay of an earlier request with the same `Idempotency-Key`. Nothing was sent this time.
202Accepted and queued.
401Standard error envelope.
402Standard error envelope.
403Standard error envelope.
422Standard error envelope.
429Standard error envelope.
500Standard error envelope.
Response body
FieldTypeNotes
id requiredstring
direction requiredinbound | outbound
from requiredstring
to requiredstring
body requiredstring
status requiredqueued | sent | delivered | failed | undeliveredMay gain values without notice — treat an unrecognised status as one you do not handle rather than as an error. A client that rejects it will break on a change the versioning policy considers additive.
encoding requiredGSM7 | UCS2 | nullNull on records that predate stored encoding.
segments requiredintegerHow many segments the carrier billed for.
priceMicros requiredinteger | nullWhat this message was priced at, in micro-dollars: 14800 is $0.0148. Denominated below a cent because a segment costs less than one, and a price rounded to cents would be wrong by up to a third. Null on inbound messages and on records that predate metered billing.
priceVersion requiredstring | nullThe rate card this message was priced by. Recorded so a charge can be explained later without assuming current rates applied to it.
errorCode requiredstring | null
errorMessage requiredstring | null
providerMessageId requiredstring | nullThe upstream provider's own id for this message. Null until the message is submitted. Quote it when raising a delivery problem with the provider.
provider requiredstring | nullWhich configured provider carried the message. Reported so the id above can be taken to the right support desk. Null on records that predate this field.
createdAt requiredstring
updatedAt requiredstring

GET /v1/messages/{id}

Fetch a message

Parameters
NameInTypeNotes
id requiredpathstringThe message id.
Responses
StatusDescription
200The message.
401Standard error envelope.
404Standard error envelope.
422Standard error envelope.
429Standard error envelope.
500Standard error envelope.
Response body
FieldTypeNotes
id requiredstring
direction requiredinbound | outbound
from requiredstring
to requiredstring
body requiredstring
status requiredqueued | sent | delivered | failed | undeliveredMay gain values without notice — treat an unrecognised status as one you do not handle rather than as an error. A client that rejects it will break on a change the versioning policy considers additive.
encoding requiredGSM7 | UCS2 | nullNull on records that predate stored encoding.
segments requiredintegerHow many segments the carrier billed for.
priceMicros requiredinteger | nullWhat this message was priced at, in micro-dollars: 14800 is $0.0148. Denominated below a cent because a segment costs less than one, and a price rounded to cents would be wrong by up to a third. Null on inbound messages and on records that predate metered billing.
priceVersion requiredstring | nullThe rate card this message was priced by. Recorded so a charge can be explained later without assuming current rates applied to it.
errorCode requiredstring | null
errorMessage requiredstring | null
providerMessageId requiredstring | nullThe upstream provider's own id for this message. Null until the message is submitted. Quote it when raising a delivery problem with the provider.
provider requiredstring | nullWhich configured provider carried the message. Reported so the id above can be taken to the right support desk. Null on records that predate this field.
createdAt requiredstring
updatedAt requiredstring

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
NameInTypeNotes
capabilityqueryvoice | sms
countryquerystringAn ISO 3166-1 alpha-2 country code.
cursorquerystringThe `nextCursor` from the previous page.
limitqueryintegerHow many records to return, from 1 to 100.
Responses
StatusDescription
200A page of numbers.
401Standard error envelope.
422Standard error envelope.
429Standard error envelope.
500Standard error envelope.
Response body
FieldTypeNotes
id requiredstring
phoneNumber requiredstring
capabilities requiredvoice | sms[]
country requiredstring | null
inboundEnabled requiredboolean
inboundWebhookUrl requiredstring | nullOverrides the account webhook for this number when set.
createdAt requiredstring
updatedAt requiredstring

GET /v1/numbers/{phoneNumber}

Fetch a number

Parameters
NameInTypeNotes
phoneNumber requiredpathstringThe number in E.164, url-encoded or not; `+12125551234` and `%2B12125551234` both work.
Responses
StatusDescription
200The number.
401Standard error envelope.
404Standard error envelope.
422Standard error envelope.
429Standard error envelope.
500Standard error envelope.
Response body
FieldTypeNotes
id requiredstring
phoneNumber requiredstring
capabilities requiredvoice | sms[]
country requiredstring | null
inboundEnabled requiredboolean
inboundWebhookUrl requiredstring | nullOverrides the account webhook for this number when set.
createdAt requiredstring
updatedAt requiredstring

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
NameInTypeNotes
phoneNumber requiredpathstringThe number in E.164.
Request body
FieldTypeNotes
inboundEnabledboolean
inboundWebhookUrlstring | null
Responses
StatusDescription
200The updated number.
401Standard error envelope.
404Standard error envelope.
422Standard error envelope.
429Standard error envelope.
500Standard error envelope.
Response body
FieldTypeNotes
id requiredstring
phoneNumber requiredstring
capabilities requiredvoice | sms[]
country requiredstring | null
inboundEnabled requiredboolean
inboundWebhookUrl requiredstring | nullOverrides the account webhook for this number when set.
createdAt requiredstring
updatedAt requiredstring

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
NameInTypeNotes
statequeryopted_in | opted_out
cursorquerystringThe `nextCursor` from the previous page.
limitqueryintegerHow many records to return, from 1 to 100.
Responses
StatusDescription
200A page of consent records.
401Standard error envelope.
422Standard error envelope.
429Standard error envelope.
500Standard error envelope.
Response body
FieldTypeNotes
phoneNumber requiredstring
state requiredopted_in | opted_out
source requiredkeyword | web | api | operator
keyword requiredstring | null
createdAt requiredstring
updatedAt requiredstring

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
FieldTypeNotes
phoneNumber requiredstringAn E.164 phone number, including the leading plus.
state requiredopted_in | opted_out
ipAddressstring
consentTextstring
Responses
StatusDescription
200The consent record.
401Standard error envelope.
422Standard error envelope.
429Standard error envelope.
500Standard error envelope.
Response body
FieldTypeNotes
phoneNumber requiredstring
state requiredopted_in | opted_out
source requiredkeyword | web | api | operator
keyword requiredstring | null
createdAt requiredstring
updatedAt requiredstring

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
NameInTypeNotes
phoneNumber requiredpathstringThe number in E.164.
cursorquerystringThe `nextCursor` from the previous page.
limitqueryintegerHow many records to return, from 1 to 200.
Responses
StatusDescription
200The number's current consent state and a page of the events that produced it.
401Standard error envelope.
422Standard error envelope.
429Standard error envelope.
500Standard error envelope.
Response body
FieldTypeNotes
id requiredstring
phoneNumber requiredstring
state requiredopted_in | opted_out
source requiredkeyword | web | api | operator
keyword requiredstring | null
ipAddress requiredstring | null
consentText requiredstring | null
messageId requiredstring | null
occurredAt requiredstring

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
NameInTypeNotes
statusquerypending | succeeded | failed
eventTypequerystring
cursorquerystringThe `nextCursor` from the previous page.
limitqueryintegerHow many records to return, from 1 to 100.
Responses
StatusDescription
200A page of deliveries.
401Standard error envelope.
422Standard error envelope.
429Standard error envelope.
500Standard error envelope.
Response body
FieldTypeNotes
id requiredstring
eventId requiredstring
eventType requiredstring
url requiredstring
status requiredpending | succeeded | failed
attempts requiredinteger
responseStatus requiredinteger | null
error requiredstring | null
deliveredAt requiredstring | null
createdAt requiredstring
updatedAt requiredstring
attemptLog requiredobject[]

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
StatusDescription
200The account.
401Standard error envelope.
422Standard error envelope.
429Standard error envelope.
500Standard error envelope.
Response body
FieldTypeNotes
id requiredstring
name requiredstring
status requiredactive | suspended
webhookUrl requiredstring | null
webhookSigningSecret requiredstring | nullThe secret outbound webhooks are signed with. Returned to the owning account and to nobody else.
createdAt requiredstring

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
StatusDescription
200The keys.
401Standard error envelope.
403Standard error envelope.
422Standard error envelope.
429Standard error envelope.
500Standard error envelope.
Response body
FieldTypeNotes
id requiredstring
name requiredstring
prefix requiredstringThe fixed prefix every Pager key carries.
hint requiredstringThe last few characters, to tell keys apart.
createdAt requiredstring
lastUsedAt requiredstring | null
revokedAt requiredstring | null

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
StatusDescription
200The service is up.
Response body
FieldTypeNotes
status requiredstring
service requiredstring
uptimeSeconds requiredinteger
version requiredstring