zhyper docs

SMS

zhyper sends outbound SMS: you hand us a sending number, a destination and a body, we record the message, hand it to the carrier and tell you what the carrier said. Every send is written to your own history before it leaves, and that history — not the carrier's retention window — is what you read back.

Two things are deliberately not here yet, and this page says so up front because a page that stayed quiet about them would be advertising something the product does not do:

  • Inbound SMS. There is no endpoint that receives a message sent to your number, and no webhook event for one.
  • Delivery receipts (DLR). Nothing tells you a handset actually received the message. The message status enum is queued, sent, failed — there is no delivered, on purpose, because we have nothing to put in it.

So sentAt means the carrier accepted the message, not the recipient got it. If your product needs to know that a message landed, that guarantee does not exist here today.

SMS reads with accounts:read and writes with accounts:write. There is no separate SMS permission: sending a message and registering a 10DLC brand are both things you do to a number you own, and a number belongs to an account.

WhatsApp is the other way round, and it moved: every WhatsApp endpoint is now behind whatsapp:read / whatsapp:write. If you were reaching for one pair to cover both surfaces, there isn't one — an API key that sends SMS does not, by that fact, manage WhatsApp templates.

Sending one

POST /v1/sms/messages (sendSms) takes exactly four fields, and all four are required:

FieldShape
profileId24 hex characters — the profile the message belongs to
fromE.164, e.g. +15551234567
toE.164
body1 to 4096 characters

There are no optional fields and no query parameters; anything else in the body or the query is 400 unknown_parameter rather than being ignored.

from must be an E.164 number. An alphanumeric sender ID is not accepted here, and that is a constraint rather than an oversight: every send writes a billing row whose sending address is checked as E.164 in the database, so a send from an alphanumeric sender could only exist by leaving it unrecorded and unmetered. Alphanumeric senders are a registration, and they live at /v1/sms/sender-ids further down this page.

The response is the message record:

{
  "id": "a1b2c3d4e5f6a7b8c9d0e1f2",
  "profileId": "b2c3d4e5f6a7b8c9d0e1f2a3",
  "status": "sent",
  "from": "+15551234567",
  "to": "+15557654321",
  "body": "Your order is on its way.",
  "encoding": "gsm7",
  "segments": 1,
  "providerMessageId": "40017b1a-1b2c-4d5e-8f90-1234567890ab",
  "errorCode": null,
  "sentAt": "2026-08-25T09:00:00.123456Z",
  "createdAt": "2026-08-25T09:00:00.098765Z"
}

The three statuses, and which fields go with them

statusWhat happenedFields that are set
queuedThe row was written; the carrier has not answerednone of the three below
sentThe carrier accepted the messageproviderMessageId, sentAt
failedThe carrier refused iterrorCode

Those pairings are enforced in the database, not just by convention, so you will never read a sent message with no carrier id, or a failed one with no reason.

The order things happen in, and what you can observe

The order is fixed and is worth knowing, because it is visible to you:

  1. The message row is written as queued and committed — before the carrier is called.
  2. The carrier is called, outside that transaction.
  3. The row becomes sent (together with its usage row) or failed.

The reason it is that way round is that the opposite ordering can lose a message: if the carrier were called first, a crash between the call and the write would leave a message that reached a real handset but exists nowhere in your history and was never metered.

The consequence you can see is the mirror image, and it is the cheaper one: a message can briefly read queued, and a message that stays queued is one whose outcome was never recorded. Be careful about what that does and does not guarantee. What is guaranteed is the pair: no carrier answer was written to the row, and the message was not billed. What is not guaranteed is that the message never left — a crash after the carrier accepted it, but before the record could be updated, leaves exactly this row. The bias is deliberate and it runs in your favour: usage records cannot be deleted once written, so the design would rather under-bill a message that went out than bill one that did not.

A stuck queued row carries no providerMessageId, so there is nothing in it you can use to ask the carrier what became of it.

Idempotency-Key

sendSms reads an optional Idempotency-Key request header. This matters more here than on most endpoints: a repeated send is not a wasted request, it is a second real message on somebody's phone and a second set of billed segments. SMS is never retried automatically for that reason, so a retry after a timeout is yours to make — and yours to make safe.

  • Same key, same request — the original message is returned and nothing is sent again.
  • Same key, anything different409 idempotency_key_reused. "Different" is compared across all four fields: profileId, from, to and body. We do not quietly replay the first response, because a caller who changed the body believes their second message went out; the message that disappears in that case is a real one, and it would leave no trace at all.
  • Scope is your team and your execution mode. The same key value used by another team, or by you in the other mode, is a different key.
  • Window: there is not one. The key stays bound to its message for as long as the message row exists. That is unlike createPost, whose replay window is five minutes — see Rate limits and idempotency.

