Sending texts
Send one text or a batch, idempotency, reading messages, numbers and usage, segments, opt-outs.
Every example uses the base URL https://www.suzko.com/api/v1 and a
credential with the right scope. Getting a credential is in the
Messaging API overview;
the envelope, every error code and the rate limits are in
Errors and limits.
Send a text
POST /messages · scope messages:send · supports Idempotency-Key
| Field | Required | Description |
|---|---|---|
to | yes | The recipient's phone number. E.164 (+13175550123) is best; other common formats are normalised. |
body | yes | The text, 1–1,600 characters, not blank. Length is counted in UTF-16 code units, so most emoji count as 2. |
from | no | One of your numbers: its id (pn_…, from GET /numbers) or its E.164 number. Leave it out to use the default sender (below). |
metadata | no | A JSON object of your own, at most 1 KB as JSON. Stored on the message and echoed back in responses and webhooks. |
Any other field is 400 invalid_request.
curl https://www.suzko.com/api/v1/messages \
-H "Authorization: Bearer szk_mk_YOUR_MACHINE_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2e0a-order-1042" \
-d '{
"to": "+13175550123",
"from": "pn_Ht6Wq2Kz9LmB",
"body": "Your order #1042 is ready for pickup.",
"metadata": { "orderId": "1042" }
}'
A text that passes every check is accepted and queued:
202 Accepted with the message.
{
"success": true,
"data": {
"object": "message",
"id": "msg_Q4f8Zk2LmP7a",
"direction": "outbound",
"from": "+13175550100",
"to": "+13175550123",
"body": "Your order #1042 is ready for pickup.",
"status": "queued",
"segments": 1,
"errorCode": null,
"metadata": { "orderId": "1042" },
"createdAt": "2026-10-20T15:04:05.000Z",
"sentAt": null,
"deliveredAt": null
}
}
from in the response is always the E.164 number, even when you sent
a pn_… id.
Which number it sends from
fromgiven: it must be one of your numbers that hasn't been released, matched exactly, and inside the credential's number list if it has one. Anything else is422 invalid_request("That number isn't on your account"). Afromthat is neither apn_…id nor E.164 is400 invalid_request.fromleft out: the first numberGET /numberslists (oldest first, within the credential's number list) that can text right now. If none can, the first one is used anyway so you get its real reason (usuallynot_registered). No usable number at all is422 not_registered.
When a send is refused
A refused send is a 4xx with a code, and nothing is queued or
charged:
{
"success": false,
"error": "This recipient has opted out of texts from your business.",
"code": "opted_out",
"requestId": "req_7Hq2Lm9Xc4Vb8Nz1Kd5Rt3Wy"
}
The request was valid but can't be carried out (422):
| Code | Why |
|---|---|
invalid_number | to isn't a valid, textable phone number. |
not_registered | The sending number isn't registered for texting yet, or there's no number this credential can text from. See Texting registration. |
opted_out | The recipient opted out. See Opt-outs. |
destination_blocked | Premium-rate numbers are always blocked; international texting is off unless support has enabled it. |
invalid_request | from isn't one of your numbers (or is outside the credential's number list). |
The other refusals:
- 402
payment_required(an unpaid texting invoice),payment_method_required(no card on file to go past your included texts),spend_limit_reached. - 403
plan_required(no active SUZKO Phone plan),account_suspended. - 429
daily_limit_reached, withRetry-Afterset to the seconds until UTC midnight.
Send a batch
POST /messages/batch · scope messages:send · supports Idempotency-Key
1 to 1,000 messages per request, in one of two shapes. The whole batch counts as one request against your rate limit.
Different messages: a messages array, each item shaped like a
single send. With this shape, body, from and metadata go inside
each message, not at the top level.
{
"messages": [
{ "to": "+13175550123", "body": "Hi Sam, your table is ready." },
{ "to": "+13175550124", "body": "Hi Alex, your table is ready.", "metadata": { "party": 4 } }
]
}
Same text to many numbers: a to array plus one body, and
optionally one from and one metadata for all of them.
curl https://www.suzko.com/api/v1/messages/batch \
-H "Authorization: Bearer szk_mk_YOUR_MACHINE_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: holiday-closure-2026-11-26" \
-d '{"to": ["+13175550123", "+13175550124", "+13175550125"], "body": "We are closed today for the holiday.", "from": "pn_Ht6Wq2Kz9LmB"}'
A valid batch always answers 202, with one result per message in
request order: { index, id } when it was accepted, or
{ index, code, error } when it was rejected.
{
"success": true,
"data": {
"accepted": 2,
"rejected": 1,
"results": [
{ "index": 0, "id": "msg_Q4f8Zk2LmP7a" },
{ "index": 1, "code": "opted_out", "error": "This recipient has opted out of texts from your business." },
{ "index": 2, "id": "msg_R7n2Wd5KcX9b" }
]
}
}
- Still
202when every message is rejected. Always checkrejectedand the per-itemcodes. - Messages are processed in order, so the spend limit and daily cap
apply as they go: if the spend limit runs out partway, the later
messages are rejected with
spend_limit_reached. - A per-item
codeis any send refusal above,invalid_requestfor a malformed item, orinternal_errorif that one message failed on our side (it wasn't sent; the rest carry on). - A shared
fromthat is well-formed but isn't one of your numbers rejects every item withinvalid_request; the batch still answers202. - Only a malformed request fails the whole batch with
400: not a JSON object, both or neither ofmessagesandto, not an array, zero or more than 1,000 messages, an unknown top-level field, top-levelbody/from/metadataalongsidemessages, or a malformed sharedbody/from/metadatain thetoshape. A bad individual message, or a bad number in thetoarray, only rejects that item. - No SUZKO Phone plan is
403 plan_requiredfor the whole batch.
Idempotency
Networks drop responses. Send an Idempotency-Key header on
POST /messages and POST /messages/batch and a retry can never text
twice.
- Any string of 1–255 characters, case-sensitive. A UUID per logical send is a good choice.
- Scoped to one credential and one route, kept 24 hours. The same key from a different personal token or machine key, or on the other send route, is a different key. So if you rotate machine keys between a request and its retry, the retry runs again. After 24 hours a key runs as a new request.
- "Same request" means the same validated content: the same fields
with the same values, and the same query string. Key order and
whitespace don't matter, and an omitted
fromequals"from": null. Theto-array andmessages-array forms of the same batch are the same request.
What you get back:
| Situation | Response |
|---|---|
| Same key, same request, finished | The stored status and body, replayed, with Idempotent-Replayed: true. Nothing is sent again. The replay has its own X-Request-Id. |
| Same key, different request | 409 idempotency_mismatch |
| Same key while the first request is still running | SUZKO waits up to 2 seconds for it and replays its result; if it's still running, 429 rate_limited with Retry-After: 1 |
| An earlier request with this key never finished (over 10 minutes old) | 409 idempotency_mismatch. It may have sent: check your messages, then use a new key. |
- 2xx and 5xx results are stored. A 5xx might have sent, so a
retry with the same key gets the same 5xx rather than risking a
second text. Check
GET /messages, then retry with a new key only if the text isn't there. - 4xx results aren't stored. A 4xx means nothing was sent, so fix the cause (add a card, wait for tomorrow's cap, correct the number) and retry with the same key.
- A malformed request (
400) never claims the key. - A batch that came back
202is stored even if items were rejected. To resend just the rejected ones, send a new batch with a new key. - Other routes ignore the header. Creating a webhook endpoint is deliberately not idempotent.
Get a message
GET /messages/{id} · scope messages:read
curl https://www.suzko.com/api/v1/messages/msg_Q4f8Zk2LmP7a \
-H "Authorization: Bearer szk_mk_YOUR_MACHINE_KEY"
Returns the message in the same shape as the send response. An id
that doesn't exist, belongs to another account, or is outside the
credential's number list is 404 not_found. Ids are case-sensitive.
List messages
GET /messages · scope messages:read
Newest first. All parameters are optional:
| Parameter | Description |
|---|---|
direction | outbound or inbound |
status | One of accepted, scheduled, queued, sending, sent, delivered, undelivered, failed, receiving, received, read, canceled |
from | A number in E.164, or one of your numbers' ids (pn_…). URL-encode + as %2B (an unencoded + is understood too). |
to | A number in E.164. |
since | Created at or after (inclusive). A date (2026-10-01, read as UTC midnight) or a date-time with an offset (2026-10-20T15:00:00Z, 2026-10-20T10:00:00-05:00). |
until | Created before (exclusive). Same formats as since. |
limit | 1–100, default 25. |
cursor | meta.nextCursor from the previous page, as-is. |
curl "https://www.suzko.com/api/v1/messages?direction=outbound&status=undelivered&since=2026-10-01&limit=100" \
-H "Authorization: Bearer szk_mk_YOUR_MACHINE_KEY"
{
"success": true,
"data": [
{
"object": "message",
"id": "msg_R7n2Wd5KcX9b",
"direction": "outbound",
"from": "+13175550100",
"to": "+13175550124",
"body": "Hi Alex, your table is ready.",
"status": "undelivered",
"segments": 1,
"errorCode": 30003,
"metadata": { "party": 4 },
"createdAt": "2026-10-20T15:04:06.000Z",
"sentAt": "2026-10-20T15:04:07.000Z",
"deliveredAt": null
}
],
"meta": { "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTEw..." }
}
nextCursor is null on the last page. Keep the same filters while
you page. A pn_… id in from that isn't yours matches nothing.
Texts from before the Messaging API launched aren't listed. Messages
on numbers you've since released stay readable.
List your numbers
GET /numbers · scope numbers:read
Your numbers (not released, and inside the credential's number list),
oldest first: the order the default sender is picked in. Each one says
whether it can text right now. Paged like messages: limit (default
25, max 100) and cursor.
{
"success": true,
"data": [
{
"object": "phone_number",
"id": "pn_Ht6Wq2Kz9LmB",
"number": "+13175550100",
"type": "local",
"displayName": "Front desk",
"canText": true,
"reason": null
},
{
"object": "phone_number",
"id": "pn_Wd3Kc8Rt1NqX",
"number": "+18885550100",
"type": "tollfree",
"displayName": null,
"canText": false,
"reason": "This number isn't registered for texting yet. Finish texting registration in Business Phone → Settings → Texting registration."
}
],
"meta": { "nextCursor": null }
}
id(pn_…) works asfromand is what a credential's number list is made of.typeislocal,tollfreeormobile.- When
canTextisfalse,reasonsays why.
Check usage
GET /usage · scope usage:read
This month's texting usage for the whole account (a credential's number list doesn't narrow it). The same numbers the dashboard shows.
{
"success": true,
"data": {
"object": "usage",
"period": "2026-10",
"allowance": 750,
"used": 812,
"overage": 62,
"overageBilled": { "amount": "0.00", "currency": "USD" },
"spendLimit": { "amount": "50.00", "currency": "USD" },
"spendCeiling": { "amount": "500.00", "currency": "USD" },
"pausedReason": null
}
}
| Field | Meaning |
|---|---|
period | The month, UTC (YYYY-MM). |
allowance | Included texts (segments) this month, pooled across your numbers. |
used | Texts (segments) sent this month. |
overage | Texts beyond the allowance. |
overageBilled | Extra texts already invoiced this month. |
spendLimit | Your monthly spend limit for extra texts. |
spendCeiling | The most the spend limit can be raised to. |
pausedReason | Why texting is paused, or null. |
payInvoicePublicId | Only present while texting is paused for an outstanding texting invoice: the invoice to pay. |
Message status
A sent text moves through these statuses:
queued → sending → sent → delivered
↘ undelivered
(any point) → failed
| Status | Meaning |
|---|---|
queued | Accepted by SUZKO and waiting to go out. Every new send starts here. |
accepted, sending | Handed to the carrier network and on its way. |
sent | The carrier network sent it. |
delivered | The recipient's carrier confirmed delivery. |
undelivered | Sent, but the carrier couldn't deliver it. errorCode usually says why. It still counts as a sent text. |
failed | It never went out. Failed texts aren't charged: they're taken back off your usage. |
received | An incoming text (direction: "inbound"). |
sentAtstaysnulluntil the text has actually been handed to the carrier network.deliveredAtis set on delivery.- A text that has gone out can still read
queued: the carrier network has its ownqueuedstep. To tell "waiting at SUZKO" from "handed off", checksentAt: it'snullonly while the text is still in SUZKO's queue. segmentsis our count when you send; once the carrier reports its final count, that number replaces it.- If a text can't be handed off because of a temporary problem, SUZKO
retries it automatically before marking it
failed. - Want status changes pushed to you instead of polling? Use webhooks.
Segments and pricing
Texts are billed in segments, the way carriers bill them:
| Encoding | When | One segment | Each segment of a longer text |
|---|---|---|---|
| GSM-7 | Only plain characters (A–Z, digits, common punctuation) | up to 160 characters | 153 characters |
| UCS-2 | Any other character: emoji, curly quotes “ ”, accented letters outside the GSM set (such as á or ç), non-Latin scripts | up to 70 characters | 67 characters |
- A few GSM-7 characters count double:
^ { } \ [ ] ~ |and€. - A single emoji or curly quote switches the whole text to UCS-2, so a 100-character text with one emoji is 2 segments instead of 1.
- The send response tells you
segmentsup front.
What it costs:
- Included texts: 250 (Starter), 750 (Business) or 1,500 (Enterprise) per number per month, pooled across all your numbers. Every number on your account adds its plan's texts to the pool (prorated in the month you add it).
- Extra texts: 2¢ per segment beyond the included texts, billed at month end, or sooner once they reach $25 in a month.
- Only outgoing texts count. Replies to you are free.
- Spend limit: extra texts stop at your monthly spend limit
(
spend_limit_reached). It's $50 a month unless you change it in Business Phone → Dashboard (texting usage card). - Going past your included texts needs a saved payment method
(
payment_method_required). - Daily cap: 5,000 segments per UTC day by default
(
daily_limit_reached). Contact support to raise it.
API texts and dashboard texts share the same allowance, spend limit and daily cap.
Pacing
A 202 means accepted, not sent. Accepted texts join a queue and
go out at the pace carriers allow for your registration. Each texting
campaign (and each toll-free number) has its own carrier-set
throughput, and a new low-volume campaign is on the slow end. A big
batch can take minutes to finish going out. Pacing never causes an
error, and one campaign's backlog doesn't hold up another's. Track
progress with GET /messages or webhooks.
Opt-outs
Recipients can stop your texts at any time, and carriers require you to honour that.
- Opting out: a reply that is exactly one of
STOP,STOPALL,UNSUBSCRIBE,CANCEL,END,QUIT,OPTOUTorREVOKE(any case, surrounding spaces ignored) puts the number on your account's opt-out list. "stop texting me please" is not an opt-out. - Opting back in: a reply of
START,YESorUNSTOPtakes it off the list. HELPandINFOget the carrier's standard help reply. SUZKO doesn't send any auto-replies of its own.- A number can also land on the list when the carrier reports it as opted out.
- Sending to a number on the list is refused with
422 opted_out, on every send path (API, dashboard and apps), before anything is charged. A text still in the queue when its recipient opts out isn't sent: it's markedfailedand not charged.
The list is in Business Phone → Settings → Opt-out list. You can add a number by hand (someone asked you by email to stop texting them) and remove numbers you added by hand. Numbers that opted out themselves, by replying STOP or through their carrier, are locked: they only come off when that person texts START.
To keep your own system in sync, subscribe to the contact.opted_out
and contact.opted_in
webhook events.
Texting registration
US carriers only deliver business texts from registered senders. A
number can text once its registration is approved: a 10DLC campaign
for local numbers, or toll-free verification for toll-free numbers.
Until then, sends from it fail with 422 not_registered, and
GET /numbers shows canText: false with the reason.
Register in Business Phone → Settings → Texting registration. The fees for carrier registration and setup are shown there before you pay.