Build with Switchgear
API reference
Find each endpoint’s parameters, request body, and responses. This reference is generated from Switchgear’s OpenAPI contract, the same source used by the API and SDK. For a first send, follow getting started, or download the OpenAPI 3.1 specification to generate a client.
API essentials
https://sandbox.switchgear.appUse sg_test_ keys. Test against simulators without sending real messages or incurring charges.
https://api.switchgear.appUse sg_live_ keys after business approval and channel activation. Live usage is billed.
Authenticate every request
Send Authorization: Bearer <key>. Each key identifies one project environment and carries its own scopes. Required scopes are listed in the endpoint descriptions. Use GET /v1/me to check your key’s project, environment, and permissions:
curl https://sandbox.switchgear.app/v1/me \
-H "Authorization: Bearer $SWITCHGEAR_KEY"Make retries safe
Send an Idempotency-Key for endpoints that require one. Use a fresh value, such as a UUID, for each new operation. A retry with the same key and body returns the original response for 24 hours. Replayed message responses include Idempotent-Replayed: true.
A reused key with a different body, or one outside its replay window, returns 409. If a send returns 503 after being stored, retry with the same key so it can be queued safely.
Read responses and errors
For message sends, 202 Accepted means queued. Follow delivery through GET /v1/messages/{id} or signed webhooks. Error responses contain an error object with a stable code, a human-readable message, and details when available. Use the code, rather than the message text, in your application logic.
Each endpoint lists its success and error responses below. For common fixes, see quickstart troubleshooting.
Handle rate limits
Limits apply per API key, per minute. Defaults are 1,200 requests in sandbox and 6,000 in production; a key can have a custom limit. Each message in a batch counts as one request.
A limit response uses 429 rate_limited. Wait for Retry-After when supplied. A batch that exceeds the key’s entire limit cannot fit in any window: split it into smaller batches.
Trace a request
Every response includes X-Request-Id. You can supply your own value using 1–128 letters, digits, or . _ : -; otherwise the API creates one. Related webhook deliveries carry it too. Include this ID when contacting Switchgear support.
Authentication
GET/v1/me
The project, environment and scopes this API key resolves to
Responses
- 200The key is valid · Me
- 401Missing or invalid key · ErrorEnvelope
API keys
POST/v1/keys/{id}/rotate
Rotate this API key
A key rotates itself: id is the calling key's id (key_id from GET /v1/me); other keys are managed in the business portal. No scope is needed. The answer holds the new key, shown once, with the same scopes and rate limit. The old key keeps working for overlap_hours (default 24, at most 168; 0 ends it now), so deploy the new key within that window; then it is revoked. A key can be rotated once.
| Field | Type | Description |
|---|---|---|
idrequired | string (uuid) · path |
Request body (RotateApiKeyRequest)
| Field | Type | Description |
|---|---|---|
overlap_hours | integer | |
name | string |
Responses
- 201The new key (
keyis its plaintext, shown once) and the old one with its newexpires_at· RotateApiKeyResponse - 400An overlap outside 0–168 hours · ErrorEnvelope
- 401Missing or invalid key · ErrorEnvelope
- 404
idis not the calling key · ErrorEnvelope - 409This key was already rotated (
invalid_transition) · ErrorEnvelope
Messages
POST/v1/messages
Send one message
Accepts the message once it is durably queued (scope messages:send). Delivery is reported through message.* events and GET /v1/messages/{id}. Send body, or template (a key) with variables: the newest approved version for the channel and locale is rendered, and every placeholder is required. Tag OTP messages otp to send them ahead of everything else, and campaigns marketing to send them behind. When the environment has quiet hours (business portal → Suppressions), a message tagged marketing that falls due inside them waits until they end in the recipient's local time; scheduled_at says until when. Messages also tagged otp or transactional never wait. Bulk email (tagged marketing, or sent in a batch unless the environment is set to marketing only) carries a one-click unsubscribe: List-Unsubscribe and List-Unsubscribe-Post headers, and a link at the foot of the email layout. An unsubscribe adds a bulk suppression and emits email.unsubscribed; email tagged otp or transactional never carries it and is never stopped by it. A push may carry push.fallback ({ sms } or { email }): if it reaches no device, the same message is sent there once, as a message of its own, billed at that channel's price. The fallback must be routable when the push is accepted. WhatsApp: whatsapp.media sends an image, document, audio or video by https url or by the media_id of an upload (POST /v1/media), with body as its caption; with a template that has a media header, it is the header's file. Without a template, a WhatsApp message is free-form, which Meta allows only within 24 hours of the person's last message (outside_service_window otherwise) and which is billed as a service conversation. The simulator fails numbers ending in 00.
| Field | Type | Description |
|---|---|---|
Idempotency-Keyrequired | string · header | Unique per request, for example a UUID. A retry with the same key and body returns the original response for 24 hours. |
Request body (SendMessageRequest)
| Field | Type | Description |
|---|---|---|
channelrequired | one of "sms", "email", "whatsapp", "push" | |
torequired | string | |
template | string | |
variables | object | |
locale | string | |
body | string | |
subject | string | |
html | string | |
sender_id | string | |
tags | array of string | |
schedule_at | string (date-time) | |
metadata | object | |
push | object | |
.title | string | |
.image | string (uri) | |
.deep_link | string | |
.data | object | |
.priority | one of "high", "normal" | |
.ttl | integer | |
.collapse_key | string | |
.badge | integer | |
.sound | string | |
.android_channel_id | string | |
.silent | boolean | |
.fallback | object | |
.sms | string | |
.email | string (email) | |
.html | string | |
whatsapp | object | |
.media | object | |
.kindrequired | one of "image", "document", "audio", "video" | |
.url | string (uri) | |
.media_id | string (uuid) | |
.filename | string |
Responses
- 202Queued. On a replay the response carries
Idempotent-Replayed: true. · Message - 400Invalid body (
validation_failed) or no Idempotency-Key (idempotency_key_missing) · ErrorEnvelope - 401Missing or invalid key · ErrorEnvelope
- 402Production and staging: the wallet can’t cover the estimated price (
insufficient_balance; prepaid stops at zero, postpaid at the credit limit). Nothing is stored; top up and retry with the same Idempotency-Key · ErrorEnvelope - 403The key lacks
messages:send, or the account is suspended · ErrorEnvelope - 409The Idempotency-Key was used with a different body, or more than 24 hours ago · ErrorEnvelope
- 413The body is over 100 KB (
payload_too_large) · ErrorEnvelope - 415WhatsApp: the uploaded file is not a type WhatsApp sends as that kind (
unsupported_media_type) · ErrorEnvelope - 422The destination is suppressed (
suppressed_destination), no routing rule matches (no_route), the template is missing (template_not_found) or not approved (template_not_approved), or the project has reached its monthly budget and the send is marketing or a batch item (budget_exceeded; OTP and transactional sends never are). WhatsApp: free-form outside the customer-service window (outside_service_window), or amedia_idthat is not an upload of this environment's or has expired (media_not_found) · ErrorEnvelope - 429Over the key's rate limit (
rate_limited, withRetry-After) · ErrorEnvelope - 503Stored but not queued; retry with the same Idempotency-Key · ErrorEnvelope
GET/v1/messages
List messages
Scope messages:read. Newest first; pass next_cursor as cursor for older messages. Never the destination or body: destination_hint is the tail of a keyed hash of the destination, as in the business portal, so recipients can be told apart without being shown. GET /v1/messages/{id} has the events.
| Field | Type | Description |
|---|---|---|
status | string · query | Comma-separated, any of: queued, sent, delivered, failed, bounced, expired |
channel | string · query | Comma-separated, any of: sms, email, whatsapp, push |
tag | string · query | Only messages sent with this tag |
since | string (date-time) · query | Created at or after |
until | string (date-time) · query | Created before |
limit | integer · query | |
cursor | string (uuid) · query |
Responses
- 200A page of messages · MessageList
- 400An unknown status or channel,
sincenot beforeuntil, or a cursor that is not a message in this environment · ErrorEnvelope - 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
messages:read· ErrorEnvelope
POST/v1/messages/batch
Send up to 1,000 messages
Scope messages:send. Up to 1000 items in a body of up to 5 MB, each a POST /v1/messages body. Every item is validated, checked against the suppression list, routed, held against the wallet and queued on its own: results has one entry per item, in order, with the queued message or that item's error (the codes POST /v1/messages would answer with), so one bad item never fails the rest. Item *i* is stored under the Idempotency-Key <key> <i>: resend the same batch with the same key and the items already accepted replay (replayed: true) while the others are tried again, so keep the items in the same order. A request-level error (below) means no item was looked at.
| Field | Type | Description |
|---|---|---|
Idempotency-Keyrequired | string · header | Unique per request, for example a UUID. A retry with the same key and body returns the original response for 24 hours. |
Request body (SendMessageBatchRequest)
| Field | Type | Description |
|---|---|---|
messagesrequired | array of SendMessageRequest | Each item is a POST /v1/messages body, validated on its own. |
.channelrequired | one of "sms", "email", "whatsapp", "push" | |
.torequired | string | |
.template | string | |
.variables | object | |
.locale | string | |
.body | string | |
.subject | string | |
.html | string | |
.sender_id | string | |
.tags | array of string | |
.schedule_at | string (date-time) | |
.metadata | object | |
.push | object | |
.title | string | |
.image | string (uri) | |
.deep_link | string | |
.data | object | |
.priority | one of "high", "normal" | |
.ttl | integer | |
.collapse_key | string | |
.badge | integer | |
.sound | string | |
.android_channel_id | string | |
.silent | boolean | |
.fallback | object | |
.whatsapp | object | |
.media | object |
Responses
- 200Every item has a result · MessageBatchResponse
- 400No
messagesarray, an empty one or more than 1,000 items (validation_failed), or no Idempotency-Key (idempotency_key_missing) · ErrorEnvelope - 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
messages:send, or the account is suspended · ErrorEnvelope - 413The body is over 5 MB (
payload_too_large); split the batch · ErrorEnvelope - 429The batch's messages don't fit in what is left of the key's rate limit this minute (
rate_limited, withRetry-After), or never could (noRetry-After; split it). Nothing was sent · ErrorEnvelope
GET/v1/messages/{id}
Current status and event history of a message
Scope messages:read. Only messages in the API key's environment are visible.
| Field | Type | Description |
|---|---|---|
idrequired | string (uuid) · path |
Responses
- 200The message · MessageDetail
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
messages:read· ErrorEnvelope - 404No such message in this environment · ErrorEnvelope
Templates
POST/v1/templates
Save a template
Scope templates:write. The first save of a key, channel and locale creates the template; each later save adds a version. Every {{variable}} in the body (and the email subject) is required when sending. Versions are approved on save, except WhatsApp, which waits for Meta. WhatsApp header gives the template a media header (image, document or video) with a sample file Meta's reviewers see (sample_media_id, an upload); each send then gives its own file in whatsapp.media. Category AUTHENTICATION takes exactly one variable, {{code}}: Meta sends its own text with a copy-code button, and POST /v1/otp/send on WhatsApp uses it.
| Field | Type | Description |
|---|---|---|
Idempotency-Keyrequired | string · header | Unique per request, for example a UUID. A retry with the same key and body returns the original response for 24 hours. |
Request body (CreateTemplateRequest)
| Field | Type | Description |
|---|---|---|
keyrequired | string | |
channelrequired | one of "sms", "email", "whatsapp", "push" | |
locale | string | |
bodyrequired | string | |
subject | string | |
html | string | |
description | string | |
category | one of "UTILITY", "MARKETING", "AUTHENTICATION" | |
examples | object | |
header | object | |
.formatrequired | one of "image", "document", "video" | |
.sample_media_idrequired | string (uuid) |
Responses
- 201The saved version. On a replay the response carries
Idempotent-Replayed: true. · Template - 400Invalid body or placeholders, or no Idempotency-Key · ErrorEnvelope
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
templates:write· ErrorEnvelope - 409The Idempotency-Key was used with a different body, or more than 24 hours ago · ErrorEnvelope
GET/v1/templates
List templates with their newest version
Scope templates:read. Oldest first; pass next_cursor as cursor for the next page.
| Field | Type | Description |
|---|---|---|
channel | string · query | |
key | string · query | |
locale | string · query | |
limit | integer · query | |
cursor | string (uuid) · query |
Responses
- 200A page of templates · TemplateList
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
templates:read· ErrorEnvelope
One-time codes
POST/v1/otp/send
Issue a one-time code
Scope otp:send. Sends a code through the messaging pipeline with tag otp (so a routing rule can prefer a fast route), using the environment's otp template when it has one. Length, TTL, attempts and resend cooldown default to the environment's settings (6 digits, 5 minutes, 3 attempts, 60 s). The code is never returned; keep id to verify.
| Field | Type | Description |
|---|---|---|
Idempotency-Keyrequired | string · header | Unique per request, for example a UUID. A retry with the same key and body returns the original response for 24 hours. |
Request body (SendOtpRequest)
| Field | Type | Description |
|---|---|---|
destinationrequired | string | |
channel | one of "sms", "whatsapp", "email" | |
length | integer | |
ttl | integer | |
template | string | |
locale | string | |
metadata | object |
Responses
- 202The challenge; the code is queued for delivery. On a replay the response carries
Idempotent-Replayed: true. · OtpChallenge - 400Invalid body, or no Idempotency-Key · ErrorEnvelope
- 401Missing or invalid key · ErrorEnvelope
- 402Production and staging: the wallet can’t cover the code’s price (
insufficient_balance) · ErrorEnvelope - 403The key lacks
otp:send· ErrorEnvelope - 409The Idempotency-Key was used with a different body, or more than 24 hours ago · ErrorEnvelope
- 422The destination is suppressed (
suppressed_destination), no routing rule matches (no_route), or the named template is missing or not approved · ErrorEnvelope - 429Inside the resend cooldown for this destination (
rate_limited, withRetry-After) · ErrorEnvelope
POST/v1/otp/verify
Check a code
Scope otp:verify. Every attempt answers 200 with a result: verified (emits otp.verified), invalid_code (attempts remain), too_many_attempts, expired (emits otp.expired) or already_verified. A challenge is bound to its destination, so a code sent to one number cannot verify another.
Request body (VerifyOtpRequest)
| Field | Type | Description |
|---|---|---|
challenge_idrequired | string (uuid) | |
coderequired | string |
Responses
- 200The result of this attempt · VerifyOtpResponse
- 400Invalid body · ErrorEnvelope
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
otp:verify· ErrorEnvelope - 404No such challenge in this environment · ErrorEnvelope
Identity verification
POST/v1/verifications
Start an identity check
Scope verifications:create. type nrc_lookup matches an NRC number and name against the registry; document reads and checks an identity document; liveness checks a live person took 1 to 8 selfie frames (pass, fail or inconclusive, with a score); face_match compares a selfie with a reference photo (passing at 85% similarity unless the environment changes it). Images are base64, up to 5 MB each and 10 MB together, kept as encrypted evidence. kyc_basic runs the environment's steps in order (by default document, face match, liveness and NRC lookup; the business portal sets which, in what order, and which stop the flow when they fail) from one subject: the document (also the face match's reference), the selfie frames (the first is the face match's selfie), and nrc, or an NRC document's expected number and names. It gives one decision: any step failing fails it, a step that couldn't run leaves it failed (step_failed), any review is review. steps and its events carry every step, and each completed step is billed at its own type's rate. Answers 202 with status: pending; the result arrives as verification.completed (with decision pass, review or fail against the environment's thresholds) or verification.failed. callback_url must be one of the environment's webhook endpoints; its events go there too. Sandbox runs against the simulator: NRC and document numbers ending /10/1 match, /10/2 are not found (a tampered document), /10/3 partially match, /10/9 fail; selfies pass and match unless an image carries switchgear-test: with liveness=fail, inconclusive or error, or face_match=no_match, borderline or error (the SDK exports such images as VERIFICATION_TEST_IMAGES).
| Field | Type | Description |
|---|---|---|
Idempotency-Keyrequired | string · header | Unique per request, for example a UUID. A retry with the same key and body returns the original response for 24 hours. |
Request body (CreateVerificationRequest)
| Field | Type | Description |
|---|---|---|
typerequired | "nrc_lookup" | |
subjectrequired | object | |
.nrcrequired | string | |
.first_namerequired | string | |
.last_namerequired | string | |
.date_of_birth | string (date) | |
callback_url | string (uri) | |
metadata | object | |
.document_typerequired | one of "nrc", "passport", "drivers_licence" | |
.front_imagerequired | string | |
.back_image | string | |
.content_typerequired | one of "image/jpeg", "image/png" | |
.expected | object | |
.document_number | string | |
.first_name | string | |
.last_name | string | |
.date_of_birth | string (date) | |
.frames | array of string | |
.challenge | string | |
.steps | array of object | |
.actionrequired | one of "blink", "turn_left", "turn_right", "smile" | |
.framesrequired | array of string | |
.times_msrequired | array of integer | |
.selfierequired | string | |
.reference_imagerequired | string | |
.documentrequired | object | |
.document_typerequired | one of "nrc", "passport", "drivers_licence" | |
.front_imagerequired | string | |
.back_image | string | |
.content_typerequired | one of "image/jpeg", "image/png" | |
.expected | object | |
.nrcrequired | string |
Responses
- 202The pending verification On a replay the response carries
Idempotent-Replayed: true. · Verification - 400Invalid body (including images over 10 MB together, or a kyc_basic without the NRC its NRC lookup needs), or no Idempotency-Key · ErrorEnvelope
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
verifications:create· ErrorEnvelope - 409The Idempotency-Key was used with a different body, or more than 24 hours ago · ErrorEnvelope
- 422No verification provider runs this type, or one of kyc_basic's steps, in the environment (
no_route, withtypeandflow) · ErrorEnvelope - 503Saved but not queued (
dependency_unavailable): retry with the same Idempotency-Key · ErrorEnvelope
GET/v1/verifications
List verifications
Scope verifications:read. Newest first; no evidence links (open one verification for those).
| Field | Type | Description |
|---|---|---|
type | one of "nrc_lookup", "document", "liveness", "face_match" · query | |
status | one of "pending", "completed", "failed" · query | |
decision | one of "pass", "review", "fail" · query | |
limit | integer · query | |
cursor | string (uuid) · query |
Responses
- 200A page of verifications · VerificationList
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
verifications:read· ErrorEnvelope
POST/v1/verifications/liveness/challenge
Start an active liveness challenge
Scope verifications:create. Returns a signed, single-use challenge, valid for 2 minutes, and 2 or 3 steps in a random order (blink, turn_left, turn_right, smile). The app asks the person to do each in turn, takes 2 to 5 frames during each, and sends them as a liveness check with subject.challenge and subject.steps (action, frames, times_ms). A challenge already used, expired, from another environment or with steps in another order is refused (400). Runs on providers that support active liveness (verify-local, and the simulator in sandbox).
Responses
- 201The challenge · LivenessChallenge
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
verifications:create· ErrorEnvelope
GET/v1/verifications/{id}
Get a verification
Scope verifications:read. The result, decision and reasons, with signed evidence links valid for 5 minutes (each download is audited). The subject is shown masked. Evidence is deleted after 7 days in sandbox and staging, 90 in production; the decision stays. A kyc_basic flow returns its steps inline, each with its result and provider response. A step can also be read by its own id (from steps or the event), with parent_id naming its flow; lists show only the flow.
| Field | Type | Description |
|---|---|---|
idrequired | string (uuid) · path |
Responses
- 200The verification · Verification
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
verifications:read· ErrorEnvelope - 404No such verification in this environment · ErrorEnvelope
Push
PUT/v1/push/apps
Register or update a push app
Scope push:manage. One app on one route: an Android package (fcm or hms), an iOS bundle id (apns), or a web origin (web). For FCM and Huawei, pass the parsed google-services.json or agconnect-services.json as config_file: only identifiers are kept (never API keys or the Huawei client secret), and they are checked against the environment’s credentials (push_config_mismatch when the file is for another project). A web app gets the environment’s VAPID public key, made on first use.
Request body (PutPushAppRequest)
| Field | Type | Description |
|---|---|---|
routerequired | one of "fcm", "hms", "apns", "web" | |
identifierrequired | string | |
config_file | object | |
vapid_subject | string |
Responses
- 200The app and its check · PushApp
- 400Invalid identifier or file · ErrorEnvelope
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
push:manage· ErrorEnvelope - 422The file names another project or app than the credentials (
push_config_mismatch) · ErrorEnvelope
GET/v1/push/apps
List push apps
Responses
- 200The environment’s push apps · PushAppList
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
push:manage· ErrorEnvelope
PUT/v1/push/devices
Register a push device
Scope push:devices. Idempotent: the same address again refreshes it, and under another user_id moves it (a shared phone). Addresses: fcm_fid (preferred) or fcm_token, hms_token, apns_token (with environment from the app build), or a webpush_subscription on a known push service. Outside sandbox, app_id must be a registered app. Send with POST /v1/messages { channel: "push", to: "user:<user_id>" }.
Request body (RegisterPushDeviceRequest)
| Field | Type | Description |
|---|---|---|
user_idrequired | string | |
app_idrequired | string | |
addressrequired | object |
Responses
- 200The device (never its address) · PushDevice
- 400Invalid address or app id · ErrorEnvelope
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
push:devices· ErrorEnvelope - 422The app is not registered (
push_app_not_registered) · ErrorEnvelope
POST/v1/push/devices/query
List push devices
Scope push:devices. Filter by user_id; live devices only unless include_invalidated; cursor-paginated. A POST so the user id (often an email or phone number) stays out of URLs and logs.
Request body (PushDeviceQuery)
| Field | Type | Description |
|---|---|---|
user_id | string | |
include_invalidated | boolean | |
limit | integer | |
cursor | string (uuid) |
Responses
- 200A page of devices · PushDeviceList
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
push:devices· ErrorEnvelope
DELETE/v1/push/devices/{id}
Stop sending to a device (logout)
| Field | Type | Description |
|---|---|---|
idrequired | string (uuid) · path |
Responses
- 204Invalidated
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
push:devices· ErrorEnvelope - 404No live device with this id · ErrorEnvelope
Suppressions
POST/v1/suppressions
Suppress a destination
Scope suppressions:write. Stop sending to a phone number, email address or push target, on one channel or (null or absent) on every channel. Sends to it then fail with 422 suppressed_destination until the suppression is removed. Idempotent without an Idempotency-Key: an active suppression for the same destination, channel and scope comes back as it is, with 200. scope: bulk (channel email only) stops just the emails that carry an unsubscribe link, for unsubscribes kept in your own systems; codes, receipts and transactional email still go. Opt-outs reported by Africa’s Talking, permanent email bounces and spam complaints reported by Resend, and unsubscribes from bulk email (scope bulk) are added automatically.
Request body (CreateSuppressionRequest)
| Field | Type | Description |
|---|---|---|
destinationrequired | string | |
channel | one of "sms", "email", "whatsapp", "push", or null | |
reason | string | |
scope | one of "all", "bulk" |
Responses
- 200Already suppressed: the existing suppression · Suppression
- 201Suppressed (never the destination itself: a masked form and a hint) · Suppression
- 400Invalid destination for the channel · ErrorEnvelope
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
suppressions:write· ErrorEnvelope
POST/v1/suppressions/query
List suppressions
Scope suppressions:write. Active suppressions, newest first, cursor-paginated. Filter by an exact destination, by channel (its own and the all-channel ones) or by source. A POST so a destination stays out of URLs and logs.
Request body (SuppressionQuery)
| Field | Type | Description |
|---|---|---|
destination | string | |
channel | one of "sms", "email", "whatsapp", "push" | |
source | one of "api", "portal", "opt_out", "bounce", "complaint", "erasure", "unsubscribe" | |
scope | one of "all", "bulk" | |
limit | integer | |
cursor | string (uuid) |
Responses
- 200A page of suppressions · SuppressionList
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
suppressions:write· ErrorEnvelope
DELETE/v1/suppressions/{id}
Remove a suppression
Scope suppressions:write. Sends to the destination work again. The removal is kept on record with the key that made it.
| Field | Type | Description |
|---|---|---|
idrequired | string (uuid) · path |
Responses
- 204Removed
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
suppressions:write· ErrorEnvelope - 404No active suppression with this id · ErrorEnvelope
Sandbox
POST/v1/sandbox/inbox/query
Read what the simulators delivered to a destination
Sandbox keys only (scope messages:read), for automated tests: returns simulator deliveries to to, newest first, with the body as the handset got it, so a test can read an OTP code. Only the last 7 days are kept. A POST so the destination stays out of URLs and logs. Delivery is asynchronous: poll, or use sandbox.waitForMessage in the SDK.
Request body (SandboxInboxQuery)
| Field | Type | Description |
|---|---|---|
torequired | string | |
since | string (date-time) | |
limit | integer |
Responses
- 200Deliveries, newest first · SandboxInbox
- 400Invalid body · ErrorEnvelope
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
messages:read(insufficient_scope), or is not a sandbox key (sandbox_only) · ErrorEnvelope
POST/v1/sandbox/inbound
Simulate a person writing to the sandbox on WhatsApp
Sandbox keys only (scope messages:send). The message arrives through the same path as Meta's webhook: it appears on GET /v1/inbound, emits inbound.received, and opens the 24-hour window for replies. media makes up a file of size_bytes and mime_type (default 2 KB of the kind's first type), which is downloaded and kept like a real one, or refused if it breaks WhatsApp's limits, so a test can try both.
Request body (SandboxInboundRequest)
| Field | Type | Description |
|---|---|---|
channelrequired | "whatsapp" | |
fromrequired | string | |
body | string | |
media | object | |
.kindrequired | one of "image", "document", "audio", "video", "sticker" | |
.mime_type | string | |
.size_bytes | integer | |
.filename | string |
Responses
- 202Queued; find it by
provider_ref· SandboxInboundResponse - 400Invalid body · ErrorEnvelope
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
messages:send(insufficient_scope), or is not a sandbox key (sandbox_only) · ErrorEnvelope - 503Not queued (
dependency_unavailable); retry · ErrorEnvelope
Media
POST/v1/media
Upload a file to send on WhatsApp
Scope messages:send. multipart/form-data with the file in file; kind (image, document, audio, video) defaults from its type, and filename (what a document's recipient sees) from the upload. Send it with whatsapp.media.media_id, or name it as a template header's sample. Limits are Meta's: image up to 5 MB (image/jpeg, image/png); document up to 16 MB (application/pdf, text/plain, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-powerpoint, application/vnd.openxmlformats-officedocument.presentationml.presentation); audio up to 16 MB (audio/aac, audio/amr, audio/mpeg, audio/mp4, audio/ogg); video up to 16 MB (video/mp4, video/3gpp). Kept encrypted for 7 days in sandbox and staging, 30 in production.
Responses
- 201The stored file · MediaObject
- 400No
file· ErrorEnvelope - 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
messages:send· ErrorEnvelope - 413Over WhatsApp's limit for the kind (
payload_too_large) · ErrorEnvelope - 415A type WhatsApp doesn't send as that kind (
unsupported_media_type) · ErrorEnvelope
GET/v1/media/{id}/content
Download a file a person sent in
The signed link GET /v1/inbound gives in media.url, valid for 5 minutes; no API key, so it can go straight to a browser or a download tool. Fetch the inbound message again for a new link. Files are kept 7 days in sandbox and staging, 90 in production, then media.status is expired.
| Field | Type | Description |
|---|---|---|
idrequired | string (uuid) · path | |
exprequired | string · query | |
sigrequired | string · query |
Responses
- 200The file, as an attachment, in its own type
- 404The link has expired or is wrong, or the file is gone · ErrorEnvelope
Usage
GET/v1/usage
Usage and charges for a month
Scope usage:read. What the API key's environment used in one UTC month, per channel and provider: billable messages (USSD: sessions), units (SMS segments, emails…) and what they were charged, in minor units of the organisation's currency. Sandbox usage is charged at zero. Statements in the business portal cover the whole organisation.
| Field | Type | Description |
|---|---|---|
period | string · query | YYYY-MM; defaults to the current UTC month |
Responses
- 200The month’s usage · Usage
- 400
periodis notYYYY-MM· ErrorEnvelope - 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
usage:read· ErrorEnvelope
packages
GET/v1/packages
Prepaid packages and what they have left
Scope usage:read. The organisation's running prepaid packages (bought in the business portal), soonest end first, and their units left added up: SMS segments to Zambian numbers, WhatsApp utility and authentication templates, and emails. Package units are used before the wallet; international SMS, WhatsApp marketing, push, USSD and verification are always charged from the wallet. Sandbox never uses packages.
Responses
- 200Running packages · PackageBalance
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
usage:read· ErrorEnvelope
Inbound messages
GET/v1/inbound
List inbound messages (replies and MO traffic)
Scope inbound:read. Newest first; pass next_cursor as cursor for older messages. from is masked (+260*******01); the full sender is never returned. matched_message_id is the message a reply answers, when the vendor says which (WhatsApp). Each new message also emits inbound.received. A WhatsApp file comes as media: pending while it downloads, then stored with a signed url valid for 5 minutes, or too_large, unsupported or failed. Reply within the window with POST /v1/inbound/{id}/reply.
| Field | Type | Description |
|---|---|---|
channel | string · query | |
limit | integer · query | |
cursor | string (uuid) · query |
Responses
- 200A page of inbound messages · InboundList
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
inbound:read· ErrorEnvelope
GET/v1/inbound/{id}
One inbound message
Scope inbound:read. For example the inbound_id of an inbound.received event, which carries no text.
| Field | Type | Description |
|---|---|---|
idrequired | string (uuid) · path |
Responses
- 200The message · InboundMessage
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
inbound:read· ErrorEnvelope - 404No such inbound message in this environment · ErrorEnvelope
POST/v1/inbound/{id}/reply
Reply on WhatsApp inside the customer-service window
Scope messages:send. Free text (body) or a file (media, with body as its caption) back to the person who sent this message, quoting it. Meta allows it only within 24 hours of the person's last message; after that, send an approved template with POST /v1/messages. Billed as a WhatsApp service conversation. The answer is the queued message; follow it like any other.
| Field | Type | Description |
|---|---|---|
idrequired | string (uuid) · path | |
Idempotency-Keyrequired | string · header | Unique per request, for example a UUID. A retry with the same key and body returns the original response for 24 hours. |
Request body (InboundReplyRequest)
| Field | Type | Description |
|---|---|---|
body | string | |
media | object | |
.kindrequired | one of "image", "document", "audio", "video" | |
.url | string (uri) | |
.media_id | string (uuid) | |
.filename | string | |
tags | array of string | |
metadata | object |
Responses
- 202The queued reply. On a replay the response carries
Idempotent-Replayed: true. · Message - 400Invalid body, a message on another channel, or no Idempotency-Key · ErrorEnvelope
- 401Missing or invalid key · ErrorEnvelope
- 402The wallet can’t cover the estimated price (
insufficient_balance) · ErrorEnvelope - 403The key lacks
messages:send· ErrorEnvelope - 404No such inbound message in this environment · ErrorEnvelope
- 409The Idempotency-Key was used with a different body, or more than 24 hours ago · ErrorEnvelope
- 415The uploaded file is not a type WhatsApp sends as that kind (
unsupported_media_type) · ErrorEnvelope - 422More than 24 hours since the person last wrote (
outside_service_window, withlast_inbound_at), or an unknown or expiredmedia_id(media_not_found) · ErrorEnvelope
USSD
POST/v1/ussd/sessions/{id}/end
End a live USSD session
Scope ussd:manage. Closes the session now and emits ussd.session.ended with reason api_end. The handset still shows the last screen; the next request the telco makes gets text (default "This session has ended.") as a closing screen. The id is the session_id your callback receives. Each step of a session is a signed POST to the shortcode's callback (UssdCallbackRequest), answered with UssdCallbackResponse within the shortcode's budget.
| Field | Type | Description |
|---|---|---|
idrequired | string (uuid) · path |
Request body (EndUssdSessionRequest)
| Field | Type | Description |
|---|---|---|
text | string |
Responses
- 200Ended · EndUssdSessionResponse
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
ussd:manage· ErrorEnvelope - 404No live session with that id in this environment (sessions live in the gateway only while active) · ErrorEnvelope
- 409The session has already ended (
ussd_session_ended) · ErrorEnvelope
POST/v1/ussd/emulator
Drive a USSD session on the simulator (sandbox)
Scope ussd:manage, sandbox keys only. Start with dial (a simulator shortcode of this environment, optionally with extra digits: *384*55*1#), then send session with each input. Runs the same session engine and calls the same callback as a telco would. Digits after the code are step 0 input, as from a telco: *388*4*334466*20# on *388*4# gives 334466*20. msisdn (E.164 with the plus, default +260970000001) and network (MCC-MNC such as 64502; default null) go with dial and reach the callback on every step. The answer is { session, msisdn, text, end }, not the callback's { text, action, state }: end is true once the session is over.
Request body (UssdEmulatorRequest)
| Field | Type | Description |
|---|---|---|
dial | string | |
session | string | |
input | string | |
msisdn | string | |
network | string |
Responses
- 200The screen to show · UssdEmulatorResponse
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
ussd:manage(insufficient_scope), or is not a sandbox key (sandbox_only) · ErrorEnvelope - 404No simulator shortcode matches the dial, or the session handle is unknown · ErrorEnvelope
- 409The emulator session has ended; dial again · ErrorEnvelope
Webhook endpoints
POST/v1/webhook-endpoints
Register a webhook endpoint
Scope webhooks:manage. Returns the signing secret once (a replay within 24 hours repeats it). event_types empty means every type. Outside sandbox the URL must be https. See the webhooks section for the events and their signature.
| Field | Type | Description |
|---|---|---|
Idempotency-Keyrequired | string · header | Unique per request, for example a UUID. A retry with the same key and body returns the original response for 24 hours. |
Request body (CreateWebhookEndpointRequest)
| Field | Type | Description |
|---|---|---|
urlrequired | string (uri) | |
event_types | array of one of "message.queued", "message.sent", "message.delivered", "message.failed", "inbound.received", "otp.verified", "otp.expired", "verification.completed", "verification.failed", "ussd.session.started", "ussd.session.ended", "ussd.flow.event", "ussd.screen.truncated", "email.unsubscribed" | |
description | string |
Responses
- 201The endpoint with its secret. On a replay the response carries
Idempotent-Replayed: true. · CreatedWebhookEndpoint - 400Invalid body or URL, or no Idempotency-Key · ErrorEnvelope
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
webhooks:manage· ErrorEnvelope - 409The Idempotency-Key was used with a different body, or more than 24 hours ago · ErrorEnvelope
GET/v1/webhook-endpoints
List webhook endpoints (without secrets)
Responses
- 200The environment's endpoints · WebhookEndpointList
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
webhooks:manage· ErrorEnvelope
DELETE/v1/webhook-endpoints/{id}
Turn a webhook endpoint off
Scope webhooks:manage. The endpoint stops receiving events at once (deliveries already queued for it are dropped) and stays in the list with enabled: false, with its delivery history. Turn it back on in the business portal. Doing it again changes nothing.
| Field | Type | Description |
|---|---|---|
idrequired | string (uuid) · path |
Responses
- 204Off
- 401Missing or invalid key · ErrorEnvelope
- 403The key lacks
webhooks:manage· ErrorEnvelope - 404No webhook endpoint with this id in this environment · ErrorEnvelope
Webhook events
Each signed event contains id, type, created_at, and data. The fields below describe each event’s data payload. Configure subscriptions in the portal’s Webhooks section and verify deliveries with the endpoint’s signing secret.
message.queued
| Field | Type | Description |
|---|---|---|
message_idrequired | string (uuid) | |
channelrequired | one of "sms", "email", "whatsapp", "push" | |
statusrequired | one of "queued", "sent", "delivered", "failed", "bounced", "expired" | |
tagsrequired | array of string | |
providerrequired | string or null | |
provider_refrequired | string or null | |
reasonrequired | string or null | |
raw_coderequired | string or null | |
cost_unitsrequired | number or null | |
metadatarequired | object, or null |
message.sent
| Field | Type | Description |
|---|---|---|
message_idrequired | string (uuid) | |
channelrequired | one of "sms", "email", "whatsapp", "push" | |
statusrequired | one of "queued", "sent", "delivered", "failed", "bounced", "expired" | |
tagsrequired | array of string | |
providerrequired | string or null | |
provider_refrequired | string or null | |
reasonrequired | string or null | |
raw_coderequired | string or null | |
cost_unitsrequired | number or null | |
metadatarequired | object, or null |
message.delivered
| Field | Type | Description |
|---|---|---|
message_idrequired | string (uuid) | |
channelrequired | one of "sms", "email", "whatsapp", "push" | |
statusrequired | one of "queued", "sent", "delivered", "failed", "bounced", "expired" | |
tagsrequired | array of string | |
providerrequired | string or null | |
provider_refrequired | string or null | |
reasonrequired | string or null | |
raw_coderequired | string or null | |
cost_unitsrequired | number or null | |
metadatarequired | object, or null |
message.failed
| Field | Type | Description |
|---|---|---|
message_idrequired | string (uuid) | |
channelrequired | one of "sms", "email", "whatsapp", "push" | |
statusrequired | one of "queued", "sent", "delivered", "failed", "bounced", "expired" | |
tagsrequired | array of string | |
providerrequired | string or null | |
provider_refrequired | string or null | |
reasonrequired | string or null | |
raw_coderequired | string or null | |
cost_unitsrequired | number or null | |
metadatarequired | object, or null |
inbound.received
| Field | Type | Description |
|---|---|---|
inbound_idrequired | string (uuid) | |
channelrequired | one of "sms", "email", "whatsapp", "push" | |
fromrequired | string | |
providerrequired | string | |
provider_refrequired | string | |
matched_message_idrequired | string (uuid), or null | |
media_kindrequired | one of "image", "document", "audio", "video", "sticker", or null | |
received_atrequired | string (date-time) |
otp.verified
| Field | Type | Description |
|---|---|---|
challenge_idrequired | string (uuid) | |
channelrequired | one of "sms", "whatsapp", "email" | |
statusrequired | one of "pending", "verified", "expired", "too_many_attempts" | |
message_idrequired | string (uuid) | |
metadatarequired | object, or null |
otp.expired
| Field | Type | Description |
|---|---|---|
challenge_idrequired | string (uuid) | |
channelrequired | one of "sms", "whatsapp", "email" | |
statusrequired | one of "pending", "verified", "expired", "too_many_attempts" | |
message_idrequired | string (uuid) | |
metadatarequired | object, or null |
verification.completed
| Field | Type | Description |
|---|---|---|
verification_idrequired | string (uuid) | |
typerequired | one of "nrc_lookup", "document", "liveness", "face_match", "kyc_basic" | |
statusrequired | one of "pending", "completed", "failed", "skipped" | |
decisionrequired | one of "pass", "review", "fail", or null | |
confidencerequired | number or null | |
reasonsrequired | array of string | |
failure_coderequired | string or null | |
providerrequired | string or null | |
stepsrequired | array of object, or null | |
metadatarequired | object, or null |
verification.failed
| Field | Type | Description |
|---|---|---|
verification_idrequired | string (uuid) | |
typerequired | one of "nrc_lookup", "document", "liveness", "face_match", "kyc_basic" | |
statusrequired | one of "pending", "completed", "failed", "skipped" | |
decisionrequired | one of "pass", "review", "fail", or null | |
confidencerequired | number or null | |
reasonsrequired | array of string | |
failure_coderequired | string or null | |
providerrequired | string or null | |
stepsrequired | array of object, or null | |
metadatarequired | object, or null |
ussd.session.started
| Field | Type | Description |
|---|---|---|
session_idrequired | string (uuid) | |
shortcode_idrequired | string (uuid) | |
shortcoderequired | string | |
msisdnrequired | string | |
networkrequired | string or null | |
providerrequired | string | |
started_atrequired | string (date-time) |
ussd.session.ended
| Field | Type | Description |
|---|---|---|
session_idrequired | string (uuid) | |
shortcode_idrequired | string (uuid) | |
shortcoderequired | string | |
msisdnrequired | string | |
networkrequired | string or null | |
providerrequired | string | |
started_atrequired | string (date-time) | |
reasonrequired | one of "user_end", "app_end", "app_timeout", "app_error", "telco_timeout", "telco_error", "redis_unavailable", "api_end" | |
stepsrequired | integer | |
ended_atrequired | string (date-time) | |
duration_msrequired | integer |
ussd.screen.truncated
| Field | Type | Description |
|---|---|---|
session_idrequired | string (uuid) | |
shortcode_idrequired | string (uuid) | |
steprequired | integer | |
lengthrequired | integer | |
limitrequired | integer |
email.unsubscribed
| Field | Type | Description |
|---|---|---|
message_idrequired | string (uuid) | |
suppression_idrequired | string (uuid) | |
channelrequired | "email" | |
destination_hintrequired | string | |
tagsrequired | array of string | |
metadatarequired | object, or null | |
unsubscribed_atrequired | string (date-time) |