Developer documentation

Build customer messaging with Convert

Send transactional messages, keep customer records in sync, inspect campaign results, and react to delivery events from one API.

Using an AI coding agent?

Copy an accurate, compact brief with the endpoints, rules, and retry behaviour it needs.

What the API can do

Convert gives your application one place to handle the practical parts of customer messaging. You can send an OTP or order update, maintain a customer profile, read campaign performance, register an SMS Sender ID, and receive delivery updates without integrating separately with every channel.

01

Send

Deliver Email, WhatsApp, or SMS directly, or let smart routing choose between WhatsApp and SMS.

02

Personalise

Use saved templates, merge variables, and customer fields for consistent transactional messages.

03

Observe

Read campaign totals and consume signed webhooks for accepted, delivered, failed, and engagement events.

Base URL https://convert.ruut.chat/api/v1
JSON over HTTPS
Start here

Your first request

There are four things to do before your application sends its first live message.

  1. 1
    Connect a channel.

    Connect Email or WhatsApp in Channels. For SMS, complete Sender ID approval first.

  2. 2
    Create an API key.

    Open Settings → Developer → API keys. Copy the key immediately because it is shown only once.

  3. 3
    Send a test request.

    Use a real recipient you control and a new idempotency key. Add "test": true to validate without delivery or a wallet charge.

  4. 4
    Listen for delivery events.

    Add a webhook endpoint so your system can move from “accepted” to the final delivery state.

Minimal smart-routing request

curl -X POST https://convert.ruut.chat/api/v1/messages \
  -H "Authorization: Bearer rk_live_xxx" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+2348012345678",
    "channel": "smart",
    "message": "Hi Ada, your order is ready."
  }'

A successful 201 Created means the selected channel accepted the message. It does not mean the recipient has received it. Use webhooks for delivery confirmation.

Core concept

How a message request works

Convert validates the request and account setup before it creates a customer record or charges the wallet.

1Authenticate

The Bearer key identifies the organisation and permitted scopes.

2Validate

Recipient, channel, template variables, sender setup, and idempotency are checked.

3Route

Convert resolves the requested channel and attempts the connected account in primary-first order.

4Settle

The wallet charge is settled only after a billable message is accepted.

5Update

Later delivery and engagement events arrive asynchronously through webhooks.

Security

Authentication and scopes

Create a key under API keys in Settings → Developer. A key belongs to one organisation, can expire or be revoked, and is shown in full only once. Store it in a server-side secret manager—never in browser code, a mobile application, or source control.

Authorization: Bearer rk_live_xxxxxxxxxxxxxxxxxxxxxxxx

Requests without a valid active key return 401 Unauthorized. A valid key without the scope required by an endpoint returns 403 Forbidden.

Available scopes

ScopeAllows
messages:writeSend and validate transactional messages.
contacts:writeCreate, enrich, update, and delete contacts.
campaigns:readList campaigns and read campaign details.
sender_ids:readRead SMS Sender ID requests and their approval state.
sender_ids:writeSubmit a new SMS Sender ID request.

If a key is exposed, revoke it in the dashboard and create a replacement. Convert stores only a digest and cannot show the original key again.

Send a message

POST /api/v1/messages

Send a single transactional message (OTP, alert, notification). The channel decides how it's delivered.

Idempotency-Key is required. Generate one unique value for every new message. Reuse the same value only when retrying the exact same request after a timeout; a changed OTP, recipient, template, variables, or channel must use a new value.

Body parameters

