Errors and limits
The response envelope, every error code, rate limits, pagination and data formats.
Everything here applies to every route under
https://www.suzko.com/api/v1. Credentials are covered in
API credentials.
The envelope
Every response is JSON in one of two shapes.
Success:
{
"success": true,
"data": { "object": "message", "id": "msg_Q4f8Zk2LmP7a" },
"meta": { "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTEw..." }
}
meta is only there on lists.
Failure:
{
"success": false,
"error": "This recipient has opted out of texts from your business.",
"code": "opted_out",
"requestId": "req_7Hq2Lm9Xc4Vb8Nz1Kd5Rt3Wy"
}
- Branch on
code, never onerror.codeis stable;erroris a human-readable message and can change. requestIdisreq_plus 24 letters and digits. It's also sent as theX-Request-Idheader on every response, successes included. Quote it when you contact support.- Responses are never cached (
Cache-Control: no-store). - Write requests reject fields the route doesn't know
(
400 invalid_request) instead of ignoring them, so a typo never silently drops an option.
Status codes
| Status | Means |
|---|---|
| 200, 201, 202 | Done. 202 means accepted and queued (a send) or scheduled (a webhook test, verification or replay). |
| 400 | The request is malformed: bad JSON, a missing or wrong field, an unknown field, a bad query parameter. |
| 401 | The credential is missing, malformed, unknown, revoked, expired or suspended. One message for all of them. |
| 402 | Money: texting is paused for an unpaid invoice, there's no payment method, or the spend limit is reached. |
| 403 | Scope, permission, plan or account policy says no. |
| 404 | Not found, or not yours. The API never says which. |
| 409 | Conflict: an Idempotency-Key reused for a different request, or a webhook endpoint in the wrong state. |
| 410 | A retired route. |
| 413 | The request body is over 5 MB. |
| 422 | Valid request, but it can't be carried out: the number, the recipient or the sender is unusable. |
| 429 | Rate limited. Wait Retry-After seconds. |
| 500 | Something broke on SUZKO's side. |
| 503 | The API (or, for writes, the write path) is temporarily unavailable. Retry with backoff. |
Error codes
| Code | Status | Meaning |
|---|---|---|
invalid_request | 400 | Malformed request (see above), a bad limit or cursor, or an Idempotency-Key that isn't 1–255 characters. Also 413 for a body over 5 MB, and 422 when from isn't one of your numbers (or is outside the credential's number list). |
unauthorized | 401 | Missing, malformed, unknown, revoked, expired or suspended credential. Send Authorization: Bearer szk_pat_… or Bearer szk_mk_…. |
step_up_required | 401 | Developer console only: confirm it's you with a code from your authenticator app, then retry. The /api/v1 routes never return it. |
payment_required | 402 | Texting is paused until your latest texting invoice is paid. |
payment_method_required | 402 | This text would go past your included texts and there's no saved payment method. |
spend_limit_reached | 402 | This text would take extra-text charges past your monthly spend limit. |
forbidden_scope | 403 | The credential doesn't have the scope this route needs. |
scope_not_permitted | 403 | A personal token whose holder no longer has the account permission the scope needs. |
owner_only | 403 | Only the account owner can do this: adopt an endpoint, or manage service accounts and machine keys (developer console). |
plan_required | 403 | The account has no active SUZKO Phone plan. Every texting route needs one, and so does creating a webhook endpoint. |
account_suspended | 403 | The SUZKO account is closed, or SUZKO Phone access on it is suspended. Contact support. |
not_found | 404 | No such message, endpoint or delivery for this credential. Also returned when the account has no SUZKO Phone account yet (send, batch, usage). |
idempotency_mismatch | 409 | The Idempotency-Key was used for a different request, or an earlier request with it never finished. Use a new key. |
endpoint_limit | 409 | The account already has 10 webhook endpoints. |
endpoint_disabled | 409 | A test event was sent to an endpoint that's disabled (after repeated failed deliveries, or by SUZKO support). |
endpoint_paused | 409 | The endpoint is paused because its creator lost access. error says why. |
endpoint_not_active | 409 | A test event or replay to an endpoint that isn't verified and active yet (pending verification or paused). Verify it first. |
not_replayable | 409 | That delivery can't be replayed: it isn't finished, it's a verification, or the creator isn't allowed the event any more. |
endpoint_locked | 409 | SUZKO support disabled this endpoint, so it can't be deleted, rotated, re-verified or adopted. Contact support to restore it. |
conflict | 409 | The endpoint changed while your request was in flight (another change landed first). Reload it and try again. |
gone | 410 | A retired route. No current route returns it. |
invalid_number | 422 | to isn't a valid, textable phone number. |
not_registered | 422 | The sending number isn't registered for texting yet, or there's no number this credential can text from. |
opted_out | 422 | The recipient opted out of your texts. |
destination_blocked | 422 | The destination can't be texted from your account (premium-rate numbers; international unless support enabled it). |
rate_limited | 429 | Over a rate limit, too many failed authentications from your address, too many webhook test events, or a request with the same Idempotency-Key is still running. Wait Retry-After seconds. |
daily_limit_reached | 429 | Today's texting cap is used up (UTC day). Retry-After is the seconds until UTC midnight. |
internal_error | 500 | Something went wrong on SUZKO's side. On a send, check before retrying (see Idempotency). |
unavailable | 503 | The API or its write path is switched off for a moment, or a dependency is down. Reads keep working when only writes are paused. |
Inside a batch, each rejected message carries one of the send codes in its own result instead of failing the whole request. See Send a batch.
Rate limits
Every request that authenticates counts against its credential's budget for its class, even one then refused for a missing scope or plan. A request that's also allowed counts against the whole account's budget (all its credentials together). Windows are fixed one-minute windows.
| Class | Routes | Per credential | Per account |
|---|---|---|---|
| Reads | Every GET | 120 per minute | 600 per minute |
| Writes | POST and DELETE under /webhooks | 20 per minute | 100 per minute |
| Sending | POST /messages, POST /messages/batch | 300 per minute | 600 per minute |
- A batch counts once, however many messages it holds.
- The account budget is only charged when the credential's budget admits the request, so one runaway credential can't use up its siblings' share.
- Idempotent replays count. Requests refused for a bad credential don't count against either budget; requests refused for a missing scope or plan count against the credential's budget only.
- Over a budget:
429 rate_limitedwithRetry-After.
Every response that got as far as the rate limiter carries:
| Header | Value |
|---|---|
X-RateLimit-Limit | The limit of the budget closest to running out (credential or account) |
X-RateLimit-Remaining | Requests left in that budget this window |
X-RateLimit-Reset | When that window resets, in Unix seconds |
Retry-After | On a 429 only: seconds to wait |
Failed authentication has its own limit: 30 failures per minute
per IP address (per /64 for IPv6). Past that, failed attempts from
that address get 429 rate_limited instead of 401. A valid
credential is never refused because of it.
Other limits, separate from the request budgets:
- Texting: the daily cap (
daily_limit_reached) and your spend limit apply to every text, however it's sent. Accepted texts go out at the pace carriers allow; see Pacing. - Webhook test events: 5 a minute per endpoint, 30 an hour per account.
- Request bodies: 5 MB (
413). A full 1,000-message batch is far smaller.
Pagination
Lists return an array in data and the next page's cursor in
meta.nextCursor. GET /messages and GET /numbers page:
limit: 25 by default, 100 at most. Anything else is400 invalid_request.cursor: passmeta.nextCursorfrom the previous page, exactly as you got it. It's opaque; an edited one is400 invalid_request.nextCursorisnullon the last page.- Keep the same filters while you page.
GET /webhooks returns all of the credential's endpoints at once (an
account has at most 10), so its nextCursor is always null.
curl "https://www.suzko.com/api/v1/messages?limit=100&cursor=eyJjcmVhdGVkQXQiOiIyMDI2LTEw..." \
-H "Authorization: Bearer szk_mk_YOUR_MACHINE_KEY"
Data formats
-
Timestamps are RFC 3339 in UTC, with milliseconds:
2026-10-20T15:04:05.000Z. -
Money is an exact decimal string plus an ISO 4217 currency, never a float:
JSON{ "amount": "50.00", "currency": "USD" } -
Phone numbers are E.164:
+13175550123. -
Ids are strings with a type prefix, and case-sensitive: messages
msg_…, phone numberspn_…, webhook endpointswh_…, deliverieswhd_…, eventsevt_…, signing keyswhk_…, requestsreq_…. The API never exposes internal database ids. -
Every resource has an
objectfield naming its type (message,phone_number,usage,webhook_endpoint,webhook_delivery,webhook_secret,event). Event payloads usecontactandendpoint_verificationtoo.