The header must be 1 to 255 characters and may be sent at most once. An empty value, an over-long one, or the header repeated twice is 400 invalid_idempotency_key rather than being treated as absent — "no key" and "empty key" have to mean different things, and only one of them means "send every time".

The SDK sends an Idempotency-Key on every non-GET request and, importantly, reuses it across its own retries. Supply your own when a retry might span a process restart.

Segments — the quantity you are billed for

segments is the billed quantity. It is a count of message parts, not of messages, and it is measured by zhyper from your body before dispatch. The encoding field records which alphabet that measurement used, so a "why was this three parts?" question can be answered from the record without recomputing anything.

AlphabetOne part holdsEach part of a multi-part message holds
GSM-7 (gsm7)160 units153 units
UCS-2 (ucs2)70 units67 units

The multi-part figures are smaller because a concatenated message spends part of every segment on a header the receiving handset uses to reassemble it. They match the per-part capacities the carrier publishes for its own counting.

Four behaviours produce surprises, and all four are worth knowing before you see them on a bill:

A single character outside GSM-7 converts the whole message. There is no partial encoding — the alphabet is a property of the message, not of a character. One emoji in a 300-character body moves it from 153-unit parts to 67-unit parts.

The backtick is not in GSM-7. Neither the basic table nor the extension table contains it. A pure-ASCII body of exactly 160 characters is one part; make one of those same 160 characters a backtick and the body is UCS-2 and three parts. Nothing about it looks different, and it costs three times as much.

Ten characters cost two units each. They occupy one visible character but two units, so a body's part count can rise while its visible length does not:

form feed   ^   {   }   \   [   ~   ]   |   €

A 160-character GSM-7 body containing one euro sign is two parts, not one.

Parts are packed, not divided. A two-unit character is never split across a part boundary: if it does not fit in the space left, the part closes early and the character starts the next one. This is not the same as dividing the total by the capacity, and the difference is exactly one part. A body of 152 plain characters, then a euro sign, then 152 more plain characters is 306 units — a plain division says two parts, the correct answer is three.

The carrier counts separately

The carrier runs its own encoding pass and produces its own part count, and that count — not ours — is what the carrier charges. This API does not return it. segments is what zhyper measured and what zhyper meters; where the two could differ, treat the carrier's own reporting as authoritative for the carrier's charge.

Billing

Every accepted send writes one usage row for your team, with the quantity set to segments. The unit cost today is 0.

Read that as the unit price has not been set yet and consumption is being measured, not as SMS is free. The measurement is in place now precisely because it cannot be added retroactively: usage that was never recorded cannot be recovered once a price exists.

Three details follow from how it is written, and each of them is something you can observe:

  • A failed send is not billed. The usage row is written in the same transaction that flips the message to sent. A message the carrier refused never reached a handset, so charging for it would be charging for nothing — and usage records cannot be deleted afterwards, which makes over-charging the one mistake that can never be corrected.
  • Test mode is not metered. Usage totals are per team, not per mode, so a test-mode send writing into them would put costs you never incurred into your real total.
  • Your prepaid balance is untouched. SMS does not draw down the balance described in Billing and balance today, and a drained balance does not stop a send. That page's rule still holds: a drained balance stops X and nothing else.

Reading your history

GET /v1/sms/messages (listSmsMessages) reads your records, not the carrier's, newest first. It is cursor-paginated like every other list (pagination); limit runs 1..100 and defaults to 25. Two filters are available, status and profileId, and each accepts a single value — ?status=sent&status=failed and ?status=sent,failed are both rejected rather than silently narrowing to the first one, so a filter you thought you applied is never half-dropped.

GET /v1/sms/messages/{id} (getSmsMessage) reads one. A message whose profile is outside your key's scope answers 404, not 403; a 403 would confirm the message exists.

Test-mode and live-mode history are separate. A message sent in test mode never appears in live history, and vice versa — see Test mode.

When SMS is not configured

If the SMS provider is not configured for live mode, the SMS endpoints answer 503 sms_provider_unavailable instead of an empty list, so "not configured" is never mistaken for "nothing registered". It is a state, not a fault: retrying does not change it. It carries type: "api_error" even though the status is 503.

