Start free
NewRate limits with Retry-AfterExplore

From your first send to a signed webhook.

One REST API for WhatsApp, SMS, RCS, email and push, with test and live keys, safe retries and signed events. The whole public contract lives in openapi.json.

Test keys start with ccx_test_ and run on a test wallet. Nothing reaches a customer.

API basehttps://api.messages.ccxcompany.co
Quickstartccx_test_
Done. Nothing reached a customer and nothing was charged.
  1. Create an API keyccx_test_••••••••Shown once
  2. Send the messagePOST /messages201 queued
  3. Get the status on your webhookmessage.deliveredSignature valid
A sample run with a test key. Values with … are placeholders.

Your first send, in three steps.

Start with a test key: it uses the test wallet and a simulated send, so nothing reaches any customer and nothing is really charged.

POST /messages
curl -X POST https://api.messages.ccxcompany.co/messages \  -H "Authorization: Bearer $CCX_API_KEY" \  -H "Content-Type: application/json" \  -H "Idempotency-Key: order-4821-shipped" \  -d '{    "numberId": "num_…",    "to": "+14155550123",    "type": "text",    "payload": { "text": { "body": "Your order is out for delivery" } }  }'

Body shortened; the full message object is in openapi.json. Values with … are placeholders.

Keys, scopes and safe retries.

Every call carries Authorization: Bearer with your key. Each route requires a scope, and a key can only do what its scopes allow.

ccx_test_Test keyUses the test wallet and the simulated transport. Use it to validate the integration without spending.
ccx_live_Live keySends for real and charges the live wallet at send time. The key stored on our side decides the environment, not the request body.
Scopesmessages:sendmessages:readsms:sendemail:sendFor example. A key without the route's scope gets a 403. Create keys under Settings, API Keys; the secret is shown only once.
metadataAn object of yours that travels with the message and comes back untouched in every webhook for it. Handy for your own order id.

Retry without sending twice.

On WhatsApp, SMS and push sends, the Idempotency-Key header lets you repeat the same call for 24 hours. Same key and same body return the original response. Same key with a different body returns 409. Try it.

Use a value of your own, like the order number, so a retry after a timeout can't send twice.
The key is valid for 24 hours. After that, the same value counts as a new send.
A 409 comes with meta.code IDEMPOTENCY_CONFLICT. Use a new key for a new send. On RCS, any reused key returns 409.
  1. Idempotency-Key: order-4821 · body A201msg_01… created and queued
  2. Idempotency-Key: order-4821 · body A201Original response for msg_01…, nothing sent again.
  3. Idempotency-Key: order-4821 · body B409CONFLICT · IDEMPOTENCY_CONFLICT
1message created

Simulated responses, abbreviated. Values with … are placeholders.

Signed webhooks, with redelivery.

Register the URL, pick the events and keep the secret. Every delivery carries X-CCX-Signature, X-CCX-Event, X-CCX-Delivery-Id and X-CCX-Idempotency-Key.

Answer 2xx within 10 secondsQueue the work and return 200 right away. Anything over 10 seconds counts as a failure.
Five more attemptsOn 5xx, 408, 429 or no answer: after 5 s, 30 s, 2 min, 10 min and 1 h. Any other 4xx is a final refusal, and 410 deactivates the webhook.
Handle redeliveryRedelivery is expected: use X-CCX-Idempotency-Key to record each event only once.
Validate before savingPOST /webhooks/validate sends a signed ping to the URL before you register it. Rotate the secret with POST /webhooks/{id}/rotate-secret.
Retries when your endpoint answers 5xx, 408 or 429, or doesn't answer
  1. 5 s
  2. 30 s
  3. 2 min
  4. 10 min
  5. 1 h
import { createHmac, timingSafeEqual } from "node:crypto"; // X-CCX-Signature: t=<unix>,v1=<hex>export function validSignature(secret, body, header) {  const p = Object.fromEntries(    header.split(",").map((kv) => kv.split("=")));  if (Math.abs(Date.now() / 1000 - p.t) > 300) return false;  const expected = createHmac("sha256", secret)    .update(p.t + "." + body).digest("hex");  return expected.length === p.v1.length &&    timingSafeEqual(Buffer.from(expected, "hex"),                    Buffer.from(p.v1, "hex"));}