FieldTypeDescription
tostringEmail address for email; E.164 phone number for WhatsApp/SMS, e.g. +2348012345678. Required.
messagestringThe text to send. Required unless a template is supplied.
templatestringA template ID (e.g. tpl_x83k29sk). Renders your saved design — see Templates.
variablesobjectOnly the caller-facing values shown by the template, such as otp or meta_1. Convert builds provider-specific Meta components and copy-code buttons.
channelstringemail, whatsapp, sms, or smart. Defaults to smart whenever omitted, including template sends.
subjectstringRequired for raw Email sends; an Email template can supply it.
htmlstringOptional HTML body for a raw Email send. message remains the plain-text alternative.
senderstringOptional active Email sender address. The default active sender is used when omitted.
testbooleanWhen true, validates channel, recipient, template, and required options without sending or charging.
first_namestringOptional — stored on the contact, usable in {{first_name}}.
last_namestringOptional.
list_idintegerOptional — saves or updates the recipient in that workspace contact list. When omitted, Convert uses API Contacts.
custom_fieldsobjectOptional profile attributes to save on the contact. Template variables such as OTPs are never stored automatically.
sessionstringOptional WhatsApp session id (defaults to your connected number).

Email

Sends from an active, confirmed sender configured in Convert. Addresses that previously hard-bounced, complained, or unsubscribed are suppressed before submission.

IDEMPOTENCY_KEY="$(uuidgen)"

curl -X POST https://convert.ruut.chat/api/v1/messages \
  -H "Authorization: Bearer rk_live_xxx" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "[email protected]",
    "channel": "email",
    "subject": "Your order is on its way",
    "sender": "[email protected]",
    "message": "Hi {{first_name}}, order ORD-1042 has shipped.",
    "html": "<p>Hi {{first_name}}, order <strong>ORD-1042</strong> has shipped.</p>",
    "first_name": "Ada"
  }'

WhatsApp only

Delivers strictly over WhatsApp. Fails if no number is connected, or the recipient isn't reachable on WhatsApp.

IDEMPOTENCY_KEY="$(uuidgen)"

curl -X POST https://convert.ruut.chat/api/v1/messages \
  -H "Authorization: Bearer rk_live_xxx" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+2348012345678",
    "message": "Your code is 123456",
    "channel": "whatsapp"
  }'

SMS only

Sends over SMS using your approved transactional Sender ID.

IDEMPOTENCY_KEY="$(uuidgen)"

curl -X POST https://convert.ruut.chat/api/v1/messages \
  -H "Authorization: Bearer rk_live_xxx" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+2348012345678",
    "message": "Your code is 123456",
    "channel": "sms"
  }'

Smart routing

The default. Sends over WhatsApp when a number is connected and the recipient is on WhatsApp; otherwise selects SMS. SMS still requires an approved Sender ID and enough wallet balance.

IDEMPOTENCY_KEY="$(uuidgen)"

curl -X POST https://convert.ruut.chat/api/v1/messages \
  -H "Authorization: Bearer rk_live_xxx" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+2348012345678",
    "message": "Hi {{first_name}}, your order shipped!",
    "channel": "smart",
    "first_name": "Ada"
  }'

Successful response — 201 Created

{
  "success": true,
  "channel": "whatsapp",
  "status": "sent",
  "message_id": "wamid.XXXX",
  "cost": 5.0,
  "to": "+2348012345678"
}

Test mode

Add "test": true to validate a request without delivering it, creating a recipient status record, or charging the wallet. The response status is validated and cost is zero.

{
  "to": "[email protected]",
  "channel": "email",
  "subject": "Your order is on its way",
  "message": "Hi Ada, order ORD-1042 has shipped.",
  "test": true
}

Send to many recipients

POST /api/v1/messages/batch takes up to 1,000 recipients in one request, each with their own message. Use it instead of looping over /api/v1/messages: one request per recipient sends everything at the provider simultaneously, which is what earns rate-limit errors on a list of any size.

Convert queues the batch and releases it at the safe rate for whichever provider is carrying it — fast for Meta Cloud WhatsApp, deliberately slow and jittered for a connected Convert WhatsApp number, where bursts risk the number itself. Eighty messages are eighty separate sends, spaced out; you make one call.

Request body

These apply to the batch as a whole.

