Developers · Public API v1
Send your first MMS.
Submit a customer message and a supported image from your server. Keep the message ID, follow its recorded status and receive signed event notifications.
Request an account Get integration help
Already integrating? Your existing in-account API area remains available after sign-in.
Before you send
You need an enabled MMS Express account, an available account balance, a server-side API key and permission to contact the recipient. Confirm your API origin and testing arrangements with us. This page does not assume a public sandbox or a charge-free send mode.
Set MMS_EXPRESS_BASE_URL to the approved HTTPS API origin, such as the origin provided during integration setup, without /api/v1. The website origin and API origin may differ. Use the correct credentials for the agreed environment.
Send Authorization: Bearer $MMS_EXPRESS_API_KEY. Keep the key in server environment variables or your secret store. Never embed it in browser JavaScript, a mobile client, a public repository or a URL.
| Scope | Use |
|---|---|
messages:send | Submit messages and read current outgoing limits. |
messages:read | Read messages, history and account-authorised media. |
webhooks:manage | Manage your webhook endpoints and delivery attempts. |
channels:read | Read your account's available channels when using an existing channel-specific integration. |
subAccountId is rejected with HTTP 403. Sub-account messages consume the main account's shared balance, subject to account permissions and limits.Prefer an international recipient number in +27 format. South African local numbers beginning with 0 are normalised to +27; formatting validation does not establish ownership, current network, availability or compatibility. Use only authorised supported recipients. Read South African availability and receiver requirements and responsible-use information.
Start with a statement image
- Export the relevant document content to a legible still image. Check the current limits below.
- Persist a unique business request ID as
MMS_EXPRESS_IDEMPOTENCY_KEY. Keep it with the exact request for recovery. - Submit to
POST /api/v1/messages. Store the returnedid. - Read
GET /api/v1/messages/{id}or process verified webhook events. HTTP 202 means accepted for processing.
The JSON request below uses placeholders. Replace them with your authorised recipient and an immutable, approved image URL served over HTTPS. For private documents, prefer the multipart upload example so you do not need to publish a source URL.
POST /api/v1/messages
Authorization: Bearer $MMS_EXPRESS_API_KEY
Idempotency-Key: $MMS_EXPRESS_IDEMPOTENCY_KEY
Content-Type: application/json{
"to": "<authorised +27 recipient>",
"text": "Your September statement is attached.",
"media": [
{ "url": "https://example.invalid/statement.jpg" }
]
}
Download runnable server-side examples
The document and message are examples. Running an example against a live account submits a chargeable message; the website preview itself sends nothing.
Images, documents and actual limits
GET /api/v1/message-limits with messages:send returns the effective maximumTextCharacters, maximumMediaBytes and kilobyteBytes (1024). Fetch these when preparing a send. Server validation remains authoritative if settings change.
| Content | Implemented behaviour |
|---|---|
| Message text | At most 250 user-perceived characters; the effective limit can be lower. Unicode text elements are counted, rather than UTF-8 bytes. Text is trimmed. Supply text, media or both. |
| Still images | JPEG, PNG, WebP and single-frame GIF are accepted when enabled for the account environment. Images are decoded, oriented and converted to JPEG; transparency is placed on white and image metadata is stripped. Animated images are rejected. |
| PDF and document files | Direct PDF upload and PDF-to-image conversion are not part of this API. Render the relevant pages in your own approved document workflow, inspect readability, then upload the resulting images. The recipient receives document images, not the original PDF, selectable PDF text or an arbitrary file attachment. |
| Other permitted media | The supported type set also includes UTF-8 text/plain, video/mp4 and video/3gpp, subject to configuration and receiver compatibility. This quick-start uses still document images. Do not assume arbitrary formats are accepted. |
| Final media size | The effective total is returned by the limits endpoint. The application default is 300 KiB (307,200 bytes), across all final attachments. JPEG conversion can change the size. |
| Source upload safety limits | Defaults are 10 attachments, 5,000,000 bytes per source attachment and 10,000,000 bytes combined. These are configurable and are separate from the final MMS limit. The submit action also caps the request body at 12 MiB; hosting may impose a smaller limit. Confirm deployed source limits during setup. |
| Image dimensions | At most 16,384 pixels on either side, 24 megapixels per image and 32 megapixels combined. These are processing safety limits, not a promise of readability on a small screen. |
compressImages: true requires targetImageSizeKb from 1 to 10240. The target applies to each output image and does not override the total MMS limit. Supplying a target with compression disabled is rejected. If the target cannot be reached within the implemented readability safeguards, the request fails instead of silently shrinking the image dimensions.
joinImages: true requires at least two images and no other attachment types. It joins them vertically in request order, subject to the same processing and final-size checks. Without joining, each input still image becomes a separate JPEG. Check the final result in a controlled test before sending long or detailed statements.
Remote media must be reachable over HTTPS and pass network-address, type, size and redirect checks. Do not put credentials in URLs. A mutable URL can change its content before the first successful acceptance: use immutable content, or upload the exact bytes. An exact replay returns the retained message rather than fetching the URL again.
Submit a message
POST /api/v1/messages accepts JSON or multipart/form-data. Both require messages:send. One request names one recipient.
| Field | Type and rule |
|---|---|
to | Required recipient string. |
text | Optional string, within the effective text limit. |
media | JSON array of objects containing an HTTPS url. For multipart, send the files as file parts instead; the example uses files. |
channelId | Optional public chn_… ID belonging to the authenticated account. Usually omit it. Existing integrations can discover available account channels with GET /api/v1/channels and channels:read. |
compressImages | Optional boolean, default false. |
targetImageSizeKb | Optional integer; required only when compression is enabled. |
joinImages | Optional boolean, default false. |
Send Idempotency-Key as an HTTP header, not a JSON field. It is optional in the API and strongly recommended for every integration. Use a non-blank value of at most 128 characters without control characters.
Acceptance, history and status
A successful submission returns 202 Accepted, a Location for message retrieval, and a JSON object. An exact idempotent replay also returns 202 and sets idempotentReplay to true; its status may have progressed since the original request.
The following is an illustrative projection of the response. Account-specific financial fields are omitted from this public example.
{
"id": "msg_11111111111111111111111111111111",
"status": "queued",
"idempotentReplay": false,
"conversationId": "conv_22222222222222222222222222222222",
"nextEligibleAt": null,
"queueReason": null,
"finalMediaSizeBytes": 102400
}| Field | Meaning |
|---|---|
id | Public msg_ message ID. Store it with your business request. |
status | Recorded message state, normally queued at first acceptance. |
price | Numeric account-specific charge returned privately to the authenticated caller. No public rate is specified here. |
currency | Currency string associated with the account response. |
idempotentReplay | Boolean identifying a retained, matching request. |
conversationId | Related public conversation ID, or null. |
nextEligibleAt | Next applicable sending-window time, or null; not a delivery estimate. |
queueReason | waiting_for_sending_window when relevant, otherwise null. |
finalMediaSizeBytes | Total final attachment bytes after processing. |
GET /api/v1/messages/{id} with messages:read returns HTTP 200 for an accessible message, or 404 when it is not found in that account. Its JSON fields are:
- Identity and content:
id,direction,from,to, nullabletext,status,transport(mmsorsmsfor recorded incoming messages). - Context: nullable
channelId,channelName,conversationId,inReplyToMessageId, and booleanreplyLinkInferred. An inferred reply link is not proof of recipient intent. - Timing and outcome:
createdAt, nullablereceivedAt,nextEligibleAt,queueReason,failureCodeandfailureMessage. Date-time values use ISO 8601 with an offset. - Media:
finalMediaSizeBytesand amediaarray withid,fileName,contentType,sizeBytesanddownloadUrl. Read account media atGET /api/v1/media/{id}with the same read scope; URLs are not anonymous document links. - History: an
eventsarray withtimestamp,statusanddescription. Financial fieldspriceChargedandcurrencyare available only in the authenticated response.
GET /api/v1/messages supports optional search, direction, status, page (default 1) and pageSize (default 50, maximum 100). Its JSON envelope is { items, page, pageSize, total }. List items contain id, direction, counterparty, textPreview, status, channel, hasAttachments, timestamp, transport, conversationId, nextEligibleAt and queueReason.
| Status | What it tells you |
|---|---|
accepted | The request was accepted; this can appear in history. |
queued | Waiting for sending, potentially for an account sending window. |
assigned | Allocated for processing. |
transferring | Message content is being prepared for sending. |
sending | A send is in progress. Its final outcome is not yet known. |
sent | A successful sending outcome was recorded. This is not delivery confirmation or a read receipt. |
failed | The send did not complete successfully, or its outcome requires attention. Read the available failure information before acting. |
received | An incoming message was recorded. This does not mean your outgoing message was received. |
There is no delivered or read state in this contract. A missing event, timeout or delayed status is not a successful delivery. See the reliability methodology for outcome and timing definitions.
Retry the request safely
Persist the idempotency key before submitting and use the same key for recovery from an uncertain HTTP outcome. Matching account, recipient, text, media order/content or URL, channel and processing options return the retained message with idempotentReplay: true. They do not submit a second message or debit again.
Reusing a key for a different request returns 409 idempotency_conflict. Do not “fix” that conflict by generating a new key until your application has reconciled the original business request. Keep immutable source images and the submitted manifest. Do not assume a documented expiry window or indefinite idempotency after historical records are removed; retain your own business-request record.
For a connection failure, timeout, 429 or retryable server failure, use bounded exponential backoff with jitter and the same request/key. A later 202 can be an exact replay. Once you have a message ID, retrieve its state instead of creating another send. A failed or uncertain sending outcome needs review; a client retry of the original request is not permission to resend a possibly sent message.
Errors and request limits
Application errors use error, message and traceId. Preserve the trace ID for support and handle the stable error code. Framework validation or an upstream HTTP service can return a different body; do not assume every failure is JSON.
{
"error": "idempotency_conflict",
"message": "The idempotency key was already used for a different request, or retained historical evidence cannot prove an exact replay.",
"traceId": "example-trace-id"
}| HTTP | Examples | Action |
|---|---|---|
| 400 | invalid_request, invalid_recipient, invalid_idempotency_key, unsupported_media, too_many_attachments, message_too_large, media_too_large, source_media_too_large | Correct the request. A media-size error can refer to the source file or final total; read its message. |
| 400 | invalid_image_options, invalid_image_target, invalid_image_join, image_target_unachievable, image_dimensions_too_large, animated_image_not_supported | Correct image inputs/options; do not assume compression can meet every target. |
| 401 | unauthorized | Check the account credential; never expose it while troubleshooting. |
| 402 | insufficient_credit, financial_hold, payment_reconciliation_hold | Resolve account funding or reconciliation before a new send. |
| 403 | forbidden, account_unavailable, monthly_credit_limit, sub_account_selection_not_supported | Check permission, account state and the sending account's credential. |
| 404 | not_found | Check the resource ID and account scope. |
| 409 | idempotency_conflict | Reconcile the original request; do not automatically change its key. |
| 413 | request_too_large | Reduce the request body within application and hosting limits. |
| 429 | rate_limit_exceeded | Back off with jitter. Keep the same key and request when retrying a submission. |
| 500 | server_error | Treat submission as potentially uncertain; retry cautiously with the same key or contact support with the trace ID. |
Each API key has its own configured request allowance. Your effective allowance is shown in API key settings; upstream controls may also apply. This is an HTTP request limit, not a sending-throughput promise. Status polling shares the allowance. A Retry-After header is not guaranteed by this contract; use conservative bounded backoff when absent.
Receive signed webhook events
Create an endpoint with POST /api/v1/webhooks and webhooks:manage. Its URL must be a public HTTPS endpoint under your control. Supported events are message.sent, message.failed and message.received; incoming events depend on the account's available receiving capability.
{
"url": "https://example.invalid/webhooks/mms-express",
"events": ["message.sent", "message.failed"]
}Creation returns HTTP 201 with { endpoint, secret }. The endpoint contains id, url, events, enabled, consecutiveFailures and createdAt. Save the signing secret securely at creation; listing endpoints does not reveal it. It is a different credential from your API key.
Each POST to your receiver has X-MMS-Express-Event, X-MMS-Express-Timestamp (Unix seconds) and X-MMS-Express-Signature. Verify:
v1=lowercase_hex(
HMAC_SHA256(signing_secret, timestamp + "." + exact_raw_JSON_body)
)- Read bounded raw request bytes before JSON middleware rewrites them. Reject missing, duplicated/ambiguous or malformed signature headers.
- Reject timestamps outside your chosen tolerance; the downloadable receiver uses five minutes either side of its clock. Keep your clock synchronised. The timestamp is refreshed for each attempt, so retry age is not event age.
- Compute HMAC-SHA256 over the timestamp, a full stop and the original UTF-8 body. Compare signatures in constant time.
- Only then parse the event, and require the signed body's
idto equal the event header. - Atomically store the event ID and enqueue its work in a durable inbox with a unique constraint. A previously committed identical event is a successful duplicate. Send a 2xx acknowledgement only after that commit; return an error if persistence fails.
The JSON envelope is { id, type, createdAt, data: { message } }. Its message contains id, direction, transport, from, to, text, status, channelId, conversationId, inReplyToMessageId, replyLinkInferred, finalMediaSizeBytes, receivedAt, failureCode, failureMessage and media with id, fileName, contentType and sizeBytes. Store payloads privately and avoid body logging.
Non-success responses and transport failures are retried using a bounded, configurable exponential schedule. Repeated failures can disable an endpoint. Attempts can duplicate and need not arrive in business-event order; use message retrieval to reconcile current state. A webhook's successful delivery means your HTTPS receiver acknowledged it, not that the customer received the MMS.
| Method and path | Result |
|---|---|
GET /api/v1/webhooks | 200; array of your endpoints without signing secrets. |
DELETE /api/v1/webhooks/{id} | 204; disable the endpoint. |
POST /api/v1/webhooks/{id}/enable | 204; re-enable an eligible endpoint. |
GET /api/v1/webhooks/deliveries?take=100 | 200; delivery-attempt history, maximum take 250. Includes event ID, status, attempt count, latest HTTP status, error and timing. |
POST /api/v1/webhooks/deliveries/{id}/retry | 204; request another delivery attempt. Your receiver must still deduplicate it. |
Download the server-side examples
These are plain-text source files. Rename them as described below, review them and set environment variables on your server. They do not run from this page and include no live credentials or preselected real recipient.
| Example | Run and requirements |
|---|---|
| cURL / Bash | bash send-mms.sh; bash, curl and jq. Uses a local JPEG upload. |
| C# / .NET | Run dotnet new console in an empty directory, replace Program.cs, then dotnet run. .NET 8+; uses an HTTPS image URL. |
| PHP | php send-mms.php; PHP 8.1+ with cURL. Uses an HTTPS image URL. |
| Server-side JavaScript | node send-mms.mjs; Node.js 22+. Uses an HTTPS image URL. |
| Webhook verification / durable inbox boundary | Node.js 22+ module. Import into your HTTPS receiver and implement its durable inbox adapter. It is not a complete hosted receiver. |
Set MMS_EXPRESS_BASE_URL, MMS_EXPRESS_API_KEY (send and read scopes), MMS_EXPRESS_TO, and a persisted MMS_EXPRESS_IDEMPOTENCY_KEY. For cURL, set MMS_EXPRESS_IMAGE_PATH; for the other send examples, set MMS_EXPRESS_MEDIA_URL. All examples submit once and retrieve the status once. Build durable request storage, bounded recovery and your business-specific error handling into your integration.