BluetickedDeveloper docs

Changelog

Every API-visible change, newest first.

Changes

/api/v1 is additive, and anything that could break an integration is marked breaking.

New endpoints, fields and headers ship without a version bump, and existing ones are not removed or renamed. Parse responses leniently and ignore fields you do not recognise, so additions never break your integration. On the rare occasion a change could fail an existing integration, it lands here marked breaking, in the same deploy, with migration guidance.

    • changedPOST /api/v1/contacts/{id}/erase answers 503 service_unavailable with reason `erasure_retrying` and a Retry-After header when erasure stops before stamping to let work on the contact finish (an Ask Blu answer about them still running, or a large backlog). It used to answer internal_error. Nothing is stamped; send the same request again after Retry-After, and contact support if it keeps answering 503.
    • addedEvery webhook delivery now carries an x-blueticked-delivery-id header, equal to the body's delivery_id and the same on every attempt, so a receiver can drop a repeat before parsing the body.
    • addedFlow Studio flows are on the API (automations:read and automations:write): GET /api/v1/studio-flows lists them (each marked `kind` automation or journey, with `activation_blockers` and `has_draft`, paged by cursor), GET /api/v1/studio-flows/{id} reads one with its draft, POST /api/v1/studio-flows creates one from a whole definition and PATCH /api/v1/studio-flows/{id} replaces an off one, both saved OFF and checked exactly as Flow Studio's canvas checks them (`compile_failed` with `details.issues`), and POST /api/v1/studio-flows/{id}/switch-off switches one off. Switching a flow on stays in Flow Studio. The SDK's studioFlows and the MCP studio_flows tool follow.
    • changedPOST /api/v1/studio-flows and PATCH /api/v1/studio-flows/{id} refuse an SMS message the flow sends itself (before its first wait) that has no `smsMessageType`: 400 validation_failed, reason `sms_type_required`, with `details.issues` naming each step. Set `transactional` for an order or service update, or `marketing` for anything promotional, which needs SMS consent and keeps quiet hours. Nothing is saved until every one says which.
    • breakingThe classic automations API is retired (automations:read and automations:write keep working for Flow Studio flows): GET and POST /api/v1/automations, GET, PATCH and DELETE /api/v1/automations/{id} and GET /api/v1/automations/recipes answer 410 with error code `gone`, reason `automations_retired` and `details.successor` /api/v1/studio-flows. Read, create and switch off flows with /api/v1/studio-flows, and start them with POST /api/v1/events/send. The 410 stays for at least one SDK major. SDK 0.9.0 removes client.automations; the MCP flows tools are gone, and Blu turns a flow off with the app's own Turn off card.
    • changedThe partner deep link into the builder now opens Studio: /studio/builder?entry=api_event&event=<name>&channel=<whatsapp|sms|email> starts a draft flow on your event. Links to the retired /automations/builder?recipe=…&event=…&channel=… keep working: they redirect to the same draft.
    • fixedPOST /api/v1/campaigns refuses an email whose blocks (inline `block_content`, or the design a `template_id` carries) still hold a starter template's example link (https://example.com, with or without a path) or placeholder image (placehold.co): 400 validation_failed with `details.code: "placeholder_content"`, now documented on the endpoint. A bare https://example.com slipped past the check before, so such an email could be sent. If you send designs built from a starter template, those sends now get placeholder_content until the link or image is replaced.
    • addedCommerce orders carry `payment` in GET /api/v1/commerce/orders, GET /api/v1/commerce/orders/{id} and every commerce order webhook (the nested order object): `provider` (paystack, payfast or manual) and `method` (cash, eft, card_machine or other when the shop marked the order paid by hand in the dashboard; null otherwise). A manual order cannot be refunded through POST /api/v1/commerce/orders/{id}/refund (409: the buyer is refunded the way they paid).
    • addedGET /api/v1/channels/{id}/whatsapp-spend (channels:read) answers what Meta billed the business for WhatsApp on one channel over the last `days` (1 to 90, default 30): totals, `by_category` and `by_country`, each with messages and cost, in the currency Meta bills in. It is Meta's own pricing analytics, marked `approximate: true`: Blueticked never resells or meters WhatsApp. A Meta failure is an error (provider_error or service_unavailable, reason meta_unavailable), never an empty spend. The SDK's channels.whatsapp.spend is in 0.9.0, which is not on npm yet.
    • changedAn order's `emails` can say `reason: "send_unconfirmed"` on a failed email: the email service never answered the last send, so it may have arrived. Check with the buyer before sending it another way. The list was documented as one that may grow; anything you do not know still reads as null. The SDK's types include it from 0.9.0, which is not on npm yet.
    • addedPOST /api/v1/commerce/orders/{id}/ready-for-collection (commerce:write) tells the buyer of a paid collection order that it is ready to collect: one email in the business's name with the shop's collection note, sent once (a repeat answers 200 with the first `ready_for_collection_at` and `already_ready: true`). Orders carry `fulfilment.ready_for_collection_at` (null until marked) in GET /api/v1/commerce/orders, GET /api/v1/commerce/orders/{id} and every commerce order webhook, and `emails` gains the `ready_for_collection` kind. Clients that list `emails` kinds should accept the new one.
    • addedSDK 0.8.0 is on npm: the TypeScript client now calls all 126 documented operations (0.7.0 called 104). New: tables and their rows, tickets and their comments, wallet invoices, business API keys (list and revoke), commerce product history (product sales and stock movements), documents (a PDF for one contact, sent with messages.send), message media, template submission, and webhook replay of every failed delivery since a time (webhooks.replayFailed). messages.send takes to.create, to.first_name and to.last_name, so a send to a new number creates the contact in the same call. Upgrade with npm install @blueticked/sdk@latest.
    • fixedSDK 0.8.0 keeps the API key inside the client: a transport or proxy message the SDK passes into its own error (network_error, retry_unsafe) has the key redacted, and util.inspect(client, { showHidden: true }) no longer prints it.
    • changedPOST /api/v1/email/sending-domain: the validation_failed message for a missing or malformed `from_local_part` now reads "Provide `from_local_part`: the part before the @, e.g. accounts." (a colon where it had a dash). The code is unchanged: branch on `error.code`, never on the message.
    • fixedAn SMS whose text holds a no-break space (U+00A0) or a narrow no-break space (U+202F), for example an amount pasted from a formatted price, is now sent with ordinary spaces, so it is counted and charged as GSM-7: 160 characters a segment instead of 70. Before, one such space made the whole message UCS-2. It applies to POST /api/v1/messages and to a campaign's SMS, to the cost a send is checked against and to what it is charged; any other character outside GSM-7 (an emoji, CJK) still makes a message UCS-2.
    • addedAI agents carry `effective_model`, the model their replies actually run on, beside `model` (the model saved in the dashboard) in GET /api/v1/ai-agents, GET /api/v1/ai-agents/{id} and PATCH /api/v1/ai-agents/{id}. A saved selection that is not yet available, such as claude-sonnet-5, runs on the default, so the two can differ; `model` keeps its meaning.
    • addedPOST /api/v1/automations: an SMS `send_message` step in a free-form `definition` may carry `smsMessageType`, `marketing` or `transactional`. A marketing SMS goes only to contacts who opted in to SMS marketing and only in the SMS sending hours (Mon-Fri 08:00-20:00, Sat 09:00-13:00, never Sundays or public holidays); `transactional` is for order or service updates. When it is absent the step goes as transactional, except in a run started by `commerce.order.abandoned` (a basket win-back is promotional), where it goes as marketing; send it to say which.
    • addedCommerce orders carry `customer.sms_marketing_opt_in` in GET /api/v1/commerce/orders, GET /api/v1/commerce/orders/{id} and every commerce order webhook: true when the buyer ticked "Send me reminders and offers by SMS" at a storefront checkout with a cell number, false otherwise (and on every order placed before the question was asked). Storefront checkouts now ask every buyer for an optional cell number, required only for a delivery. A flow on commerce.order.abandoned sends its SMS only to a buyer who said yes, and no longer sends WhatsApp; order updates are unchanged.
    • addedCommerce orders carry `number`, a short order number sequential per business from 1001 (the #1043 a buyer and the dashboard see), in GET /api/v1/commerce/orders, GET /api/v1/commerce/orders/{id}, each line of GET /api/v1/commerce/products/{id}/sales, and every commerce order webhook (nested as `order.number`, and flat as `order_number`). `GET /api/v1/commerce/orders?q=` finds an order by it, with or without its #. `reference` is unchanged: it stays the payment reference and the key of the buyer's order page. Orders placed before the change are numbered in the order they were created.
    • changedPATCH /api/v1/flows/{id} now answers 409 conflict when a definition change would overwrite staged journey changes. Publish the staged journey changes or choose Discard staged journey changes before editing the form; metadata changes and unchanged definitions remain allowed. POST /api/v1/flows/{id}/publish publishes the staged definition when one exists and clears it atomically. If that draft or the live definition changes during publishing, it refuses the stale result rather than overwriting the newer edit. Existing form URLs remain unchanged.
    • changedA commerce order's `emails` entry with state `failed` now says why its last try failed in `reason`: message_rejected (the recipient address or the message was refused), sender_not_allowed (our sending setup was refused), provider_rate_limited, provider_unavailable, send_error (it failed on our side before an answer) or provider_error (any other answer). It is never the email provider's own words. A failure recorded before 15 Oct 2026, when the reason began to be kept, and any value outside the list, reads null; `retrying` and `sent` entries still carry null.
    • addedCommerce orders carry `emails` in GET /api/v1/commerce/orders, GET /api/v1/commerce/orders/{id} and every commerce order webhook (the nested order object): how each automatic order email went, by kind (confirmation, sale_notification, shipped, tracking, refunded). Each entry gives `state` (sent, retrying, failed or skipped), `at`, `attempts` (sends tried so far) and, on a skip only, `reason` from a documented list that may grow (no_buyer_email, no_owner_email, no_tracking_reference, reserved_address; anything else reads null). A kind appears once something was recorded for it, so an order emailed before 13 Oct 2026 may have none. It is state only: no address or message content. In a webhook body it is a snapshot at the moment of the event; re-fetch the order for how the emails went since.
    • addedPOST /api/v1/documents (contacts:write) uploads one private PDF, up to 4 MB, for one contact as multipart/form-data (`file`, `contact_id`, optional `kind`) and answers 201 with the document. The bytes decide what the file is: anything that is not a complete PDF is refused with details.reason not_a_pdf. The SDK gains bt.documents.upload({ file, contact_id }) for it from its next published release.
    • addedPOST /api/v1/messages takes `document: { id }` for a document uploaded with POST /api/v1/documents, as well as `{ url, filename }`. The document 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 the workspace does not hold answers 404 not_found, details.reason document_not_found. Nothing is written either way.
    • addedGET /api/v1/conversations/{id}/messages/{messageId}/media (conversations:read) answers with a signed URL for one message's file that works for five minutes, plus its media_type, media_mime and media_filename. While the file is still being fetched it answers 409 conflict with details.reason media_pending; when the fetch failed for good, 404 not_found with details.reason media_unavailable. The messages list now gives every message with a file a media_download_path to call (null for text). The SDK gains bt.conversations.messageMedia(id, messageId) for it from its next published release.
    • changedmedia_url on GET /api/v1/conversations/{id}/messages is deprecated and will be removed on 2027-04-15: use media_download_path. Until then it is a signed link minted for each read that works for one hour. Before, it was the stored link: for files you sent that stayed valid for seven days, and for files a customer sent it pointed into private storage and did not open. Do not store it; read it again, or call media_download_path, when you need the file.
    • changedPOST /api/v1/ai-agents/{id}/chat: each reply is now charged R2.50, or its model cost plus 20% when a very long reply costs more (the price of every AI reply). The workspace's daily ceiling now counts what the replies cost in model time, R25 a day (about 100 typical replies), rather than what they were charged, so a workspace keeps the same headroom; it still answers 402 spend_cap_reached, whose spent_today_zar_cents is that cost.
    • addedmessage.received webhooks now carry has_media, media_type, media_mime, media_filename and media_status, the same fields GET /api/v1/conversations/{id}/messages returns. has_media is false for text and null only when the message could not be read when the event fired. The event fires before the file is fetched, so media_status is almost always "pending". The media object ({type, filename}) is unchanged, and the webhook still carries no media URL. The test delivery (POST /api/v1/webhooks/{id}/test, media: true) and the sandbox's simulated reply send the same fields.
    • addedGET /api/v1/commerce/orders takes contact_id (a contact's UUID) and returns the orders linked to that contact, with the other filters. A buyer is linked to a contact when their order is paid, so this lists one customer's orders even when they gave no email. A malformed contact_id answers 400 validation_failed; a contact outside your workspace returns no orders. The SDK's commerce.listOrders accepts contact_id.
    • addedGET /api/v1/businesses/{id}/keys lists credentials issued by the calling API key for that business, with limit/cursor pagination and no secrets. DELETE on the same path with key_id revokes one such credential idempotently. Both require businesses:write; keys issued by another caller, through the dashboard, or before minter attribution was recorded remain managed through the dashboard. To replace a credential, mint a new one, deploy it, then revoke the old one.
    • addedGET /api/v1/commerce/discounts now returns redemptions_waiting on each code: orders that still hold a use while they can be paid (pending, failed or abandoned, placed in the last 7 days). Checkout counts these against max_redemptions too, so a capped code is used up when redemptions and redemptions_waiting together reach the cap. redemptions keeps its meaning (paid orders) and is now counted in full, however many orders carry the code.
    • fixedPOST /api/v1/messages without business_id now uses the selected live channel's business for recipient lookup, contact creation, suppression checks and the conversation. This prevents a send from being filed under the workspace's default business when its channel belongs to another. WhatsApp templates select their attached channel; dry runs use the same scope. A contact found only in another business (or with no business) now answers 404 not_found with details.reason recipient_not_found: create the recipient in the sending business with to.create, or pass an explicit business_id and a channel belonging to it. Multiple WhatsApp or email channels still require a business_id or channel_id. Test keys and SMS without a workspace sender retain their existing channel-free behavior.
    • fixedCommerce resource routes now consistently answer malformed UUIDs with 400 validation_failed before resource access, including product edits and deletion, order reads, fulfilment, refunds, courier booking, discounts and delivery-zone removal. A valid UUID missing from your workspace answers 404; deleting an unknown product previously reported success. Clients should handle 400 for malformed IDs and 404 for missing products; malformed IDs previously answered 500.
    • addedGET /api/v1/commerce/products/{id}/stock-movements (commerce:read) lists every change to a product's stock count and its options' counts, newest first: the change, the count it left, why (a paid order with its reference, a hand edit, an API update, a product created) and who made it, by name. The database records every change itself, so none is missed; page with limit (at most 100) and before.
    • fixedPOST /api/v1/messages with to.create: true now refuses a soft-deleted suppressed email recipient regardless of letter casing, using the same literal email matcher as contact creation. It also re-checks suppression when contact creation finds a row added by another request after validation. Both answer 400 validation_failed with the opt-out explanation before creating a message; unsuppressed recipients behave as before.
    • fixedA flow's payment-link step run again on the same message now hands out a stored link only while at least a day of Blueticked's 7-day payment re-check remains (links up to 6 days old), and never makes a fresh link on an order older than that: both are refused with payment_link_expired and the sentence "This step's payment request is older than 6 days, so it was not sent again: send a new payment request." Orders whose payment failed but still hold a link are now re-checked with Paystack too, so a later payment on that link still settles.
    • fixedAPI key authentication counts failed secrets toward the authentication throttle; valid secrets use their configured request quota. Missing credentials on routes that also accept dashboard sessions now receive API-key guidance and the same failure throttle. All dashboard key profile edits require two-step verification. A second graceful rotation during an active grace period answers 409 with details.reason rotation_grace_active: wait until details.expires_at or choose end_old_secret=now to end every earlier secret. Rotation preserves last-used history; Manage shows when the previous secret expires, and a failed list refresh keeps the newly revealed secret available to save.
    • fixedA flow's payment-link step run again on the same message (a Send again, or a replay) no longer hands out a stored checkout link older than 7 days, the window in which Blueticked re-checks a link's payment with Paystack: the step is refused, nothing is sent, and the refusal carries the new code payment_link_expired in the closed set of send refusal codes. Send a new payment request instead; a link inside the window is still sent again.
    • fixedA Send again of a flow payment-link step whose order was abandoned without a checkout link now reports payment_link_order_abandoned, explaining that the order was abandoned before a link was available. It sends nothing and does not ask Paystack again. Existing gateway refusal, unconfirmed-payment, closed-order and amount-mismatch outcomes keep their meaning; an abandoned order with a link still reuses it.
    • addedOwners and admins can edit an existing secret API key's scopes in Developers > Keys > Manage with two-step verification. The key keeps its secret and ID; changes are audited with before and after scopes. Agent and widget scopes remain server-managed. Missing-scope 403 next steps now point to the scope editor.
    • fixedPOST /api/v1/webhooks/{id}/replay-failed and the dashboard now share a per-webhook replay lock: a concurrent bulk replay answers 409 conflict with Retry-After: 60. Retry after the first call finishes; deliveries already replayed are skipped. Different webhooks can still replay concurrently. A configured lock service that cannot be reached answers 503 service_unavailable before any replay reads or inserts; local development without Redis keeps its unlocked behavior and logs a warning.
    • fixedA flow's payment-link step run again on the same message (a Send again, or a replay) no longer hands out the link of an order that is already paid, cancelled or refunded: the step is refused, nothing is sent, and the refusal carries the new code payment_link_order_closed in the closed set of send refusal codes. An abandoned order's link is still sent again, since it can still be paid, and the order stays abandoned until a payment moves it to paid.
    • fixedPOST /api/v1/campaigns now checks that a list or segment audience belongs to the organisation and selected business (or is organisation-wide) before saving or scheduling. A foreign, deleted or missing audience answers 404 not_found with details.reason list_not_found or segment_not_found; a failed audience lookup answers 503 service_unavailable with details.reason audience_unchecked. Choose an available audience, or retry a failed lookup. Scheduled and recurring sends recheck ownership before reading members.
    • addedPOST /api/v1/webhooks/{id}/replay-failed replays, in one call, every delivery of a subscription that failed for good since a time (at most 7 days back): up to 100 per call, oldest first, with remaining counting the rest. A delivery that already has a replay, or whose payload was removed, is skipped, so a later call never sends an event twice (concurrent bulk replays of one webhook now answer 409 conflict with Retry-After: 60; retry after the first finishes). Each replay goes to the subscription's current URL, signed with its current secret. Needs webhooks:write; 10 calls an hour per workspace; honours Idempotency-Key. The dashboard's delivery log offers the same as Replay failed, and the SDK gains bt.webhooks.replayFailed(id, { since }) for it from its next published release.
    • changedAn order abandoned because its payment link never reached the customer (failure_reason link_not_delivered) now starts the owner's abandoned-order flows that chose it, not only the commerce.order.abandoned webhook, which still fires once. An abandoned-order flow's trigger says which abandonments start it: only when the customer left the checkout (what every existing flow keeps doing), only when the payment link never reached them, or any checkout left unpaid. The flow's event data carries abandon_reason (left_checkout or link_not_delivered) beside the order.
    • changedemail.bounced now fires only for a bounce that suppressed the address (a permanent, undetermined or unclassified bounce), once per message. A temporary bounce sends no webhook and is retried, and a later permanent bounce of the same email still sends email.bounced. A campaign's bounced count and its bounce rate count the same bounces, so temporary bounces are no longer counted as bounced. A flow triggered by a bounce follows the same rule: a temporary bounce no longer starts it.
    • fixedAn order made by a flow's payment link now moves to abandoned with failure_reason link_not_delivered when the provider reports the link's message failed after accepting it (a WhatsApp or SMS delivery report, or an email bounce), not only when Blueticked refused it at send. commerce.order.abandoned fires once, carrying failure_reason, and a payment that arrives anyway still moves the order to paid. A message waiting for credit is not a failure: its order stays pending until the message goes out, and so does one whose provider outcome is unknown.
    • addedGET /api/v1/commerce/payment-links takes an optional reference and returns only the payment link with that exact reference (such as an order's own reference), in the same shape: orders is empty when none of yours has it. An empty or over-long reference is a 400 validation_failed. The SDK's commerce.listPaymentLinks takes { reference } the same way; calling it with nothing, or with request options alone, is unchanged.
    • fixedPOST /api/v1/tickets refuses a contact_id that is not the contact of the given conversation_id, with 400 validation_failed. It used to store both, so the ticket pointed at two people and the flows that follow a ticket's conversation skipped it without a word. Omit contact_id when you pass conversation_id: the ticket takes the conversation's contact. A contact_id with no conversation_id is unchanged.
    • fixedPATCH /api/v1/webhooks/{id} with rotate_secret: true has always answered the new signing secret, but the published 200 schema left secret out, so a generated client could not read it. The schema now declares webhook.secret, a string present only on a rotate, and the SDK's webhooks.rotateSecret returns it as a required string (RotatedWebhookSubscription). A plain update still has no secret. The secret is returned once: store it from this answer.
    • fixedPOST /api/v1/commerce/payment-links refuses a link priced from items whose total is under R 1 or over R 1 000 000, with the same 400 and sentence as an amount outside those bounds ("Enter an amount between R 1 and R 1 000 000."). It used to refuse only a total of nothing, so a basket could come to R 0.50 or past the bound the route promises. Nothing is written and your Paystack account is not read for a refused basket.
    • addedPOST /api/v1/campaigns accepts a table as the audience: { type: "table", table_id, status? } sends one message per contact found in that table's rows (or only the rows in one status), using each contact's newest row; rows with no contact are skipped, and every contact still passes the same consent, opt-out and suppression checks as any audience. A WhatsApp template value can read a column of that row with { source: "row_field", value: "<column key>" }. A table that is not this business's answers 404 not_found (details.reason table_not_found); a reference table, a status or column the table does not have, or a row_field value on an audience that is not a table answers 422 validation_failed with details.reason table_has_no_people, table_status_unknown, table_column_unknown or row_field_needs_table.
    • addedPOST /api/v1/messages now says why it refuses an email its sender cannot send: the 400 validation_failed carries details.reason. A marketing email from the shared Blueticked sender is custom_identity_required (transactional email can still use it); a marketing email whose domain has no confirmed DMARC record, or whose business profile lacks its legal name or postal address, is dmarc_required, business_identity_required or physical_address_required; a freemail reply-to is freemail_reply_to, and one off the sending domain is reply_to_not_aligned. A blu_test_ key does not check the sender, so the first live send is where these show. Branch on the reason rather than the message.
    • addedA WhatsApp Send a payment link step can name its own approved template for when the customer last wrote more than 24 hours ago. The template carries the link, as a value mapped to payment_link or as a link button whose address starts https://checkout.paystack.com/ (Blueticked fills in its ending), and goes out in place of the step's message once the link is made. A template with no place for the link refuses before any payment link is made, with the new code payment_link_template_no_link and its sentence; a step with no template still refuses with payment_link_window_closed, whose sentence now says a template can carry the link.
    • addedWebhook deliveries carry x-blueticked-signature-v2: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>"> beside x-blueticked-signature, which is unchanged. Because it signs the send time, a receiver can refuse a delivery whose timestamp is older than 5 minutes, so a captured request can't be replayed later. During a secret rotation it carries a second v1 signed with the previous secret. The SDK's verifyWebhook and constructEvent (and bt.webhooks.verify and constructEvent) require it and refuse a timestamp outside the tolerance (options.toleranceSeconds, default 300). A delivery without it, or a body-only signature string passed on its own, is refused unless options.requireV2 is false, which accepts the body-only signature while you migrate and is weaker: a capture replayed with v2 stripped then passes. The body-only header stays for existing receivers and will be retired only with notice here; delivery_id remains the way to drop a retry you have already handled.
    • breakingThe Sites API (GET and POST /api/v1/sites, POST /api/v1/sites/{id}/publish and POST /api/v1/sites/{id}/unpublish) now answers every error in the published envelope, { ok: false, error: { code, message, details, next_steps }, request_id }, where it answered a bare { error: { code, message } }. Its own codes became catalogued codes, with the specific case in details.reason: no_draft and invalid_draft are validation_failed (400); live_address_unavailable is service_unavailable (503); publish_failed, unpublish_failed and insert_failed are internal_error (500); not_published, business_id_required and unknown_template are validation_failed (400); business_not_found is not_found (404); slug_unavailable is conflict (409); invalid_body, invalid_json (a body that is not JSON) and invalid_id (a site id that is not a UUID) are validation_failed (400) with no reason; db_error and a bare internal_error are internal_error (500), and a site address that could not be checked is internal_error with reason insert_failed where it was a 409. conflict and forbidden keep their codes, and a plan limit answers as on every other route. Statuses are otherwise unchanged. Branch on error.code, and on details.reason for the case; every reason is listed on the Errors page.
    • changedPATCH /api/v1/commerce/orders/{id}/fulfilment answers with already_fulfilled: false when it marks the order fulfilled, and already_fulfilled: true when the order already was. A repeat mark now keeps the first fulfilled_at and sends nothing again (no second commerce.order.fulfilled webhook, shipped email or order flow); it used to move fulfilled_at and announce every time, so a retried call is now safe. An order is announced as fulfilled once in its life: marking it again after taking it back (fulfilled: false) records the new fulfilled_at and announces nothing. A mark that loses to a concurrent undo answers 409 conflict. The published schema now allows fulfilled_at: null, which an undo (fulfilled: false) has always answered. A commerce.order.* event repeated within 24 hours of the first runs its order flows once.
    • fixedPOST /api/v1/webhooks and PATCH /api/v1/webhooks/{id} accept every event in the catalogue at once, as GET /api/v1/webhooks lists them in available_events. The events list was capped at 20 while the catalogue has 28, so subscribing to all of them answered 400 invalid_events. The cap is now the size of the catalogue, and a list longer than it is still refused.
    • fixedThe email.opened and email.clicked webhooks now fire once per message, as email.opened was documented: on the first open, and on the first click with that click's url. Each repeat open or click of the same email used to send another delivery. A link click that only unsubscribes or opens the preference centre is still not a click. email.bounced and email.complained follow the same rule. The delivery payload is unchanged.
    • addedA 401 unauthorized for a key whose secret checks out now says why in details.reason: revoked (the key will not work again; create a new key), expired (its expiry date has passed; rotate it to make it work again) or workspace_inactive (its workspace is no longer active). Branch on the reason rather than the message. An unknown key or a wrong secret still answers 401 with no reason, so a caller without the secret learns nothing about which keys exist. A key has no expiry unless one is set, and an owner can set or move it on the key at any time.
    • fixedA retry of POST /api/v1/webhooks with the same Idempotency-Key now omits secret from the webhook it returns, where it answered secret: null. The published 201 schema and the SDK's WebhookSubscription.secret?: string never allowed null; secret is now optional in the schema, a string when present. The signing secret is still returned once, in the first answer, and never stored with the cached reply. Test whether secret is present, not whether it is null: a replay cached before this change may still say secret: null until its 24-hour idempotency window ends. If the first answer was lost, rotate the secret, or have an owner show it again in the dashboard.
    • addedCommerce orders carry failure_reason and link_message_id in GET /api/v1/commerce/orders, GET /api/v1/commerce/orders/{id} and every commerce order webhook (the nested order object). failure_reason says why an order was set aside, from a documented list that may grow: today only link_not_delivered, which means the order came from a flow's payment link whose message could not be delivered, so the order is abandoned and the customer never got the link. It is null otherwise, and a payment that arrives anyway still moves the order to paid and clears it. Such an order also fires commerce.order.abandoned to webhooks, carrying failure_reason so it reads apart from a checkout left unpaid; it does not start abandoned-order flows yet. link_message_id is the id of the message the payment link went out in (GET /api/v1/messages/{id} gives that message's own failure_reason), and null for an order no payment link made.
    • changedPOST /api/v1/email/sending-domain no longer answers 502 provider_error when the email provider already has the domain. It answers 409 conflict with details.reason domain_in_use_elsewhere (another Blueticked workspace sends from it), domain_in_use_by_business (another business in your workspace does) or domain_registered_unclaimed (no workspace holds it; contact support with request_id to release it, as a retry gets the same answer). It answers 503 service_unavailable with domain_holder_unchecked if the holder could not be read. The email provider account is shared by every workspace, so a domain it already has is never taken over. A race in which another channel takes the name answers the same way; it used to answer 400 validation_failed. Nothing is created or deleted at the provider in any of these cases.
    • changedThe sending-domain routes' 502 provider_error message for a timeout or a provider 5xx now says Blueticked could not confirm the change, because the provider may have made it. Remove, tracking and verify add that trying again changes nothing. Add says to contact support if a retry answers domain_registered_unclaimed. details.provider_status and next_steps are unchanged.
    • addedPOST /api/v1/messages accepts to.create: true (with optional to.first_name and to.last_name) to create the recipient as a 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 (403 forbidden with details.reason "contacts_write_required" otherwise). A created contact has no consent, so a marketing send with to.create answers 400 validation_failed with details.reason "consent_required" and writes nothing. The contact is created only after every other check passes, so a refused send leaves none behind; the plan's contact limit answers 403 plan_limit_reached, as POST /api/v1/contacts does. The 202 answer adds contact_created, and contact_id when the send created the contact or the key holds contacts:read; a test_mode dry run adds would_create_contact and creates nothing. Without to.create a send to someone who is not a contact still answers 404 not_found, now with details.reason "recipient_not_found". Every details.reason the API writes is listed on the Errors page.
    • addedA 429 rate_limited answer from /api/v1 now carries Retry-After: the whole number of seconds to wait before retrying, never below 1. It names the same moment as x-ratelimit-reset, which stays an ISO-8601 timestamp (not a number of seconds), so an HTTP client that waits on Retry-After by default now retries at the right time instead of at once. A simulated rate_limited (X-Simulate-Error with a test key) carries Retry-After and x-ratelimit-reset too, so a retry loop rehearses against the real headers. Every other header and the error body are unchanged.
    • changedPOST /api/v1/messages (to.phone), POST /api/v1/contacts and PATCH /api/v1/contacts/{id} (phone) accept a phone the way POST /api/v1/events/send always has: international (+27821234567) or South African national form (082 123 4567, 27821234567), with spaces, dashes or brackets ignored. The contact is stored and returned with the E.164 form (phone: "+27821234567"), and a message reaches the same stored contact whichever form addressed it. A value that is not a phone still answers 400 validation_failed, now with the same sentence as events/send, which names both forms.
    • changedPOST /api/v1/commerce/payment-links answers 400 validation_failed for an amount under R 1, as its message always said ("Enter an amount between R 1 and R 1 000 000."). It used to accept any amount that rounded to at least one cent, such as 0.5. Amounts from R 1 to R 1 000 000, and links priced from items, are unchanged.
    • fixedPOST /api/v1/templates answers 422 template_kind_unsupported, with details.field naming it, when the body carries a header field (any field whose name starts with header, in any case: header, header_format, headerFormat). The API creates templates without a header, and it used to drop such a field without a word, so a call asking for a DOCUMENT header made a template without one and the document send against it failed later. Nothing is created. Make a template with a header in the dashboard; one with a DOCUMENT header is then sent with document. A body without a header field creates as before.
    • fixedPOST /api/v1/campaigns no longer says a campaign was not started when Blueticked could not take it back. If a launch fails after its recipients were prepared (the fan-out failed part way, the send queue was unavailable or the hand-off to it failed) or a schedule is refused, and the campaign could not be removed, the answer is 500 internal_error with details.campaign_id, and error.message says the campaign may still go out and names GET /api/v1/campaigns/{id} to check before trying again. That answer is stored against the Idempotency-Key, so a retry with the same key within 24 hours gets the same answer instead of creating a second campaign. A campaign that was removed answers as before (a queue that is not configured is still 503 queue_unavailable), and refusals before any recipient was prepared (the wallet, the send budget, a blocked document) keep their own code and release the key.
    • addedFlows gain a Send a payment link step, so an event's outcome line can now read not_sent with one of seven new codes: payment_amount_invalid, payment_account_missing, payment_account_unreadable, payment_currency_unsupported, payment_link_refused, payment_link_unconfirmed and payment_link_trigger_unsupported, each with its own sentence. payment_link_unconfirmed means Blueticked could not confirm the link was created and sent nothing: check the business's payment links before sending another. Every existing code keeps its meaning.
    • addedA WhatsApp Send a payment link step started by an event refuses when the customer last wrote more than 24 hours ago, with the new code payment_link_window_closed and its sentence, before any payment link is made. A closed-window template cannot carry the link yet, so the flow's closed-window template is never sent in its place. Customer replies and forms, which open the window, are unchanged.
    • fixedJourney.trigger is no longer advertised by the SDK: the existing journey serializer returns enrolment_trigger instead. Read enrolment_trigger and handle null. Journey request and response payloads are unchanged.
    • fixedEnrollContactsResult.capped is a number, not a boolean: it counts the contacts the existing enrolment limit capped. Compare capped with zero rather than treating it as a boolean. Journey enrolment payloads and limits are unchanged.
    • fixedSite.name is no longer advertised by the SDK: no site response has ever carried a name. Identify a site by its slug; GET /api/v1/sites also returns its primary_domain and public_url. Site creation and read payloads are unchanged.
    • fixedAutomation.enabled is no longer advertised by the SDK: the serializer reports status. Read status and distinguish draft, live and paused instead of checking enabled. Flow activation rules and payloads are unchanged.
    • fixedAutomation.trigger is no longer advertised by the SDK: the existing flow serializer returns what starts the flow as the top-level trigger_type. Read trigger_type. Flow payloads and execution are unchanged.
    • fixedAutomationRecipe.key is no longer advertised by the SDK: the recipe serializer returns id. Use id when creating a flow from a recipe. Recipe payloads are unchanged.
    • fixedThe SDK method contracts now expose the webhook test's existing 202 queued delivery, discount.free_delivery and distinct campaign counters: list items require sent/delivered/failed, while detail returns all seven counters. Integration field_map and response_map inputs accept plain interface-typed items without the response's arbitrary-key index signature. Endpoint payloads and runtime behavior are unchanged.
    • fixedThe /me generated contract now keeps business.slug non-null and permits null business.timezone, matching the selected database columns. Conversation list includes business_id; conversation last_message_at is non-null, matching its database column. The webhook-test 202 delivery includes its selected created_at. Earlier overly nullable or incomplete SDK promises are corrected; response payloads are unchanged.
    • changedWhen the email provider refuses Blueticked's own connection (provider_status 401 or 403), the sending-domain routes' 502 provider_error now says so: error.message says the fault is Blueticked's to fix, not the domain, and next_steps names contact_support instead of check_sending_domain. Every other provider status answers as before.
    • changedPOST /api/v1/email/sending-domain, POST /api/v1/email/sending-domain/verify and DELETE /api/v1/email/sending-domain answer 502 provider_error when the email provider refuses or fails a sending-domain change, with details.provider "resend" and details.provider_status (the provider's HTTP status). They answered 503 channel_unavailable with the provider's own message, though the channel was connected. error.message is now Blueticked's sentence for that status, and next_steps names check_sending_domain. A missing email configuration on Blueticked's side answers 500 internal_error.
    • addedPOST /api/v1/messages and POST /api/v1/campaigns answer 422 template_kind_unsupported, with details.kind, for a WhatsApp template Blueticked can't send yet: product (Product or Item list buttons, a product header or product cards), carousel (carousel cards), expiring_offer (a limited-time offer with an expiry) or coupon_code (a coupon-code button). No send path supplies those per-send values yet, so these requests used to reach Meta and fail there; now nothing is stored or sent. The code is new; every other code keeps its meaning.
    • addedCommerce orders carry refunded_at, the moment an order was refunded, in GET /api/v1/commerce/orders, GET /api/v1/commerce/orders/{id} and every commerce order webhook (the nested order object). A refunded order now keeps paid_at, the moment it was paid, where it used to be cleared to null. An order refunded before this change has refunded_at null, and paid_at is null too unless the original payment event could restore it. Revenue figures count status paid only, so a refunded order is never counted as revenue.
    • addedCommerce orders carry paystack_reference, Paystack's own reference for the payment (its transaction id, distinct from the order's reference, which is what Blueticked hands Paystack), in GET /api/v1/commerce/orders, GET /api/v1/commerce/orders/{id} and every commerce order webhook (the nested order object). It is kept when an order is paid, from the verified payment notice or Blueticked's own check with Paystack, and is null on an order not yet paid and on one paid before this change.
    • changedCommerce routes (/api/v1/commerce/...) now answer error.message as a sentence ("That order was not found.", "The product could not be added. Try again in a moment.") where some answered an internal code such as order_not_found or product_create_failed. error.code, the machine-readable field to branch on, is unchanged.
    • changedAn SMS refusal only Blueticked can clear (no SMS provider configured, the marketing reply route missing, or Do-Not-Contact screening unavailable) now answers channel_unavailable with next_steps [{ action: "retry_with_backoff", by: "waiting" }] instead of connect_channel with the SMS setup page, which asked you to fix something you could not. A refusal about your own SMS sender identity still points at the setup page. error.code is unchanged.
    • fixedTemplate.category in the generated SDK is a closed set of marketing, utility and authentication, matching the existing template validator. The previous string type advertised arbitrary categories the API rejects. Use one of those three values. Template payloads and category validation are unchanged.
    • fixedThe OpenAPI POST /api/v1/messages test-mode response examples now belong to their actual 200 response, not the queued 202 response. Set test_mode:true for a 200 response with status "validated" and no queueing; ordinary sends still return 202 with status "queued". The SDK's TestSendResponse and SendMessageResponse remain distinct generated contracts. Runtime status codes, response payloads and provider behaviour are unchanged.
    • fixedThe public SDK response facades now derive from the generated OpenAPI contract, including business/key, wallet, contact, flow, template, integration, message/event, campaign, table, ticket, commerce, email and resource responses. Verified serializers correct nullable fields, wallet plan totals, contact custom_fields, optional DNS record fields, optional DMARC results and complete payment-link projections; conversation detail uses its actual selected fields, flow creation exposes its draft note and webhook tests expose the queued delivery. Ticket creation documents its existing 201 status; conversions document both 201 creation and 200 idempotent replay. List resource types do not require detail-only request_id metadata on each item. Keep null checks and check optional fields before using them. Message and commerce-order lifecycle status enums are preserved; documented closed sets remain closed. SDK request and configuration types remain input contracts. Runtime response bodies and send behaviour are unchanged.
    • fixedCreatedBusiness.created_at is no longer advertised by the SDK: the provisioning RPC returns id, organization_id, name, slug and timezone, but never created_at. Read GET /api/v1/businesses for the creation timestamp instead. POST /api/v1/businesses payloads are unchanged; organization_id remains required.
    • fixedFlow.external_id is no longer advertised by the SDK: flow serializers return meta_flow_id, not external_id. Read meta_flow_id and handle null before publishing. GET /api/v1/flows and GET /api/v1/flows/{id} payloads are unchanged.
    • fixedMessageStatus remains a closed lifecycle set and now includes parked, the existing wallet-held message state previously missing from the SDK union. Handle parked without treating it as delivered or failed. GET /api/v1/messages/{id} payloads are unchanged.
    • fixedTemplate.status remains a closed lifecycle set and now includes disabled, the existing Meta-disabled state previously missing from the SDK union. A disabled template cannot be sent; choose an approved template instead. Template list and detail payloads are unchanged.
    • fixedSendMessageResponse.status is queued, not the full message lifecycle union: the initial 202 receipt only acknowledges queueing. Read GET /api/v1/messages/{id} or delivery webhooks for later lifecycle states instead. POST /api/v1/messages payloads are unchanged.
    • fixedThe generated OpenAPI contract for GET /api/v1/commerce/orders and GET /api/v1/commerce/orders/{id} now allows null for optional order descriptions, customer identifiers, fulfilment details, product and variant identifiers, discounts, site ids and payment timestamps. refunded_at is always present and may be null, matching the serializer. The SDK's CommerceOrder and CommerceOrderItem derive from that generated contract, so new documented fields propagate without a second field list. Response payloads and send behavior are unchanged. SDK consumers should keep null checks on those fields.
    • fixedThe SDK's WorkspaceReadiness, workspace context, conversation and WhatsApp readiness types now derive from the generated response contracts. Those contracts document the optional sandbox pending_for_live blockers, nullable business_id and business-id lists, nullable business timezones and readiness connection/message timestamps, each readiness channel's business boundary, and optional action_url and blocker links. Business slugs and conversation last_message_at are non-null. Endpoint payloads are unchanged; this corrects the generated types to match existing GET /api/v1/me, GET /api/v1/readiness, GET /api/v1/conversations and GET /api/v1/channels/whatsapp/readiness responses. Keep null checks before using a business boundary or nullable timestamp, and check a link exists before navigating.
    • changedCampaigns (including POST /api/v1/campaigns) and journeys greet a contact by first name the way flows already do. A {{first_name}} token, and a template variable bound to first_name, now send a first name that is a title or an initial ("SR.", "Dr", "T") as "there" (or the token's own fallback), and an all-capitals or all-lower-case name in its usual case ("MARY" is sent as "Mary"). A contact with no first name is greeted "there" too: a WhatsApp template variable bound to first_name no longer fails that recipient as an empty value. The stored contact is never changed, and every other token and binding (last_name, full_name, email, phone, custom fields) is sent as stored. Test sends and previews show the same text.
    • changedGET /api/v1/campaigns?channel=telegram answers 400 validation_failed (details.field channel) where it filtered by it, while Telegram is switched off for the platform, as it is today (if it is switched on, the filter works as for the other channels). Telegram is not a channel customers can use: it has no connect path, and no v1 call can create a Telegram campaign. whatsapp, sms and email filter as before, and campaign objects are unchanged (a stored channel value of telegram can still appear in a response).
    • addedPOST /api/v1/templates/{id}/submit (templates:write) sends an existing draft template to Meta for review and answers with the template at status pending. The draft itself is submitted, so no second template is left behind, as there was when a draft was retried through POST /api/v1/templates. Only a draft is accepted: any other status answers 409 conflict with the status in details, and so does a draft another submit is sending at that moment, so one draft reaches Meta once. The draft is checked as the dashboard checks it (400 validation_failed), a number that is not connected answers 503 channel_unavailable, and Meta's refusal answers 502 provider_error. It honours Idempotency-Key. A test key runs the checks and stops before Meta, leaving the draft unchanged (test_mode: true, submitted_to_meta: false). The TypeScript SDK gains bt.templates.submit in its next npm release.
    • changedPATCH /api/v1/conversations/{id} with status resolved now hands the conversation back to flow, as Done in the Inbox does: a hand-over pause is lifted, so flows answer the customer's next message. While a ticket on the conversation is open or in progress, the hand-over stays. The response is unchanged.
    • fixedOn POST /api/v1/events/send and incoming app callbacks, a line refused because the business's channel is not connected, or because the customer was deleted before the send, now reads not_sent with code channel_not_connected or contact_deleted and its own sentence, where it read as a failure inside Blueticked. Both codes join the closed set an outcome line's code comes from. Nothing else changes.
    • fixedAn API key's monthly spend cap counts the month from 00:00 South African time on the 1st, the month the usage screen shows. It counted from 00:00 UTC, so for the first two hours of a month (00:00 to 02:00 SAST) a key could still be refused 402 spend_cap_reached for the month that had just ended.
    • addedOn POST /api/v1/events/send and incoming app callbacks, a not_sent outcome line that a flow refused now carries code beside its reason, from a closed set declared in the API reference (for example template_value_missing, template_contact_value_missing, template_value_unmapped, template_not_ready). Branch on the code, never the sentence: template_value_missing means the event itself lacked a value the message needs, so sending the same event again will not send that line, while a contact value or an unmapped template value can be fixed first. Other lines, and every other field, are unchanged.
    • addedIntegrations carry five new read-only fields: connector_key, connection_state (pending, connected, expiring, needs_reauth or revoked), connection_state_since, connection_state_reason and external_account_label. They describe a connection made by signing in to a system from Blueticked and are null on every other connection. GET /api/v1/integrations and GET /api/v1/integrations/{id} return them, except that the list leaves out a sign-in that was started and not finished (pending); nothing is required on requests, and the vendor's tokens are never returned.
    • addedPATCH /api/v1/integrations/{id} accepts an optional type: "date" on each inbound_field_map item. A target the organization does not have yet is then created as a date custom field instead of text, and an incoming ISO time mapped to a date field is stored as that business's calendar day (YYYY-MM-DD in its time zone) instead of being dropped. Items without type, and every response, are unchanged. A booking's start time mapped this way can start a journey on "A date comes around", for example a reminder the day before.
    • fixedEvery /api/v1 call made with an API key now closes its request-log entry. A call that failed inside Blueticked is logged as 500 internal_error, where it read "no reply recorded" with no status. A plain response (one without the standard JSON envelope) now carries the x-request-id header that names it in your request log. Response bodies are unchanged.
    • changedA /api/v1 call that sends its own x-request-id header no longer gets that value back as its request id. Every call's request_id (in the body and the x-request-id response header) is one Blueticked mints, so two calls that reuse one header, such as a retry while the first is still running, each get their own request-log entry with their own status; they used to share one, and one of the two answers was lost. The id you sent is kept on the request-log entry as client_request_id when it is 1 to 200 letters, digits or . _ : - characters.
    • fixedA /api/v1 call that fails inside Blueticked now answers 500 with the standard error envelope (code internal_error, next_steps, request_id) and the x-request-id header, where it answered a bare text 500 with neither. The request_id names the request-log entry, so you can quote it to support. Nothing else changes: the call is still logged as 500 internal_error.
    • fixedReports lists failures with no stated reason, as the Failed count always did. The recent-failures list left out a failed message with no failure reason, so it could show fewer failures than the Failed count beside it.
    • fixedWhatsApp flow template actions now refuse named parameters instead of queuing an unfilled message. The same refusal covers the closed-window template fallback. Incoming app callbacks and POST /api/v1/events/send report a fixed not_sent reason, with no provider send or message row for that action; their response shapes and status codes are unchanged. Flow activation and selection identify unsupported named templates, including legacy bodies missing format metadata. Existing selections and mappings are preserved: choose an approved template with numbered parameters before activating or sending. Email and SMS snippets are unchanged.
    • fixedIncoming app callbacks and POST /api/v1/events/send say why a template step could not send, in a fixed not_sent reason, instead of "Something went wrong sending this line. Try it again": a contact value the template's link needs, a link ending the step is missing or no longer uses, a template that was not found or is not ready to send, a document header a flow cannot fill, or no template chosen. Nothing is queued for that step. Response shapes and status codes are unchanged.
    • changedOn POST /api/v1/events/send and incoming app callbacks, a flow line that queued a message and then had a later step refused now reads queued with that message's id, as the callback already did; the refusal stays in the flow's run history. A line whose queue publication failed reads not_sent and keeps the ids of every message row it made.
    • changedPOST /api/v1/messages with a template_id answers 500 internal_error when the template could not be read, where it answered 404 not_found. A template that does not exist is still 404 not_found. Retry the 500; fix the template_id on a 404.
    • changedPOST /api/v1/events/send accepts international and South African national phone forms, including spaces and punctuation, using the same normalizer as app callbacks. It never creates a contact or grants consent. Live responses expose outcome.kind and per-flow outcome.outcomes with flowId, channel, kind, reason and messageIds: queued means rows were queued, not provider delivery; not_sent distinguishes refusals or failed actions, no_flow means no listener, and ran means non-send actions. Free-text WhatsApp still requires an open 24-hour window or an approved template. Evaluations return 200 with outcome: null and queue nothing. The OpenAPI document and generated SDK now describe both responses; npm publishing is separate.
    • fixedAn integration that only receives events (one created to take an app's callbacks, with no address of its own) is no longer treated as a sender. POST /api/v1/integrations/{id}/test refuses it with 409 conflict and details.reason "receive_only" before anything is sent; it used to post to the placeholder address and record the failure in its health, turning a working integration to degraded. PATCH /api/v1/integrations/{id} refuses field_map, response_map, method, headers, timeout_ms, auth_secret or auth_header_name on it the same way, writing nothing; renaming it still works, and setting a real endpoint_url makes it two-way. Send a sample event from the app to the integration's callback URL to test it.
    • fixedPATCH /api/v1/webhooks/{id} resuming a paused webhook resets consecutive_failures and clears its auto-pause stamps in the same organization-filtered update. If the endpoint still fails, another 15 consecutive permanent failures pause it and raise a new alert. Setting an already-active webhook active does not clear its failure streak.
    • addedPOST /api/v1/journeys/{id}/enroll adds unavailable_contact_ids to successful live and sandbox responses. It lists requested ids that were not eligible, deduplicated in request order, without revealing whether a contact exists or why it was unavailable. Enrollment, failed and skipped counts are unchanged; the list is empty when every requested contact is eligible.
    • changedPOST /api/v1/journeys/{id}/enroll restores per-row outcomes for organization-wide keys: missing, deleted, foreign-organization or other-business contacts count as failed, while eligible contacts are enrolled. Sandbox keys report the same eligibility counts without inserting or sending. Business-restricted keys retain their authority checks; unavailable contacts still refuse the request before any enrollment.
    • changedSuppression webhook notifications now resolve contacts in the suppression row's own business, even for an organization-wide caller. Copies of the same identifier in other businesses no longer receive contact notifications from that row. Integrations that need changes in several businesses should maintain the suppression records in each business explicitly; organization-wide suppression records retain their organization-wide reach.
    • changedPOST /api/v1/messages names the offending field when the body does not fit the schema, as its next_steps already promised: "Invalid message: `to` — …" where it said "Invalid request body". The code (400 validation_failed) and the details issues list are unchanged.
    • changedchannel_unavailable no longer suggests a sandbox key. On messages, templates and events/send a test key is sandboxed before the channel check, so it never sees this refusal; on campaigns, flow publish and the SMS platform checks a test key is refused the same way. Either way a caller who sees channel_unavailable gains nothing by switching keys. Campaigns, events/send, templates and flow publish now answer it with the channel's own connect_channel step and the URL of its setup page, as messages already did; WhatsApp keeps the connect-session endpoint.
    • fixedGET /api/v1/readiness links each missing channel to a setup page that exists (/whatsapp/channel, /sms/channel, /email/settings), where it linked /whatsapp/setup, /sms/setup and /email/setup, which answered 404.
    • changedEvery example id of a Blueticked resource in the reference, the webhook payloads and the OpenAPI document is now a real UUID (request ids are req_ and 32 hex digits, as the API sends them), so id fields carry format: uuid. Ids from another system keep their own shape: a WhatsApp wamid, a Meta flow or template id. Validate our ids as UUIDs, never by a prefix.
    • fixedAn Idempotency-Key answer now belongs to the request that got it: a 409 idempotency_conflict, a 409 idempotency_in_progress or a 400 for a malformed key names that request's request_id (it named a fresh one nobody could look up), and a replayed response appears in the request log with its real status, marked as a replay of the original request. A replay now carries an x-request-id header naming the original request, the same id its body carries. Both used to show there as "HTTP 500 · unfinalized". A request whose answer the log never saw now reads "no reply recorded" with no status, never a 500.
    • changedBusiness-restricted API keys (allowed_business_ids set) now work on journeys (list, get, enroll), AI agents (list, get, pause), flows (list, create, get, pause, delete, and the recipe catalogue), form submissions (list, mark read), suppression (list, add, remove), conversions and sites (list, create, publish, unpublish). Every read and write is filtered to the key's businesses; a row in another business reads as absent (404 not_found), and a business_id you name that the key does not hold is refused with 403 forbidden. On the site endpoints this replaces the earlier 403 for a site outside the key's businesses. An organization-wide key is unchanged. Wallet, invoices, webhooks and business provisioning stay organization-only.
    • addedGET /api/v1/journeys accepts ?business_id= to list one business's journeys. A business-restricted key may only name a business it holds; a malformed id is a 400 validation_failed.
    • changedPOST /api/v1/journeys/{id}/enroll now checks every contact before anything is enrolled: a contact that is missing, deleted, or not in the journey's business answers 403 forbidden and nothing is enrolled, where it was previously attempted and counted under failed. A business-restricted key must also pass contact ids that are UUIDs; duplicates are enrolled once.
    • changedPOST /api/v1/conversions bounds the request body at 32 KiB and answers 413 payload_too_large above it; a body that is not JSON answers 400 validation_failed. A business-restricted key must pass contact_id or contact_email, and an email that matches contacts in more than one of its businesses must be disambiguated with contact_id.
    • changedPATCH /api/v1/automations/{id} answers 409 conflict when the flow was edited, moved or deleted between the read and the pause write, where it previously answered 500. Nothing is overwritten in that case; read the flow again and retry.
    • changedGET /api/v1/journeys/{id} documents optional messageType on raw email send steps: marketing or transactional. Existing legacy steps without it remain marketing; transactional identifies a service message, not permission to bypass opt-out or suppression. The generated SDK now includes the field in its response guidance and example.
    • addedThe inbound callback POST /api/integrations/inbound/{id}/{token} now answers 202 with an outcome object when the connection has flows that name it: outcome.kind is one of not_sent, no_contact, no_flow, withheld, queued, already_sent or ran, and outcome.outcomes carries one entry per flow that listened, each with its flow id, channel, kind, a reason in plain words and the ids of any messages queued. The worst kind across the entries is the summary. automation_handled keeps its meaning (a flow matched) and is true whenever there is at least one entry. Connections with no flow naming them answer exactly as before and carry no outcome key, so existing senders are unaffected. Treat queued as job acceptance, not provider delivery; read the delivery log for the eventual result.
    • breakingPATCH /api/v1/integrations/{id} now refuses invalid inbound_field_map values with 400 invalid_field_map instead of storing them unchecked. Resolve ambiguous display names to stable custom-field keys; each contact.phone or contact.email target may appear only once. Valid display-name mappings are saved with canonical keys. Read the error reason, correct the map and retry. The generated SDK includes the new error code.
    • breakingPOST /api/integrations/{id}/deliveries/{deliveryId}/retry now returns 202 with delivery_id, accepted, queued and recovery_pending, not a synchronous 200 provider result. Treat queued as job acceptance, not provider delivery; read the delivery log for the eventual result. A queue outage leaves the original event pending and recovery_pending=1 for worker recovery. Only failed original outbound events can be retried; resume an inactive integration first. Replays preserve the original delivery identity rather than creating child rows. Dashboard session authentication and business-manager permission are required; this is not an API-key route.
    • addedPOST /api/integrations/{id}/deliveries/retry-missed accepts at most 25 failed original outbound events per request and returns 202 with accepted, queued, recovery_pending and remaining. Repeat while remaining is greater than zero; already pending events are excluded and retain their existing identity. Resume the integration first. This dashboard-session route requires business-manager permission, not an API key.
    • changedInbox customer-wait clocks are now cleared by a person or AI-agent reply, not a flow. Actor-less API-key replies currently leave the wait running; support integrations should not assume sending via /api/v1/messages removes the conversation from Waiting. API-key actor attribution is tracked separately.
    • addedGET /api/v1/tickets/{id}/comments (tickets:read) returns the ticket's private notes and lifecycle events, newest first, with limit (1-200), has_more and next_cursor. Business-scoped keys can read only tickets in their allowed businesses. The TypeScript SDK gains bt.tickets.comments in its next npm release; the published 0.7.0 does not have it.
    • changedTicket and ticket-comment lists now return opaque next_cursor tokens containing the timestamp and id so equal timestamps do not skip rows. Pass next_cursor back unchanged. Existing timestamp cursors remain accepted for compatibility; use the returned opaque token for tie-safe pagination.
    • addedTables are available to integrations: GET /api/v1/tables and GET /api/v1/tables/{id} read definitions, including column keys and statuses. GET and POST /api/v1/tables/{id}/rows list and create rows; GET and PATCH /api/v1/tables/{id}/rows/{rowId} read and update one row. DELETE /api/v1/tables/{id}/rows/{rowId} removes a reference-table row; records-table rows cannot be deleted. Use tables:read or tables:write as appropriate; tables themselves are created in the dashboard. Business-restricted keys only reach their permitted businesses. The table_row.created and table_row.status_changed webhook events let your app follow newly added rows and status changes.
    • addedGET /api/v1/commerce/products/{id}/sales (commerce:read) reports a product's paid order lines over the last 30 days, with total units, revenue_cents and the 20 most recent lines. Amounts are integer cents. A product outside the workspace answers 404; a failed read is an error, not a zero-sales result.
    • fixedA blu_test_ key on POST /api/v1/ai-agents/{id}/chat returns a simulated sandbox reply: no model runs and nothing is charged. POST /api/v1/journeys/{id}/enroll returns simulated counts without inserting enrolments or queueing real messages. POST /api/v1/commerce/payment-links refuses a test key with 403 rather than creating a real order and payment transaction; there is no simulated payment link. Live-key behavior is unchanged.
    • fixedPOST /api/v1/tickets resolves business_id from the conversation, then contact, an explicit business_id or the key's single business grant. IDs are checked against the caller's workspace; a ticket that cannot be placed in an authorized business is refused rather than becoming visible only to administrators.
    • addedTicket responses include nullable team_id and sla_breached_at. PATCH /api/v1/tickets/{id} accepts team_id and validates that assigned_to is an active workspace member who can access the ticket's business, and that the team belongs to the workspace. Invalid assignments are refused rather than storing inaccessible assignees.
    • addedGET and POST /api/v1/tickets list and raise tickets; GET and PATCH /api/v1/tickets/{id} read a ticket and update its status, assignee, priority or title. Use tickets:read or tickets:write as appropriate. Tickets belong to the key's workspace and permitted businesses; creation and updates run the same lifecycle effects as the dashboard. New ticket.assigned, ticket.updated and ticket.reopened webhook events distinguish assignment, field changes and reopening from ticket creation and resolution.
    • addedGET /api/v1/wallet/invoices lists the workspace's tax invoices. GET /api/v1/wallet/invoices/{id} returns the full document: seller and buyer snapshots, line items, totals and VAT split, so your app can render or archive it. Both require wallet:read and an organization-wide key; business-restricted keys cannot use these organization-level billing endpoints.
    • fixedSDK 0.7.0 is a security release; please upgrade. In SDK versions up to 0.6.1 the client object's configuration was enumerable, so passing the client through JSON.stringify, a logger or any generic serializer could expose your live API key. 0.7.0 makes the credential non-enumerable, and every earlier version is deprecated on npm. Upgrade with npm install @blueticked/sdk@latest (no code changes needed), and if a client object may have reached your logs, rotate the key in Developers, API keys.
    • fixedPOST /api/v1/ai-agents/{id}/chat honors the platform's AI runtime switch. When disabled it returns 503 service_unavailable before loading the agent, running a model or charging the wallet. The chat handoff endpoint stays available because it runs no model and must not lose a visitor's escalation.
    • addedThe ticket.created and ticket.resolved webhook events announce when a ticket is raised and resolved. Tickets can be raised by a flow and worked in Control Centre; your integration can subscribe to these events rather than polling for lifecycle changes.
    • fixedPublic API errors use a consistent error envelope across campaigns, messages, readiness and commerce. SDK return types and pagination now match the documented responses; an SDK update is needed to consume those corrected types. Conflict codes distinguish a request collision from resources that have dependents.
    • addedNew error code: `conflict`, returned as 409 when a request collides with what already exists — a product code or discount code that is taken, two options sharing a name, an order that already has a waybill, a courier booking on an order that is not paid yet. It is the counterpart to resource_in_use, which keeps its own narrower meaning: other live resources DEPEND on the one you are touching, and error.details lists them. conflict has no dependents to list, so its remedy says what is actually true — next_steps[0] is { action: "resolve_conflict", by: "caller" }: send a different value, or bring the resource into the required state, then retry. No person has to intervene, and retrying the same body will fail the same way.
    • changedThe commerce endpoints (products, discounts, delivery zones, orders, fulfilment, courier bookings) now answer their 409s with code `conflict` instead of `resource_in_use`. The status, the message and everything else about the response are unchanged. This is a correction: all eleven of those refusals are uniqueness or state collisions, so the resource_in_use remedy was telling callers to "remove or repoint" dependents from an error.details list that commerce never sends. If you branch on error.code for these endpoints, add a conflict arm; resource_in_use continues to mean exactly what it always did everywhere else, including DELETE /api/v1/groups/{id} and DELETE /api/v1/automations/{id}.
    • changedReference baseline: these existing API resources and webhook events were already available by 19 August 2026. This records historical coverage, not newly released endpoints. Later additions and caller-visible changes have their own dated entries.
    • addedPOST /api/v1/ai-agents/{id}/chat/handoff (ai_agents:chat) — the escalation door. When the agent can't help (or the visitor types "agent" in the widget), the visitor leaves their details and becomes a LEAD in your Leads pipeline: source agent_chat, transcript attached, contact deduped and consent-ledgered. consent must be literally true and at least one of phone/email present, or nothing is stored — this is the only place in the agent channel where anything personal is written. Nothing is metered here. A paused agent still hands off; only drafts 404. The widget ships with the whole flow built in: inline form, ticked checkbox, confirmation.
    • addedPublishable keys + the embeddable chat widget. Developers → API keys → New widget key mints a blu_pub_ credential that is safe to ship in page source: it can do exactly one thing (POST /api/v1/ai-agents/{id}/chat), and only from the exact origins you list — anywhere else, and on every other endpoint, it answers 403. Scopes are server-fixed to ai_agents:chat; caller-supplied scopes are ignored the way agent profiles ignore them. Drop the snippet from the mint dialog before </body> and your live agent answers on your own site: <script src="https://app.blueticked.com/widget.js" data-agent="..." data-key="blu_pub_..." async></script>. The widget carries the conversation in the visitor's tab (the API stays stateless), shows the agent's AI disclosure on first reply, and suggests a human when the reply scores below the agent's confidence threshold. CORS is answered only on the chat endpoint and echoes the matched origin — never *.
    • addedPOST /api/v1/ai-agents/{id}/chat (ai_agents:chat, a new send-class scope) — your workspace's live AI agent as a headless channel, so you can build a chatbot on your own site or app that answers from the agent's knowledge and persona. Stateless by design: you carry the history (max 12 turns), nothing your end user types is stored, and session_id survives only as a one-way hash in the billing ledger. Each reply is metered from the prepaid wallet at measured cost (ai.api_chat, priced as the public site chat) behind a shared R25/day workspace ceiling — 402 spend_cap_reached when it's spent, 402 insufficient_balance when the wallet is. The response's handoff_recommended flag applies the agent's own confidence threshold, the same rule the autonomy ladder uses on WhatsApp; a paused or draft agent answers enable_requires_dashboard because switching on an autonomous responder stays a human act.
    • addedDELETE /api/v1/automations/{id} (automations:write) — an integration can finally tidy up after itself instead of leaving half-wired copies behind on every failed run. Two limits, both deliberate: a key may only delete flows THAT KEY created (anything else answers not_found, exactly as an unknown id would — whether another integration owns something in the workspace is not your key's business), and only a PAUSED flow can go. Deleting a live one returns 409 resource_in_use with the remedy: PATCH { enabled: false } first, so customer replies never go quiet as a side effect of a cleanup script. Past runs stay on record.
    • addedPOST /api/v1/automations with recipe_id now accepts api_events: ["your.event"] to replace the recipe's listening list — provisioning a correctly-wired paused flow per moment is one call. The OpenAPI request schema also now documents the full contract (recipe_id OR a free-form definition, as a oneOf): the free-form door always existed, but only the notes said so, and an integrator reading the schema rightly concluded it didn't.
    • addedConnected apps now have a review surface. Every resource created over the API — flows, templates, webhook subscriptions — records the credential that created it, and the customer's dashboard (Developers → Agents & connected apps) shows each app's whole starter kit in one place: what arrived, its status, and a Review & enable link per flow. The draft-only invariant is unchanged — API-created flows still always arrive paused; this makes the human review step one click instead of a hunt across three modules.
    • addedPartner deep links into the flow builder are now a documented contract: /automations/builder?recipe=api-event-follow-up&event=<name>&channel=<whatsapp|sms|email> drops a signed-in customer into the builder with your event pre-wired as the trigger — one click from a moment in YOUR product to a configured flow in Blueticked.
    • breakingPOST /api/v1/events/send: `channel` no longer silently defaults to email — and is not required either. Omit it and your workspace's own live flows decide: the one channel whose flow matches the event AND can reach the contact fires, and the response's channel field says which. When flows on more than one channel qualify, the request fails with validation_failed asking for an explicit channel. Migration: if you relied on the old silent email default, pass channel: "email". Full disclosure: on 2026-08-09 we briefly shipped channel as REQUIRED without an entry here, which broke a working integration mid-flight. That was two mistakes — the silent default and the unflagged fix — and this entry is the correction for both: contract-affecting changes now always land here, marked breaking, in the same deploy.
    • addedA blu_test_ key can now create WhatsApp templates with NO channel connected — the same promise the sandbox makes for sends. Sandbox templates are born approved (the simulator stands in for Meta) with external_id: null and submitted_to_meta: false in the response, and are immediately usable in test sends, so the whole template lifecycle rehearses without touching a Meta asset. A live send that references a sandbox-born template is refused with the actual reason — recreate it with a live key — instead of a confusing Meta template-not-found.
    • addedmessage.received webhooks now say what non-text content arrived: a media field carrying {type, filename}, or null for a text-only message. An empty body with media set is content, not an empty message. The webhook deliberately carries no media URLs — fetch content through the conversations API.
    • changedThe OpenAPI spec for POST /api/v1/events/send now machine-encodes "exactly one of contact_id, email or phone" as a oneOf of required-clauses, so generated clients and validators enforce what the description text always said.
    • fixedPOST /api/v1/events/send dry runs (test_mode: true, or any blu_test_ key) no longer require a connected channel. An evaluation sends nothing, so refusing it with channel_unavailable contradicted the sandbox promise; live requests on a disconnected channel still fail fast with the connect-link remedy.
    • addedThe events/send channel-ambiguity refusal is now machine-recoverable — the one validation error a caller is expected to handle in code no longer requires parsing the sentence. error.details[0] carries { path: ["channel"], candidates: [...] } (the channels whose live flows match the event and can reach the contact) and error.next_steps[0].action is choose_channel: branch on it, pick a candidate, retry with channel set.
    • fixedPOST /api/v1/messages dry runs (test_mode: true) on a blu_test_ key no longer demand a connected channel that the same key's real send doesn't need — the rehearsal was stricter than the thing it rehearses, 503ing where the actual call 202'd. A dry run now mirrors exactly what that key's real send would require, so a live key's dry run still needs the channel.
    • addedPOST /api/v1/webhooks/{id}/test accepts media: true (message.received on whatsapp only) to deliver the media-bearing sample — a photo with no caption: body "" with media {type: "image", filename: null}. The dashboard's Send-a-test-delivery block offers the same as a checkbox. This was the one inbound shape an integrator could not rehearse over the wire without a real handset.
    • addedOAuth access tokens now work on the whole REST API, not only the MCP endpoint. A browser-approved connection — an MCP client, or a third-party app offering a Connect Blueticked button — can call any /api/v1 endpoint with its bto_at_ bearer; the delegated key's scopes, business restrictions, rate limits and audit apply unchanged. This widened nothing: the MCP call_api tool already reached every endpoint with the same delegated key. It is what lets an app integrate on a consented, revocable, auto-refreshing grant instead of a copy-pasted key.
    • changedEvery MCP tool now declares a human title alongside its read-only/destructive annotations, shown by clients in permission prompts and tool pickers.
    • addedPOST /api/v1/businesses onboards one of YOUR customers over the API — the step that previously required a human clicking Add business in our dashboard, once per customer. Pass create_key to mint that customer's credential in the same call: the key is pinned to that one business, can only hold scopes your key holds, never carries businesses:write (so a leaked per-customer key cannot mint others), and inherits your key's environment (a blu_test_ key mints only blu_test_ keys). GET /api/v1/businesses lists your customers; POST /api/v1/businesses/{id}/keys mints for an existing one — with expires_in_days and a monthly_spend_cap_rands per-customer guardrail against your shared wallet. Requires the new businesses:write scope on an organization-wide key. @blueticked/sdk 0.6.0 ships bt.businesses.create / createKey / list. The whole onboarding is now machine-driven: create the business, mint its key, send its owner a connect link, wait for channel.connected.
    • changedGET /api/v1/readiness is now answered per business. Channels belong to a business, so the old organization-wide answer told a platform 'whatsapp: true' for every customer as soon as ANY one had connected. Pass ?business_id= to ask about one customer; a business-restricted key answers for its own automatically; the response's new scope object says which business the answer covers — null means the whole organization. bt.workspace.getReadiness({ business_id }) in the SDK.
    • addedmessage.sent, message.delivered, message.read and message.failed webhooks now carry business_id, so a platform receiving one workspace-wide stream can route each receipt to its customer without calling back. Null on messages that predate attribution. GET /api/v1/messages/{id} returns business_id too. The worker-dispatched events also gained the status and occurred_at fields the documented shape always declared.
    • changedTemplates now work with business-restricted keys — the per-customer credential we recommend could send a WhatsApp template but was 403'd from listing or creating one. Listing narrows to the key's businesses; creating resolves the channel within them and files the template under that channel's business rather than the workspace default.
    • addedEvery error now carries error.next_steps — the remedy, as data. Each step has a stable `action` to branch on and a `by` of `caller`, `human` or `waiting`. `human` is the one that matters: a person must act in a browser, so retrying cannot succeed however the request is rewritten. The classic case is 503 channel_unavailable — nobody has connected WhatsApp, so a retry loop just fails identically until it gives up. @blueticked/sdk 0.5.0 acts on this: err.needsHuman and err.isTransient, and a 5xx whose remedy needs a person now throws immediately instead of consuming the retry budget.
    • addedSandbox keys can rehearse failures: send `X-Simulate-Error: channel_unavailable` (or any of fourteen codes) and the response is the real one — real status, real code, real next_steps — with error.details.simulated set to true so a test can assert it fired. Error handling was previously the one part of an integration you could not test before production, because a sandbox key never sees a missing channel or an empty wallet by design. Ask for an unrecognised code and the error lists what you may request. Live keys refuse the header rather than ignoring it: a suite that appeared to pass against a live key would prove nothing.
    • changedMCP tools/list now publishes each tool's route and required scope in _meta, so a client can decide what it may call before calling it rather than discovering a 403. Derived from the same reference the API enforces against, so it cannot drift.
    • addedPOST /api/v1/channels/connect-sessions mints a single-use link that lets YOUR customer connect their own WhatsApp Business account, with no Blueticked login. Connecting WhatsApp is a human act performed by the business owner inside Meta's Embedded Signup and no API key can do it for them, so this turns the step that stalls most integrations into a link you send. The link expires (default 24h, 5 minutes to 7 days), is single-use, and authorises exactly one thing: attaching a WhatsApp channel to your workspace — it cannot read, send, or write anything else. Pass a `reference` to record which of your customers a link was for. To detect completion, subscribe to the channel.connected webhook (it echoes your reference), or poll GET /api/v1/readiness — can_send.whatsapp flips to true once the connect lands. The url is returned once and is never stored in plaintext.
    • fixedA campaign SCHEDULED with a blu_test_ key could send for real. The route branched on mode before it branched on the key, so the schedule path returned above both sandbox protections — the fan-out that flags recipient rows and the guard that keeps sandbox sends off the live queue — and nothing on the campaign row recorded that a sandbox key created it. Scheduled sandbox campaigns now stay simulated: no provider send, no wallet debit. Immediate sends were never affected.
    • addedGET /api/v1/readiness answers 'why can't I send yet' as data. It returns can_send per channel plus a list of blockers, each with a stable code, the channels it blocks, and whether the fix is a dashboard action, an API call, or waiting on Meta review. Most integrations stall on provisioning rather than code, and this makes that state readable instead of something you infer from a failed send. A sandbox key always reports ready and returns what a live key would still be waiting on as pending_for_live.
    • changedCommerce endpoints now return the documented error envelope — { ok: false, error: { code, message }, request_id } — instead of a plain English sentence under `error`. A client generated from openapi.json could not read the old shape at all. As a side effect, commerce requests made with an API key now close their audit row correctly: every one of them, successful included, was previously recorded in your API request log as a 500.
    • addedNew error code provider_error (502) for an upstream provider refusing a request — Paystack declining a payment link is the live case. Distinct from service_unavailable, because a provider refusal usually needs the request or the connected account changed rather than a retry. The spec also now declares 422 and 502, which some routes already returned.
    • fixedMCP: an explicit null in a tool argument is now a value rather than an omission, so schema-advertised clears (assigned_to: null to unassign a conversation) work. Previously they returned 'Nothing to update' — or worse, combined with another field, returned 200 having applied only part of the write. The OAuth discovery documents and the 401 challenge now advertise offline_access, so a client that echoes the advertised scopes receives a refresh token instead of a connection that dies after an hour.
    • fixedThe spec's test-mode prose said a blu_test_ key returns 200 status:"validated". It returns 202 queued and simulates delivery from there; the 200 comes from test_mode:true in the request body, on any key.
    • addedThe TypeScript SDK is on npm: `npm install @blueticked/sdk`. Typed end to end, zero dependencies, ESM and CommonJS. It now refuses to retry a POST the server does not deduplicate — only the routes that honour Idempotency-Key are retried, and anything else fails fast with `retry_unsafe` rather than risking a duplicate write. `error.details` is surfaced (the error table tells you to read it), and `bt.request()` reaches any endpoint without a typed method yet.
    • addedGET /api/v1/conversations/{id}/messages now returns inbound attachments: has_media, media_type, media_mime, media_filename, media_status and media_url. Branch on has_media rather than body — a photo or PDF sent without a caption has an empty body, which was previously indistinguishable from an empty text message. media_status of `failed` is terminal.
    • changedSandbox sends (blu_test_ keys) now work on a workspace with no connected channel. Previously WhatsApp and email sends were refused with channel_unavailable before test mode was considered, so a brand-new workspace could not exercise the send path at all. Live keys are unchanged and still require a connected channel.
    • fixedThe `document` field on POST /api/v1/messages now has a published schema (url, filename, 240-char limit) instead of appearing as an untyped object in the OpenAPI document. Its URL only needs to be fetchable by WhatsApp once at send time — a short-lived pre-signed URL is the right shape for confidential documents; earlier docs wrongly said it had to be publicly fetchable.
    • fixedForm post-submit actions and saved integrations sign `HMAC-SHA256(timestamp + '.' + rawBody)` and send an x-blueticked-timestamp header. The reference previously documented the body alone, so a receiver built from it rejected every delivery. Subscription webhooks (Developers → Webhooks) do sign the body alone — both use the same signature header, so verify against the scheme for the surface you subscribed to.
    • addedJourneys: GET /api/v1/journeys and /api/v1/journeys/{id} read your multi-step sequences; POST /api/v1/journeys/{id}/enroll puts contacts onto an active one. Enrolment needs the new journeys:enroll scope, which is send-class — a journey messages people over days, so it sits outside the agent Prepare profile. Only an active journey accepts enrolments; a draft or paused one is refused with journey_not_active.
    • addedAI agents: GET /api/v1/ai-agents and /api/v1/ai-agents/{id} read personas, autonomy mode and channel bindings (ai_agents:read). PATCH /api/v1/ai-agents/{id} can only PAUSE one (ai_agents:write) — enabling or configuring an agent is refused with enable_requires_dashboard, because switching on something that answers customers unattended should have a human looking at it.
    • addedForm submissions: GET /api/v1/form-submissions lists what visitors sent through your site forms, with ?unread=true for an intake queue, and PATCH /api/v1/form-submissions/{id} marks one read once your system has stored it (forms:read / forms:write). Visitor IP and user-agent are deliberately not returned.
    • addedSites: GET /api/v1/sites now resolves verified custom domains, and POST /api/v1/sites creates a site from a template. A created site is always an unpublished DRAFT — publishing still needs the separate sites:publish scope, so a key can build sites without ever putting one in front of the public.
    • addedAutomations: GET/POST /api/v1/automations, GET /api/v1/automations/recipes and PATCH /api/v1/automations/{id} (automations:read / automations:write). Creation is draft-only by design — a new flow always arrives paused whatever you send, and activation_requires_dashboard is returned if you try to enable one.
    • addedCommerce configuration over the API: payment links, fulfilment settings and discount codes, alongside the existing product and order endpoints.
    • addedPer-key monthly spend caps. Set monthly_spend_cap_rands when creating a key and billable sends attributed to it stop at that ceiling with 402 spend_cap_reached — a deliberate stop, not a retryable error, and it leaves the workspace wallet untouched. Intended as the guardrail before handing a credential to an unattended agent.
    • changedThe TypeScript SDK now covers every documented endpoint, including the journeys, AI-agent, site and form-submission additions above. (At the time of this entry it was not yet on npm; it published as @blueticked/sdk on 2026-08-08 — see the entry above.)
    • fixedspend_cap_reached previously surfaced as 400 validation_failed, which read to a caller as a malformed request rather than a deliberate stop. It now returns its documented 402 with its own code. button_params_mismatch was likewise being returned without appearing in the published error catalogue; it is now documented.
    • addedDocumentation for the TypeScript SDK, the MCP server and the agent-readable API documents, under Tools in the docs.
    • addedAgent-readable documentation at /llms.txt and /llms-full.txt. The full document carries the entire contract — auth, every endpoint, every scope, every error code and the webhook rules — as one plain-text fetch, for pointing a coding agent at. Both are public, need no key, and are generated from the same source as the OpenAPI document.
    • addedMCP server at /api/mcp (Model Context Protocol, Streamable HTTP). Claude Code and Codex can now connect through browser-approved OAuth without copying a key. The owner chooses organization, businesses, safety profile, Test/Live mode and expiry; every tool remains bound by the delegated API scopes and rate limits.
    • fixedSome documentation and changelog prose dropped the /api prefix when naming endpoints. Every path has always been /api/v1/… — only the wording was wrong, no behaviour changed.
    • addedNew least-privilege send scopes whatsapp:send and sms:send, matching the existing email:send. messages:send stays the umbrella (all channels); a per-channel scope authorises just one, so you can mint a WhatsApp-only or SMS-only key.
    • changedPOST /api/v1/campaigns now requires authorisation to send on the campaign's channel — the umbrella messages:send or the channel's send scope (whatsapp:send / sms:send / email:send) — in addition to campaigns:write. A key that only holds campaigns:write can no longer queue sends. Grant messages:send (or the specific channel scope) to keys that run campaigns; full-access keys are unaffected.
    • addedEvery authenticated response now returns x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset headers, so you can pace before hitting a 429 (previously these were only on the 429 itself).
    • changedRotating an API key now keeps the previous secret valid for a 24-hour grace window, so a mid-deploy integration is never cut off. The rotate response includes previous_key_expires_at.
    • addedNew endpoints: GET /api/v1/wallet/ledger, DELETE /api/v1/contacts/{id}, POST /api/v1/contacts/{id}/erase (POPIA erasure), GET/PATCH/DELETE /api/v1/groups/{id}, and PUT/DELETE /api/v1/groups/{id}/members.
    • changedSandbox keys (blu_test_) no longer submit templates or publish Flows to Meta — POST /api/v1/templates and POST /api/v1/flows/{id}/publish return a simulated result flagged test_mode instead of touching your live WhatsApp assets.
    • fixedList endpoints (campaigns, templates, conversations) now return an error on a database failure instead of an empty page, so a mirror-syncing integration can't mistake a transient outage for 'zero rows'.
    • fixedFlow events (POST /api/v1/events/send) now match a contact's email exactly, closing a case where an address containing % or _ could resolve the wrong contact.
    • addedThe published OpenAPI spec now documents real success statuses (201/202), the full error-code set, query parameters, and per-event webhook payload schemas.
    • fixedThe marketing-email documentation example is now a valid request (marketing sends cannot use cc/bcc — send one message per recipient).