Test mode does not depend on that configuration. The test-mode provider is always present, so you can build and exercise the whole SMS surface — sending, history, the registries below — before live SMS is switched on for your account.

10DLC brands and campaigns

US carriers require application-to-person traffic on ordinary 10-digit numbers to be registered: a brand (who you are) and then a campaign (what you send) under it. This is a carrier and registry requirement, not a check this API performs at send time — sendSms will not refuse an unregistered number for you; unregistered traffic is filtered or refused downstream instead.

These records live at the carrier. zhyper keeps no local copy, on purpose: two copies of a registration would drift and nothing would notice. Four consequences, all of which you will meet:

  • Results reflect the carrier at the moment you call.

  • Pagination is the carrier's page model, not this API's cursor envelope — and it is not one shape but two. Brands and campaigns return records with page and totalRecords; sender IDs return senderIds with a meta object (pageNumber, pageSize, totalPages, totalResults). Both are passed through as the registry gives them. Turning either into a keyset cursor would be inventing an ordering guarantee nobody gave us.

    listSmsMessages is the exception in the other direction: those rows are ours, so it carries the normal data / hasMore / nextCursor envelope.

  • Creates are not idempotent. Repeating one registers again. The SDK sends an Idempotency-Key on every non-GET request, but these endpoints do not read it — there is no local row for it to replay from.

  • A brand carries two status fields and they answer different questions. status is the registration result at the carrier; identityStatus is the registry's verdict on your identity. A brand can be OK and still UNVERIFIED, and only the pair tells you whether you can send.

Campaigns carry the same split for the same reason: submissionStatus says whether the request reached the registry, campaignStatus says where it got to afterwards. A campaign that reads CREATED and MNO_REJECTED was submitted without a hitch and then refused by the mobile operators.

GET /v1/sms/campaigns requires brandId. The carrier publishes no account-wide campaign listing, so a call without it would be a guaranteed rejection rather than a wider result.

Dry runs, and why the panel does not offer them

createSmsBrand and createSmsCampaign accept "mock": true, which has the registry score the record without charging vetting fees. Use it while you are still finding typos: the alternative is discovering a mistyped EIN by paying for it.

The panel does not draw that field. Anything you register from the panel today is a real registration with real fees. If you want a dry run, make the call against the API with "mock": true.

Alphanumeric sender IDs

/v1/sms/sender-ids registers a sender name rather than a number — three to eleven characters, and never digits alone. Like the 10DLC records these live at the carrier and have no local copy.

usLongCodeFallback is an E.164 number used where alphanumeric senders are refused outright, which is the case for US and Canadian carriers. The carrier attaches it to the sender, not to an individual message, because being refused is a regulatory property of the sender.

DELETE /v1/sms/sender-ids/{id} returns the record it removed rather than an empty body, so you can log which messaging profile lost a sender without a read-before-write.

Errors worth branching on

CodeStatusMeaning
invalid_sms_body400body is empty or longer than 4096 characters
invalid_phone_number400from or to is not an E.164 number
invalid_id400An id is 24 characters but not 24 hex characters
invalid_string400A string field is out of range or carries a control character
invalid_idempotency_key400Header empty, over 255 characters, or sent twice
missing_required_parameter400A required field or brandId is absent
unknown_parameter400An unrecognised body field or query parameter
invalid_request_body400The body is not a JSON object
body_not_allowed400A body was sent on a read endpoint
invalid_query400A query parameter was supplied more than once
invalid_status400status is not queued, sent or failed
invalid_integer400page, recordsPerPage, pageNumber or pageSize is invalid
invalid_boolean400A boolean field was sent as something else
invalid_country400country is not an ISO-2 code
invalid_entity_type400entityType is not a recognised value
invalid_vertical400vertical is not a recognised value
invalid_sub_usecases400subUsecases is not an array, or has more than 50 entries
invalid_cursor400The cursor did not come from us
profile_not_found404No such profile for this key
sms_message_not_found404No such message for this key, or it is out of scope
idempotency_key_reused409The key was already used with a different request
outbound_rate_limited429Carrier access is throttled; honour Retry-After
outbound_rate_limiter_unavailable429We could not check the throttle; retry
sms_provider_error502The carrier failed unexpectedly
sms_provider_unavailable503SMS is not configured for this mode

One wart worth naming: a malformed id answers invalid_id when it is 24 characters long and invalid_string when it is not. Both mean the same thing to you — the id is not a valid one.

The envelope, and how to branch on it, is on Errors.

Next

On this page