BluetickedDeveloper docs

Messages

2 endpoints, each with examples that open in the console prefilled.

All resourcesGuide: Quickstart
POST/api/v1/messagesmessages:sendTry in console

Send one message

Send one WhatsApp template, SMS, or email message to a contact, or create the contact in the same call with to.create: true.

Body parameters
business_idstring
channel_idstring
channelrequiredstring
torequiredobject
template_idstring
template_variablesobject
  • Business-initiated WhatsApp messages (outside the 24-hour customer-service window) must use an approved template; within 24 hours of the customer's last message a free-form body is accepted instead of template_id.
  • Pass business_id for managed-business scoping; pass channel_id when you need to send from a specific WhatsApp number.
  • If channel_id is omitted for a WhatsApp template, Blueticked uses the number attached to that approved template.
  • Use Idempotency-Key on write calls to avoid duplicate sends.
  • The recipient is looked up in your contacts; one that is not there answers 404 not_found with details.reason: "recipient_not_found". Pass to.create: true (optionally with to.first_name and to.last_name) to create the contact in the same call, exactly as POST /api/v1/contacts would: in the business you name, or your default business. It needs the contacts:write scope too. The answer says contact_created, and carries contact_id when the send created the contact or the key holds contacts:read.
  • A contact created by a send triggers your contact-created flows and the contact.created webhook, like any new contact. It has given no consent, so a marketing send with to.create is refused up front (details.reason: "consent_required") and nothing is written; record the opt-in with POST /api/v1/contacts first. The contact is created only after every other check passes (the channel, the body, the wallet, the plan's contact limit, which answers 403 plan_limit_reached), so a refused send leaves none behind; if the send fails after it (the send queue unavailable, say), the contact stays.
  • Authorize WhatsApp sends with either messages:send or the least-privilege whatsapp:send scope.
curl -X POST "https://app.blueticked.com/api/v1/messages" \
  -H "Authorization: Bearer $BLUETICKED_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: change-me-per-request-0001" \
  -d '{
  "business_id": "22222222-2222-4222-9222-222222222222",
  "channel_id": "11111111-1111-4111-9111-111111111111",
  "channel": "whatsapp",
  "to": {
    "phone": "+27800000001"
  },
  "template_id": "1f2f3f4f-0000-4000-9000-abcdef123456",
  "template_variables": {
    "1": "Sam",
    "2": "#1001"
  }
}'
Response
{
  "message_id": "763c4dc3-d46b-4c10-8bd4-3f233a933ea0",
  "conversation_message_id": "5592edf6-d7ae-4198-962e-3e8ebe83bb49",
  "conversation_id": "fa3efd47-67b2-428c-b75f-17d1dd2e9790",
  "status": "queued",
  "contact_id": "ff506e53-e2f0-4d92-884a-dab77d628bad",
  "contact_created": false,
  "request_id": "req_b2e3d610e10f1b07468721ee98f0e1d2"
}
POST/api/v1/messagesmessages:sendTry in console

Send a customer statement (template with PDF)

Send a WhatsApp template that has a DOCUMENT header, attaching a per-recipient PDF by URL.

Body parameters
channelstring
toobject
template_idstring
template_variablesobject
documentobject
  • The template must be approved with a DOCUMENT header; templates without one reject the document parameter with document_not_applicable.
  • document.url must be an https:// URL that WhatsApp can fetch without credentials at send time. It is fetched once, when the message is sent, and never afterwards. A short-lived pre-signed URL (S3, GCS, Supabase storage; a few minutes of TTL is ample) satisfies this and is the recommended pattern for anything confidential: payslips, statements, invoices. The file never has to be durably public. filename is what the recipient sees (max 240 characters).
  • Or send a PDF uploaded with POST /api/v1/documents by its id: document: { id }, with an optional filename to show instead of the uploaded one. It is sent only to the contact it was uploaded for: another contact's document, or one sent to a contact to.create would create, is refused with 400 validation_failed, details.reason: "document_not_for_contact", and an id this workspace does not hold answers 404 not_found, document_not_found. Pass id or url, never both.
  • Omitting document on a DOCUMENT-header template returns document_required.
  • Authorize WhatsApp sends with either messages:send or the least-privilege whatsapp:send scope.
