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

FieldRequiredDescription
toyesThe recipient's phone number. E.164 (+13175550123) is best; other common formats are normalised.
bodyyesThe text, 1–1,600 characters, not blank. Length is counted in UTF-16 code units, so most emoji count as 2.
fromnoOne of your numbers: its id (pn_…, from GET /numbers) or its E.164 number. Leave it out to use the default sender (below).
metadatanoA 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.

Shell
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.

JSON
{
  "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

  • from given: 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 is 422 invalid_request ("That number isn't on your account"). A from that is neither a pn_… id nor E.164 is 400 invalid_request.
  • from left out: the first number GET /numbers lists (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 (usually not_registered). No usable number at all is 422 not_registered.

When a send is refused

A refused send is a 4xx with a code, and nothing is queued or charged:

JSON
{
  "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):

CodeWhy
invalid_numberto isn't a valid, textable phone number.
not_registeredThe sending number isn't registered for texting yet, or there's no number this credential can text from. See Texting registration.
opted_outThe recipient opted out. See Opt-outs.
destination_blockedPremium-rate numbers are always blocked; international texting is off unless support has enabled it.
invalid_requestfrom 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, with Retry-After set 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.

JSON
{
  "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.

Shell
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.

JSON
{
  "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 202 when every message is rejected. Always check rejected and the per-item codes.
  • 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 code is any send refusal above, invalid_request for a malformed item, or internal_error if that one message failed on our side (it wasn't sent; the rest carry on).
  • A shared from that is well-formed but isn't one of your numbers rejects every item with invalid_request; the batch still answers 202.
  • Only a malformed request fails the whole batch with 400: not a JSON object, both or neither of messages and to, not an array, zero or more than 1,000 messages, an unknown top-level field, top-level body/from/metadata alongside messages, or a malformed shared body/from/metadata in the to shape. A bad individual message, or a bad number in the to array, only rejects that item.
  • No SUZKO Phone plan is 403 plan_required for 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 from equals "from": null. The to-array and messages-array forms of the same batch are the same request.

What you get back:

SituationResponse
Same key, same request, finishedThe stored status and body, replayed, with Idempotent-Replayed: true. Nothing is sent again. The replay has its own X-Request-Id.
Same key, different request409 idempotency_mismatch
Same key while the first request is still runningSUZKO 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 202 is 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

Shell
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:

ParameterDescription
directionoutbound or inbound
statusOne of accepted, scheduled, queued, sending, sent, delivered, undelivered, failed, receiving, received, read, canceled
fromA number in E.164, or one of your numbers' ids (pn_…). URL-encode + as %2B (an unencoded + is understood too).
toA number in E.164.
sinceCreated 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).
untilCreated before (exclusive). Same formats as since.
limit1–100, default 25.
cursormeta.nextCursor from the previous page, as-is.
Shell
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"
JSON
{
  "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.

JSON
{
  "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 as from and is what a credential's number list is made of.
  • type is local, tollfree or mobile.
  • When canText is false, reason says 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.

JSON
{
  "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
  }
}
FieldMeaning
periodThe month, UTC (YYYY-MM).
allowanceIncluded texts (segments) this month, pooled across your numbers.
usedTexts (segments) sent this month.
overageTexts beyond the allowance.
overageBilledExtra texts already invoiced this month.
spendLimitYour monthly spend limit for extra texts.
spendCeilingThe most the spend limit can be raised to.
pausedReasonWhy texting is paused, or null.
payInvoicePublicIdOnly present while texting is paused for an outstanding texting invoice: the invoice to pay.

Message status

A sent text moves through these statuses:

TEXT
queued → sending → sent → delivered
                        ↘ undelivered
          (any point) → failed
StatusMeaning
queuedAccepted by SUZKO and waiting to go out. Every new send starts here.
accepted, sendingHanded to the carrier network and on its way.
sentThe carrier network sent it.
deliveredThe recipient's carrier confirmed delivery.
undeliveredSent, but the carrier couldn't deliver it. errorCode usually says why. It still counts as a sent text.
failedIt never went out. Failed texts aren't charged: they're taken back off your usage.
receivedAn incoming text (direction: "inbound").
  • sentAt stays null until the text has actually been handed to the carrier network. deliveredAt is set on delivery.
  • A text that has gone out can still read queued: the carrier network has its own queued step. To tell "waiting at SUZKO" from "handed off", check sentAt: it's null only while the text is still in SUZKO's queue.
  • segments is 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:

EncodingWhenOne segmentEach segment of a longer text
GSM-7Only plain characters (A–Z, digits, common punctuation)up to 160 characters153 characters
UCS-2Any other character: emoji, curly quotes “ ”, accented letters outside the GSM set (such as á or ç), non-Latin scriptsup to 70 characters67 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 segments up 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, OPTOUT or REVOKE (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, YES or UNSTOP takes it off the list.
  • HELP and INFO get 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 marked failed and 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.