FieldTypeDescription
messagesarrayRequired. One object per recipient — see the table below.
channelstringwhatsapp, sms, email or smart. One channel for the whole batch. Defaults to smart.
templatestringOptional template ID, rendered separately for each recipient using that recipient's variables.
senderstringActive Email sender address. Email channel only; the default active sender is used when omitted.
list_idintegerContact list new contacts are filed under. Defaults to API Contacts.

Each object in messages

One recipient, and what to send them.

FieldTypeDescription
tostringRequired. E.164 phone number, or an email address on the email channel.
messagestringThis recipient's own text. Required unless a template is given. Supports {{merge_fields}}.
variablesobjectThis recipient's template variables. Also fills {{merge_fields}} in a raw message.
subjectstringRequired per recipient on the email channel, unless the template supplies one.
media_urlstringImage or document to send with the message. WhatsApp only.
referencestringYour own id for this recipient — an order number, a job id. Echoed back on the status endpoint so you can match outcomes to your records without storing ours. Must be unique within the batch.

Contact details

Optional, and saved to the contact record rather than used for this one send — so a recipient you reach through the API is a real contact afterwards, usable in campaigns and personalisation. Skip them if you already manage contacts through the contacts API.

FieldTypeDescription
first_namestringStored on the contact and available as {{first_name}}. Equivalent to putting first_name in variables — either works, no need for both.
last_namestringAs above, for {{last_name}}.
custom_fieldsobjectMerged into the contact's custom attributes, and usable as merge fields in later sends.
whatsapp_opt_inbooleanRecords that this recipient consented to WhatsApp. Required by Meta Cloud before you can message them.

Example — three personalised WhatsApp messages

IDEMPOTENCY_KEY="$(uuidgen)"

curl -X POST https://convert.ruut.chat/api/v1/messages/batch \
  -H "Authorization: Bearer rk_live_xxx" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "whatsapp",
    "messages": [
      { "to": "+2348012345678", "message": "Hi Ada, order #1201 shipped.",  "reference": "order-1201" },
      { "to": "+2348098765432", "message": "Hi Tobi, order #1202 ships tomorrow.", "reference": "order-1202" },
      { "to": "+2348055500011", "message": "Hi Nkem, order #1203 is ready for pickup.", "reference": "order-1203" }
    ]
  }'

Successful response — 202 Accepted

202 means queued, not sent. Validation, contact creation and the wallet reservation are all done by the time you get this — so the accepted list is a firm commitment — but no message has reached the provider yet.

{
  "success": true,
  "batch_id": "batch_9f2c7a1de0b34c85a6d1",
  "status": "queued",
  "channel": "whatsapp",
  "queued": 3,
  "rejected_count": 0,
  "counts": { "pending": 3, "sent": 0, "failed": 0, "skipped": 0 },
  "reserved_cost": 13.5,
  "accepted": [
    { "index": 0, "to": "+2348012345678", "reference": "order-1201", "channel": "whatsapp", "message_id": 90211, "cost": 4.5 },
    { "index": 1, "to": "+2348098765432", "reference": "order-1202", "channel": "whatsapp", "message_id": 90212, "cost": 4.5 },
    { "index": 2, "to": "+2348055500011", "reference": "order-1203", "channel": "whatsapp", "message_id": 90213, "cost": 4.5 }
  ],
  "rejected": []
}

Partial acceptance

One bad row does not sink the batch. Valid recipients queue, and each rejection comes back with the index of the entry in the array you sent, so you can fix and resubmit just those.

{
  "success": true,
  "batch_id": "batch_4b81ea22c7d94f60b3aa",
  "queued": 1,
  "rejected_count": 2,
  "rejected": [
    { "index": 1, "to": "not-a-phone", "error": "'not-a-phone' is not a valid phone number.", "error_code": "invalid_phone" },
    { "index": 2, "to": "+2348055500011", "error": "'message' is required when no template is given.", "error_code": "missing_message" }
  ]
}

Checking on a batch

GET /api/v1/messages/batch/:batch_id returns the running counts and every recipient's outcome. Delivery also fires the usual message.* webhooks, which is the better option if you would otherwise poll.