curl -X POST "https://app.blueticked.com/api/v1/messages" \
  -H "Authorization: Bearer $BLUETICKED_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: change-me-per-request-0001" \
  -d '{
  "channel": "whatsapp",
  "to": {
    "phone": "+27800000001"
  },
  "template_id": "1f2f3f4f-0000-4000-9000-abcdef123456",
  "template_variables": {
    "1": "Sam",
    "2": "May 2026"
  },
  "document": {
    "url": "https://files.example.com/statements/acct-1001.pdf",
    "filename": "Statement May 2026.pdf"
  }
}'
Response
{
  "message_id": "763c4dc3-d46b-4c10-8bd4-3f233a933ea0",
  "conversation_message_id": "5592edf6-d7ae-4198-962e-3e8ebe83bb49",
  "conversation_id": "fa3efd47-67b2-428c-b75f-17d1dd2e9790",
  "status": "queued",
  "contact_id": "ff506e53-e2f0-4d92-884a-dab77d628bad",
  "contact_created": false,
  "request_id": "req_b2e3d610e10f1b07468721ee98f0e1d2"
}
POST/api/v1/messagesmessages:sendTry in console

Send a template with a personalised link button

Send a WhatsApp template whose URL button ends in {{1}}, supplying the per-send link ending.

Body parameters
channelstring
toobject
template_idstring
template_variablesobject
template_buttonsobject[]
  • A dynamic URL button is one whose stored URL ends in {{1}}; url_suffix is the value that replaces it for this send.
  • index is the button's position in the template's buttons array (0-based).
  • Templates with a dynamic URL button REQUIRE a matching template_buttons entry. The send is rejected with button_params_mismatch otherwise; a suffix aimed at a static button is rejected the same way.
  • WhatsApp template sends only; template_buttons on other channels or without template_id fails validation.
  • Authorize WhatsApp sends with either messages:send or the least-privilege whatsapp:send scope.
curl -X POST "https://app.blueticked.com/api/v1/messages" \
  -H "Authorization: Bearer $BLUETICKED_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: change-me-per-request-0001" \
  -d '{
  "channel": "whatsapp",
  "to": {
    "phone": "+27800000001"
  },
  "template_id": "1f2f3f4f-0000-4000-9000-abcdef123456",
  "template_variables": {
    "1": "Sam"
  },
  "template_buttons": [
    {
      "type": "url",
      "index": 1,
      "url_suffix": "orders/RIPL-4421"
    }
  ]
}'
Response
{
  "message_id": "763c4dc3-d46b-4c10-8bd4-3f233a933ea0",
  "conversation_message_id": "5592edf6-d7ae-4198-962e-3e8ebe83bb49",
  "conversation_id": "fa3efd47-67b2-428c-b75f-17d1dd2e9790",
  "status": "queued",
  "contact_id": "ff506e53-e2f0-4d92-884a-dab77d628bad",
  "contact_created": false,
  "request_id": "req_b2e3d610e10f1b07468721ee98f0e1d2"
}
POST/api/v1/messagesmessages:sendTry in console

Send an SMS

Send a plain-text SMS to a contact (or create it with to.create: true), from a free-form body or a saved message template.

Body parameters
channelrequiredstring
torequiredobject
bodystring
  • SMS sends take a free-form body (max 459 chars; a marketing send's STOP footer counts toward it) or a saved message template via template_id (workspace-owned, no provider approval).
  • template_id is channel-aware: SMS/email ids come from your saved message templates; a WhatsApp template id on an SMS send returns 404.
  • A STOP footer is appended to marketing sends only (message_type: "marketing"); a transactional send, the default, goes out exactly as written, so send anything promotional as marketing. Inbound STOP replies suppress the recipient.
  • A closed SA marketing-hours window rejects a marketing SMS with error.code: "validation_failed" and details.code: "sms_marketing_window_closed" (branch on details.code, not error.code).
  • Authorize SMS sends with either messages:send or the least-privilege sms:send scope.
curl -X POST "https://app.blueticked.com/api/v1/messages" \
  -H "Authorization: Bearer $BLUETICKED_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: change-me-per-request-0001" \
  -d '{
  "channel": "sms",
  "to": {
    "phone": "+27800000001"
  },
  "body": "Your OTP is 4821. It expires in 10 minutes."
}'
Response
{
  "message_id": "763c4dc3-d46b-4c10-8bd4-3f233a933ea0",
  "conversation_message_id": "5592edf6-d7ae-4198-962e-3e8ebe83bb49",
  "conversation_id": "fa3efd47-67b2-428c-b75f-17d1dd2e9790",
  "status": "queued",
  "contact_id": "ff506e53-e2f0-4d92-884a-dab77d628bad",
  "contact_created": false,
  "request_id": "req_b2e3d610e10f1b07468721ee98f0e1d2"
}
POST/api/v1/messagesmessages:sendTry in console