Compute it over the raw body, exactly as received, without re-serializing the JSON. The 5-minute window on the t stamp blocks replays of old events.

  • message.sent
  • message.delivered
  • message.read
  • message.failed
  • message.received
  • template.approved
  • template.rejected
  • sms.delivered
  • sms.received
  • email.delivered
  • email.bounced
  • email.opened
  • wallet.low_balance

When the response isn't 2xx.

Every error comes as problem details (RFC 7807), with a stable code your own code can compare against. Pick one to see its response.

Response · 409 CONFLICT
HTTP/1.1 409Content-Type: application/problem+json {  "type": "about:blank",  "title": "conflict",  "code": "CONFLICT",  "status": 409,  "detail": "idempotency key reused with a different payload",  "meta": { "code": "IDEMPOTENCY_CONFLICT" }}

Sample error body, as the API returns it.

What you can do through the API.

The routes below are part of the public contract. Anything not in openapi.json isn't contract and may change.

WhatsAppPOST /messagesGET /messagesGET /messages/{id}
SMSPOST /v2/sms/messagesPOST /v2/sms/previewGET /v2/sms/replies
RCSPOST /v2/rcs/messagesGET /v2/rcs/messages/{id}
EmailPOST /v2/email/messagesGET /v2/email/messages/{id}
PushPOST /v2/push/messagesPOST /v2/push/tokensPOST /v2/push/preview
Contacts and lists/contacts/contacts/import/v2/lists/v2/segments
Templates and flowsGET /v2/templatesPOST /v2/templatesGET /v2/flows
Webhooks/webhooksPOST /webhooks/validatePOST /webhooks/{id}/rotate-secret
Journeys/v2/journeysPOST /v2/journeys/{id}/activatePOST /v2/journeys/{id}/entries
Exports and walletGET /v2/exports/{whatsapp|sms|rcs|email}GET /wallet

Guides for what gets stuck most.

Short answers to the questions that come up once the integration leaves the whiteboard.

How many segments will my SMS cost?
71CharactersGSM-7Encoding1Segment

Accents swapped before sending: á becomes a. The whole message fits in GSM-7, so it stays at 160 characters for one segment.

Counted the way the bill counts. POST /v2/sms/preview returns segments and price before sending. SMS goes to Brazilian numbers; international routes are on request.

Meta rejected my template. Now what?

It's almost always format, not content: a promotion declared as utility, a variable at the start or end of the body, or a URL button without a fixed prefix. Before you submit to Meta, the dashboard editor warns when the text uses aggressive promotional language, when a marketing template has no opt-out button and when the body goes over 1,024 characters.

How long does number approval take?

There are two steps. Number verification, with the code by SMS or call, is quick. Business verification, which unlocks higher sending limits, is done by Meta itself, and the timeline is Meta's.

How many segments will my SMS cost?

Up to 160 characters is one segment; beyond that, one every 152. With emoji the message goes as Unicode: up to 70 is one segment, beyond that one every 67. Accented characters missing from GSM-7 are swapped for their plain equivalent when the whole message fits in GSM-7, so accents don't raise the price. POST /v2/sms/preview returns segments and price before sending. SMS goes to Brazilian numbers; international routes are on request.

What happens if my balance runs out?

The wallet is prepaid and is charged at send time. With no balance, SMS, RCS and email return 402, and WhatsApp and push sends fail with an insufficient-balance status. An SMS campaign that runs out mid-send pauses with the reason on screen, and Resume sends the messages refused for lack of balance along with the rest. The test wallet is separate and never charges real money.

My emails are landing in spam

Publish SPF, DKIM and DMARC for the domain, ramp up volume slowly on a new domain, remove invalid addresses and keep transactional and marketing on separate subdomains. Addresses that bounce go onto the suppression list.

My webhook isn't receiving events

Check that the URL answers 2xx within 10 seconds, that the signature is computed over the raw body and that the webhook is active, not paused. A 4xx from your side (other than 408 and 429) isn't retried; 5xx and no answer are, after 5 s, 30 s, 2 min, 10 min and 1 h.

How do I invite my team and set permissions?

Invite by email and pick one of nine roles, from owner to read-only. The organization can require two-step verification from every member, with a 30-day grace period. There's no per-seat fee, so nobody needs to share a login.

Stuck on a route? Talk to the people who run the API.

Sign up through the site and get a US$5 test credit.