{
  "success": true,
  "batch_id": "batch_9f2c7a1de0b34c85a6d1",
  "status": "partially_failed",
  "counts": { "pending": 0, "sent": 2, "failed": 1, "skipped": 0 },
  "messages": [
    { "message_id": 90211, "reference": "order-1201", "to": "+2348012345678", "status": "delivered",
      "channel": "whatsapp", "provider_message_id": "wamid.XXXX", "cost": 4.5 },
    { "message_id": 90213, "reference": "order-1203", "to": "+2348055500011", "status": "failed",
      "channel": "whatsapp", "error": "not_on_whatsapp", "cost": 0.0 }
  ]
}

Billing

The whole batch is reserved against your wallet when it is accepted, and each message is captured when it sends or refunded when it fails. If your balance runs out partway through, the remaining recipients are rejected with insufficient_balance rather than silently dropped. One wallet entry covers the batch.

Send with a template

Design a message once in Templates — choose how it looks, add {{variables}}, and pick a type (transactional or promotional). Each template gets an immutable ID like tpl_x83k29sk. Send it by passing that template id plus a variables object — Convert renders your saved design and uses smart routing unless you explicitly choose a channel. Provider-specific Meta parameters are generated behind the scenes; API callers supply only the variables displayed by Convert. Email templates also store a subject, preview text, from name, and reply-to address.

Open any template and click Use via API to copy its ID and ready-made snippets (cURL, Node, Python, PHP, Flutter, Go).

Example — send an OTP template

IDEMPOTENCY_KEY="$(uuidgen)"

curl -X POST https://convert.ruut.chat/api/v1/messages \
  -H "Authorization: Bearer rk_live_xxx" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+2348012345678",
    "template": "tpl_x83k29sk",
    "channel": "smart",
    "variables": {
      "first_name": "Ada",
      "otp": "638492",
      "expiry": "10",
      "company_name": "Acme"
    }
  }'

Successful response — 201 Created

The response echoes the template id that was sent.

{
  "success": true,
  "channel": "whatsapp",
  "status": "sent",
  "message_id": "wamid.XXXX",
  "cost": 5.0,
  "template": "tpl_x83k29sk",
  "to": "+2348012345678"
}

Validation

Caller-facing variables marked required must be supplied (and non-empty). Optional ones may be omitted and fall back to their configured default. Meta component aliases such as authentication button parameters are resolved by Convert and never appear as API requirements. Missing caller-facing variables return 422 with the list of what's missing:

{
  "success": false,
  "error": "Missing required variable(s): otp.",
  "error_code": "missing_variables",
  "missing": ["otp"]
}

Contacts

Manage the people you message. Add them one at a time over the API, in bulk from a file, or let the send API create them automatically.

Create or enrich a contact

POST /api/v1/contacts

curl -X POST https://convert.ruut.chat/api/v1/contacts \
  -H "Authorization: Bearer rk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "phone_number": "+2348012345678",
    "first_name": "Ada",
    "last_name": "Obi",
    "email": "[email protected]"
  }'

Phone numbers are normalized to E.164 and emails to lowercase. A matching normalized phone, email, or external customer ID enriches the existing organisation contact instead of creating a duplicate.

Update a contact

PATCH /api/v1/contacts/:id

Replace :id with the numeric contact ID. This is a partial update: omitted fields keep their existing values. The API key can update only contacts in its organisation.

curl -X PATCH https://convert.ruut.chat/api/v1/contacts/1042 \
  -H "Authorization: Bearer rk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "first_name": "Adanna",
    "email": "[email protected]",
    "tags": ["customer", "vip"],
    "custom_fields": { "tier": "platinum" }
  }'

Returns 200 OK, 404 Not Found for an unknown or cross-organisation ID, or 422 Unprocessable Entity for invalid or duplicate identifiers.

Remove a contact

DELETE /api/v1/contacts/:id

Permanently removes the contact from the API key's organisation.