Send an email

Send a transactional email to a contact (or create it with to.create: true), with an optional subject line and reply-to.

Body parameters
business_idstring
channelrequiredstring
torequiredobject
subjectstring
bodyrequiredstring
message_typestring
reply_tostring
ccstring[]
bccstring[]
  • Email uses to.email and a free-form body; pass subject to set the subject line (free-text emails without one default to "Notification").
  • Authorize email sends with either messages:send or the least-privilege email:send scope.
  • message_type defaults to transactional (bypasses marketing opt-outs, never an 'all' block); set marketing to honour marketing opt-outs, double opt-in and the contact's recorded email consent.
  • reply_to is accepted only when it matches the active sender address for that business; configure sender/reply identity in Blueticked before sending.
  • cc / bcc (arrays of addresses, max 50 each) are accepted on transactional email only: any cc/bcc address on the suppression list (opted-out / bounced / complained) is silently dropped, and the primary recipient must pass or the whole send is rejected. Marketing email rejects cc/bcc outright (see the marketing-email example).
  • Emails send through the selected business's connected sender identity. If multiple email channels exist, pass business_id.
  • A one-click unsubscribe header is added automatically; bounces and complaints auto-suppress the recipient.
curl -X POST "https://app.blueticked.com/api/v1/messages" \
  -H "Authorization: Bearer $BLUETICKED_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: change-me-per-request-0001" \
  -d '{
  "business_id": "22222222-2222-4222-9222-222222222222",
  "channel": "email",
  "to": {
    "email": "sam@example.com"
  },
  "subject": "Your statement is ready",
  "body": "Hi Sam, your May statement is attached in your account.",
  "message_type": "transactional",
  "reply_to": "accounts@yourbusiness.co.za",
  "cc": [
    "colleague@yourbusiness.co.za"
  ],
  "bcc": [
    "archive@yourbusiness.co.za"
  ]
}'
Response
{
  "message_id": "763c4dc3-d46b-4c10-8bd4-3f233a933ea0",
  "conversation_message_id": "5592edf6-d7ae-4198-962e-3e8ebe83bb49",
  "conversation_id": "fa3efd47-67b2-428c-b75f-17d1dd2e9790",
  "status": "queued",
  "contact_id": "ff506e53-e2f0-4d92-884a-dab77d628bad",
  "contact_created": false,
  "request_id": "req_b2e3d610e10f1b07468721ee98f0e1d2"
}
POST/api/v1/messagesmessages:sendTry in console

Send a marketing email (HTML body)

Send a marketing email with your own HTML body, rendered inside the branded shell with the required compliance footer.

Body parameters
business_idstring
channelrequiredstring
torequiredobject
subjectstring
message_typestring
bodyrequiredstring
htmlstring
reply_tostring
  • Send both: body is the plain-text alternative every email needs, and html is the rendered part. html is your content only. Blueticked wraps it in the sender's branded shell and always appends the compliance footer (unsubscribe + physical address), so the unsubscribe link can never be dropped.
  • message_type: "marketing" is required for promotional mail: recipients with a marketing opt-out, an unconfirmed double opt-in, or no recorded email consent are rejected up front (details.reason: "consent_required" for the last).
  • reply_to, when supplied, must match the active sender address for the selected business. This protects sender alignment and deliverability.
  • Marketing email is strictly one recipient per send: passing cc or bcc is rejected with a 400 (validation_failed, "Marketing email cannot use cc or bcc") so consent, unsubscribe and tracking stay recipient-specific. Use the transactional send example if you need cc/bcc.
curl -X POST "https://app.blueticked.com/api/v1/messages" \
  -H "Authorization: Bearer $BLUETICKED_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: change-me-per-request-0001" \
  -d '{
  "business_id": "22222222-2222-4222-9222-222222222222",
  "channel": "email",
  "to": {
    "email": "sam@example.com"
  },
  "subject": "20% off this month",
  "message_type": "marketing",
  "body": "Winter sale: 20% off all services this month.",
  "html": "<h1>Winter sale</h1><p>20% off all services this month.</p>",
  "reply_to": "hello@yourbusiness.co.za"
}'
Response
{
  "message_id": "763c4dc3-d46b-4c10-8bd4-3f233a933ea0",
  "conversation_message_id": "5592edf6-d7ae-4198-962e-3e8ebe83bb49",
  "conversation_id": "fa3efd47-67b2-428c-b75f-17d1dd2e9790",
  "status": "queued",
  "contact_id": "ff506e53-e2f0-4d92-884a-dab77d628bad",
  "contact_created": false,
  "request_id": "req_1d287d7abad27e32267ed53db44059f0"
}
POST/api/v1/messagesmessages:sendTry in console

Validate one message

Validate auth, contact lookup, channel readiness, template variables and opt-outs without sending.

Body parameters
test_modeboolean
channelstring
toobject
template_idstring
template_variablesobject
  • test_mode: true is a dry-run: it validates the request and returns status: "validated" without creating or sending anything. It works with any key, a live one included, that holds the reads it returns: contacts:read, and templates:read when a template is named. It needs no send scope.
  • No message rows are created in test mode; for an end-to-end sandbox that actually simulates a send (row created, no provider called, no credits spent), use a test API key (blu_test_…) instead. On a test key a message moves queued → sent → delivered, about one step every 5 seconds, and on to read for WhatsApp; SMS and email stop at delivered. The webhooks message.sent, message.delivered and message.failed fire as it moves; the simulated read fires none.
  • This is the safest first integration call.
curl -X POST "https://app.blueticked.com/api/v1/messages" \
  -H "Authorization: Bearer $BLUETICKED_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: change-me-per-request-0001" \
  -d '{
  "test_mode": true,
  "channel": "whatsapp",
  "to": {
    "phone": "+27800000001"
  },
  "template_id": "1f2f3f4f-0000-4000-9000-abcdef123456",
  "template_variables": {
    "1": "Sam"
  }
}'
Response
{
  "test_mode": true,
  "status": "validated",
  "channel": "whatsapp",
  "to": "+27800000001",
  "contact_id": "ff506e53-e2f0-4d92-884a-dab77d628bad",
  "rendered_body_preview": "Hi Sam, just letting you know...",
  "would_enqueue": false,
  "request_id": "req_b2e3d610e10f1b07468721ee98f0e1d2"
}
GET/api/v1/messages/{id}messages:readTry in console

Get message status

Fetch the latest status and provider identifiers for a previously queued message.

  • Use webhooks for production status changes; polling is useful during testing.
  • cost_zar_cents is the recorded per-message cost in ZAR cents (informational; SMS is billed per segment).
  • failure_reason is non-null only when status is failed; the four lifecycle timestamps (sent_at, delivered_at, read_at, failed_at) fill in as the message progresses and stay null until then.
curl -X GET "https://app.blueticked.com/api/v1/messages/{id}" \
  -H "Authorization: Bearer $BLUETICKED_API_KEY"
Response
{
  "id": "763c4dc3-d46b-4c10-8bd4-3f233a933ea0",
  "business_id": "22222222-2222-4222-9222-222222222222",
  "channel": "whatsapp",
  "status": "delivered",
  "external_id": "wamid.HBgL...",
  "failure_reason": null,
  "cost_zar_cents": 18,
  "sent_at": "2026-05-22T10:29:58.000Z",
  "delivered_at": "2026-05-22T10:30:00.000Z",
  "read_at": null,
  "failed_at": null,
  "created_at": "2026-05-22T10:29:55.000Z",
  "updated_at": "2026-05-22T10:30:00.000Z",
  "request_id": "req_b2e3d610e10f1b07468721ee98f0e1d2"
}