curl -X DELETE https://convert.ruut.chat/api/v1/contacts/1042 \
  -H "Authorization: Bearer rk_live_xxx"

Returns 204 No Content with no response body. Deletion also removes dependent recipient delivery records; use an opt-out when the customer and history should remain.

Bulk upload

For large lists, import a CSV or Excel (.xlsx) file from Contacts → a list → Add contacts. You map your columns to fields (phone, first name, last name, email, and any custom fields) and Convert imports them in the background, checking each number against WhatsApp.

To bulk-load programmatically, loop the single-contact endpoint:

const contacts = [
  { phone_number: "+2348010000001", first_name: "Ada" },
  { phone_number: "+2348010000002", first_name: "Uche" }
];

for (const c of contacts) {
  await fetch("https://convert.ruut.chat/api/v1/contacts", {
    method: "POST",
    headers: {
      "Authorization": "Bearer rk_live_xxx",
      "Content-Type": "application/json"
    },
    body: JSON.stringify(c)
  });
}

Custom fields

Attach your own attributes via custom_fields. These become available as merge tags (e.g. {{order_id}}) in messages.

{
  "phone_number": "+2348012345678",
  "first_name": "Ada",
  "custom_fields": {
    "order_id": "ORD-1042",
    "tier": "gold"
  }
}

Campaigns

Campaign endpoints are read-only. Use them to bring broadcast names, channels, status, delivery totals, and cost into your own reporting. Campaign creation, editing, scheduling, resending, and cancellation remain dashboard workflows.

GET /api/v1/campaigns — list recent campaigns

GET /api/v1/campaigns/:id — one campaign with its list

curl https://convert.ruut.chat/api/v1/campaigns \
  -H "Authorization: Bearer rk_live_xxx"

Example campaign detail

{
  "id": 12,
  "name": "October promo",
  "status": "sent",
  "routing_strategy": "smart_fallback",
  "sent_count": 940,
  "failed_count": 6,
  "actual_cost": 4700.0,
  "contact_list": { "id": 3, "name": "Customers" }
}

sent_count records accepted submissions, while delivered_count is updated when delivery receipts arrive. Costs and counts can therefore continue to settle after a campaign first enters its final send state.

SMS setup

SMS Sender ID requests

A Sender ID is the name recipients see for an SMS. It must be approved before Convert can send SMS for your organisation. These endpoints let an internal system submit a request and track its registration state.

GET/api/v1/sender_id_requests

List requests. Add ?status=pending or another known status to filter.

GET/api/v1/sender_id_requests/:id

Read one request, including carrier-level approval and rejection states.

POST/api/v1/sender_id_requests

Submit a new registration request.

Submit a transactional Sender ID

curl -X POST https://convert.ruut.chat/api/v1/sender_id_requests \
  -H "Authorization: Bearer rk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "requested_sender_id": "ACME",
    "sms_type": "transactional",
    "use_case": "Order and delivery notifications"
  }'

requested_sender_id is required. You may also send carrier or carriers, sms_type, transactional_sender_id, promotional_sender_id, and use_case. Approval is asynchronous; poll the read endpoint or review the request in the dashboard before attempting SMS.

Routing strategies

Campaigns route every contact using one of these strategies (the send API mirrors them via channel):

StrategyBehaviour
whatsapp_onlyWhatsApp only; contacts not on WhatsApp are skipped.
email_onlyEmail to recipients with a valid, non-suppressed address.
sms_onlySMS to everyone via your Sender ID.
whatsapp_firstTry WhatsApp; fall back to SMS for the rest.
smart_fallbackRoutes eligible, opted-in contacts to WhatsApp and the remaining active contacts to SMS. It does not retry failed WhatsApp submissions over SMS.

Message state progresses from sent (provider accepted) to delivered when a delivery event arrives. Email can also become bounced or unsubscribed. Campaign Email adds first-open and first-click timestamps; opens can be under-counted by client privacy controls and prefetching can inflate them.

Webhooks

Get notified in real time when things happen. Add an endpoint under Webhooks and pick the events you want. Convert sends a POST with a JSON body for each event.

Events

EventFires when…
message.sentA single message was accepted by the provider.
message.receivedAn inbound message was received on a connected WhatsApp channel.
message.deliveredA message was delivered to the recipient's device.
message.readA WhatsApp message was read by the recipient.
message.failedA message could not be delivered.
message.bouncedAn email provider reported a hard or soft bounce.
message.unsubscribedAn email recipient unsubscribed.
message.openedAn email was opened (subject to client privacy behavior).
message.clickedA tracked link in an email was clicked.
campaign.completedA campaign finished sending to its whole list.
contact.createdA new contact was added (via UI, import, or API).

Payload

{
  "event": "message.delivered",
  "created_at": "2026-06-29T12:00:00Z",
  "data": {
    "message_id": 8841,
    "to": "+2348012345678",
    "channel": "whatsapp",
    "status": "delivered",
    "provider_message_id": "wamid.XXXX",
    "cost": 5.0
  }
}

Verifying signatures

Each request carries X-Convert-Signature: sha256=<hmac> — an HMAC-SHA256 of the raw body using your endpoint's signing secret (shown on the endpoint page). Recompute and compare to confirm authenticity:

# Rails example
expected = "sha256=" + OpenSSL::HMAC.hexdigest(
  "SHA256", ENV["CONVERT_WEBHOOK_SECRET"], request.raw_post
)
verified = ActiveSupport::SecurityUtils.secure_compare(
  expected, request.headers["X-Convert-Signature"].to_s
)

Respond with any 2xx to acknowledge. Non-2xx (or timeouts) are retried with backoff.

Errors

Failed requests return a JSON body with error and a stable error_code:

{
  "success": false,
  "error": "Insufficient wallet balance to send this message.",
  "error_code": "insufficient_balance"
}
HTTPerror_codeMeaning
400idempotency_key_requiredIdempotency-Key is missing or longer than 200 characters.
401unauthorizedMissing, invalid, or revoked API key.
403forbiddenThe API key does not include the required scope.
402insufficient_balanceWallet balance too low to send.
409idempotency_conflictThe key was already used for a different request.
409idempotency_in_progressA request using this key is still being processed.
422invalid_channelchannel must be email, whatsapp, sms, or smart.
422invalid_phoneThe to number isn't a valid phone number.
422invalid_emailThe Email to value isn't a valid address.
422missing_subjectAn Email subject was not supplied by the request or template.
422email_sender_requiredNo active, confirmed Email sender is available.
422email_sender_not_foundThe requested sender is not active in this organisation.
422email_suppressedThe recipient previously bounced, complained, or unsubscribed.
422missing_messageNo message body or template provided.
404template_not_foundNo template with that id in your account.
422template_archivedThe template is archived and can't be sent.
422missing_variablesOne or more required template variables were not supplied.
422whatsapp_not_connectedWhatsApp channel requested but no number is connected.
422recipient_not_on_whatsappRecipient isn't reachable on WhatsApp (use sms/smart).
422unknown_sessionThe requested WhatsApp session does not belong to the organisation.
422whatsapp_failedNo compatible connected WhatsApp account accepted the message.
422sender_id_requiredSMS was selected, but the account has no approved Sender ID.
502send_failed / sms_failed / email_failedThe provider rejected or failed to send.
Before launch

Go-live checklist

Use this final pass before moving an integration from a local test to live customer traffic.

  • Keep the API key in a server-side secret store and grant only the scopes the integration needs.
  • Generate a unique idempotency key for every new message and persist it with the request.
  • Confirm the Email sender, WhatsApp connection, or SMS Sender ID needed by your chosen channel.
  • Verify webhook signatures against the raw request body before parsing or processing the event.
  • Retry timeouts and 5xx responses with backoff; fix 4xx errors instead of retrying them unchanged.
  • Test opt-outs, suppressions, insufficient balance, invalid recipients, and unavailable channels.