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.
- changed The message.received webhook now fires for email too. An endpoint subscribed to message.received will start receiving events with
channel"email" once your business receives email in Blueticked: a customer's reply to your receiving address, mail forwarded from your info@ address, or a reply to a flow's question. Each fires once, when the email reaches the Inbox; auto-replies, bulk mail and bounces fire nothing. On email,fromis the sender's bare address,bodyis the text (or the subject when there is none), and two new fields say the rest:email_subject, andemail_fromwith the sender's display name ("Sam Mokoena <sam@example.com>"). Every message.received event carries both fields; they are null on WhatsApp, SMS and Telegram, so nothing changes for those. Attachments are not in the event. POST /api/v1/webhooks/{id}/test takeschannel"email" to send the email sample. If your endpoint assumes every inbound message comes from a phone, checkchannelfirst.
- changed The message.received webhook now fires for email too. An endpoint subscribed to message.received will start receiving events with
- added POST /api/v1/integrations/{id}/actions and PATCH /api/v1/integrations/{id}/actions/{actionId}: an action can carry lookup, a first call that finds the person asking before the action runs. Its path and query may read only {{contact.email}} or {{contact.phone}}, which the engine fills from the number or address the person messages from (a WhatsApp or SMS conversation proves a phone; an answer to an email the flow asked proves an email), never from a step's values. id_from names the reply field holding their id, and the action reads it as {{lookup.id}}, which it must use. Optional unique_from and match require exactly one match whose field is the person's own; optional filter and vary keep only list items whose field is the person's own, trying the phone in e164, national and digits forms, and such an action may read only {{lookup.id}} and the identity tokens and bring back only status fields. A lookup that breaks a rule answers 400 validation_failed with details.reason action_invalid. GET answers now include lookup (or null) and bound_tokens, the identity tokens the action reads. POST /api/v1/integrations/{id}/actions/{actionId}/test takes test_contact (email, phone): test values for an action that reads the person's identity, for that run only; without it such an action is refused.
- added Integrations and integration actions: a
response_mapitem can count a list. Add"count": trueand the value is the length of the list atsource(an order'sresponse.line_items, for example), 0 for an empty list. A value that is not a list counts as missing, so the item'sfallbackorrequiredapplies as for any other value. Accepted on POST and PATCH /api/v1/integrations and /api/v1/integrations/{id}/actions, returned on their reads, and ignored onfield_map. Items withoutcountare unchanged.
- added Integrations and integration actions: a
- changed Plan limits while the plan cannot be read: when Blueticked cannot read a workspace's plan for a moment (a database hiccup, or a malformed subscription), its limits are now the tier's baseline (Starter's when the tier is unknown) instead of no limit. During such a moment a call over the baseline answers 403 plan_limit_reached with details.degraded true, and the workspace's rate limit is the baseline's, so a call can answer 429; send the same request again shortly. A key created during such a moment keeps its own rate limit.
- changed POST /api/v1/campaigns: a campaign started with a key that has a monthly spend cap is checked against that cap, and its spend now counts toward it. A campaign sent now is checked before any message is written, with its estimated cost for the final audience; a scheduled campaign is checked when it is created, with the estimate for its audience then. When the key's spend this month plus that estimate would pass the cap, nothing is sent or saved and the call answers 402 spend_cap_reached, as a send at the cap does; raise or remove the cap, or send to fewer people. When the cap or the spend cannot be checked, it answers 503 service_unavailable, reason
spend_cap_unavailable; send the same request again in a moment. A key with no cap is unaffected.
- changed POST /api/v1/campaigns: a campaign started with a key that has a monthly spend cap is checked against that cap, and its spend now counts toward it. A campaign sent now is checked before any message is written, with its estimated cost for the final audience; a scheduled campaign is checked when it is created, with the estimate for its audience then. When the key's spend this month plus that estimate would pass the cap, nothing is sent or saved and the call answers 402 spend_cap_reached, as a send at the cap does; raise or remove the cap, or send to fewer people. When the cap or the spend cannot be checked, it answers 503 service_unavailable, reason
- changed POST /api/v1/messages: a key with a monthly spend cap never sends past it because its spend could not be read. When the key's cap or its spend this month cannot be checked, nothing is sent and the call answers 503 service_unavailable, reason
spend_cap_unavailable; send the same request again in a moment. Before, such a send went through unchecked. A key with no cap is unaffected.
- changed POST /api/v1/messages: a key with a monthly spend cap never sends past it because its spend could not be read. When the key's cap or its spend this month cannot be checked, nothing is sent and the call answers 503 service_unavailable, reason
- added POST /api/v1/studio-flows and PATCH /api/v1/studio-flows/{id}: scoped email choice questions (ask_buttons) can be saved and compiled once receiving is enabled by Blueticked's default-off readiness switch. Until then email questions remain unavailable. The customer replies with the choice's number or words; quoted history never supplies the answer. A private reply address, its pinned sender and the exact question identify the parked wait, so a reply cannot answer a newer question or another flow. Typed questions (ask_text), form questions (ask_form) and buttons without a parked question remain unavailable on email. WhatsApp and SMS question behaviour is unchanged.
- breaking POST /api/v1/commerce/orders/{id}/book-courier (session-only): a booking with service_code now requires confirmed_amount_cents, the positive integer amount from that service's quote; a missing or invalid amount returns 400. Migrate by requesting a quote without service_code, showing its price, then sending both fields after confirmation. The quote response adds default_service_code, the service stored at checkout when still offered, or null. The server quotes the chosen service again before booking: a changed price returns 409 with details.rate_changed true and details.rate holding the new quote. Show that price and ask the owner to confirm again before submitting it with a new Idempotency-Key. An unavailable service returns 409: request a new quote. No booking or key claim occurs on either refusal.
- changed GET /api/v1/wallet/invoices and GET /api/v1/wallet/invoices/{id}: invoice_label for the new Blueticked number range uses a hyphen, for example BT-INV-10001. Issued legacy invoices keep labels such as INV 000018503. Treat invoice_label as display text; use id as the stable invoice identifier. The invoice_number field is unchanged.
- fixed POST /api/v1/messages: a message your integration sends to a customer who is waiting now counts as your team's reply, as one from a teammate or the AI agent does, so the conversation stops showing the customer as waiting in the Inbox. The message records the API key that sent it. Messages the platform sends on its own, such as consent requests and confirmation emails, still do not count as a reply.
- fixed GET /api/v1/commerce/products/{id}/sales:
revenue_centsis now what the lines were charged, after the discount each line was given at checkout; before, it summed quantity ×unit_price_centsand overstated revenue whenever a discount applied. Usegross_centsfor the old figure (the same lines before any discount). Each line inlinesalso returnsdiscount_cents(0 when none) andtotal_cents(what that line was charged);unit_price_centsis unchanged. A line from before per-line discounts has no allocation, so it counts at its full value in both figures. - added GET /api/v1/wallet/invoices and GET /api/v1/wallet/invoices/{id} return
invoice_label, the invoice number exactly as the document prints it: INV 000018512 for earlier invoices, BT-INV 10001 for invoices in the new BT-INV series. Showinvoice_labelinstead of formattinginvoice_numberyourself; once BT-INV numbering starts the two series overlap, soinvoice_numberalone no longer identifies an invoice.invoice_numberis unchanged.
- fixed GET /api/v1/commerce/products/{id}/sales:
- changed POST /api/v1/commerce/payment-links: when Paystack does not answer in time (or answers with a 5xx or a duplicate reference), the order now stays
pendinginstead offailed, because Paystack may still hold a payment for it; the answer is the same 502, and no link is handed out. Blueticked checks the order with Paystack: a payment settles it aspaid; otherwise it moves toabandonedwith failure_reasonlink_not_delivered, which now means the order's payment page never reached the customer (a flow's payment link that could not be delivered, or a payment link from this endpoint that Paystack never confirmed). commerce.order.abandoned fires once carrying that reason, and only abandoned-order flows that chose "the payment link never reached them" (or any) start.
- changed POST /api/v1/commerce/payment-links: when Paystack does not answer in time (or answers with a 5xx or a duplicate reference), the order now stays
- breaking Commerce discounts can now be automatic or entered codes, scoped to the basket, selected products or one collection. GET and POST /api/v1/commerce/discounts include name, mode, scope, collection_id, version, starts_at and ends_at. Automatic discounts have code: null: integrations must accept a nullable code and identify these discounts by id. Existing code creation keeps its code/basket defaults and expires_at alias. POST accepts mode, name, scope, product_ids, collection_id, starts_at and ends_at; automatic, scoped and scheduled creation returns 409 promotions_not_ready until the storefront rollout is enabled. The list includes promotions_enabled. PATCH /api/v1/commerce/discounts/{id} accepts optional expected_version and returns version; a stale version returns 409 promotion_version_conflict. Invalid definitions and rule limits return 400 with the documented promotion reason; unavailable targets return 409 promotion_target_missing. Unconfirmed definition reads or writes return 503 promotion_read_failed or promotion_save_failed: read the current discounts before retrying a save.
- changed The legacy expires_at alias still accepts date-times without an offset, interpreted in the business time zone (Africa/Johannesburg by default); explicit offsets keep their instant. Date-only expiry includes the whole local day. An empty ends_at uses expires_at. PATCH and DELETE retain 404 discount_not_found. Eight reference/OpenAPI operation titles were shortened for the existing MCP budget: list/create payment links, get/set fulfilment settings, list/create discounts, pause/resume discount and delete discount. Paths and operation ids are unchanged.
- added Commerce order responses and order webhook payloads retain copied promotion facts in discount, including nullable code, id, name, mode, scope, collection_id, version, goods_discount_cents and delivery_discount_cents. Order items include promotion_line_key, gross_before_cents, discount_cents and gross_after_cents when allocated at checkout. Legacy orders keep their existing discount and item shape. One offer applies per order: an entered code replaces automatic selection; automatic offers choose the lowest final payable amount, including delivery. Later definition edits do not rewrite paid orders.
- breaking POST /api/v1/studio-flows and PATCH /api/v1/studio-flows/{id} now refuse an SMS message with no smsMessageType wherever the flow sends it itself: inside an "Ask and wait for an answer" or form question's lanes and a "Check a detail" step's paths too, not only a button question's branches. To keep saving, set smsMessageType (transactional for an order or service update, marketing for anything promotional) on every SMS message step. The refusal is the one you already handle: 400 validation_failed, details.reason sms_type_required, details.issues naming each step (for example {"path": "steps[0].branches[0].steps[0]", "message": "Choose the message type ..."}). Flow Studio's own save already asked for it.
- added Flow Studio's "Check a detail" step (type check: a rule on a contact field, a tag or an earlier answer, then a Yes or Otherwise path). A save whose check reads a custom field that no longer exists answers 400 validation_failed, reason compile_failed, with the issue "This field no longer exists. Choose another."; if the fields could not be read just then, 503 service_unavailable, reason field_read_failed. Nothing was saved; try again.
- changed A WhatsApp form that a Flow Studio step ("Ask with a form in WhatsApp") made for itself belongs to that step: PATCH /api/v1/flows/{id}, DELETE /api/v1/flows/{id} and POST /api/v1/flows/{id}/publish answer 409 conflict with details.reason managed_by_studio. Change its questions in the step; switching its flow on publishes it. GET still reads it. Every other form is unchanged.
- added POST /api/v1/commerce/products/media accepts JSON prepare and complete requests for images up to 10 MiB. Prepare returns HTTP 200 with a receipt and a private signed upload URL. Upload the exact bytes to upload.signed_url, then complete with that receipt and the same caller within one hour. Complete verifies the stored size, MIME type and SHA-256 digest, checks the image bytes, resizes and removes metadata before returning the permanent public image with HTTP 201.
- breaking Multipart uploads on POST /api/v1/commerce/products/media now state and enforce a 4 MiB limit (4,194,304 bytes), down from the previously documented 10 MB. The hosting platform already refused request bodies over about 4.5 MB, so those larger multipart uploads never worked. For images over 4 MiB and up to 10 MiB, migrate to the JSON prepare, direct upload and complete path. Smaller multipart uploads keep working. The OpenAPI final-image response is corrected from 200 to the HTTP 201 the route already returned.
- breaking POST /api/v1/commerce/products/import accepts optional JSON channel_id, a WhatsApp channel UUID (body limit 4 KiB), and imports only that channel's live cached catalogue. Omitting it keeps working when exactly one connected WhatsApp channel in the workspace has a live catalogue. With none, the request refuses plainly; with multiple eligible channels it now answers 400 validation_failed instead of importing the organisation's newest catalogue. Pass channel_id to select the intended channel when more than one is eligible. Missing, deleted, disconnected, non-WhatsApp or foreign channels cannot supply a catalogue. This shortcut remains session-only for organisation owners/admins; API-key and SDK product methods, price verification and feature gating are unchanged.
- added Manual organisation-wide product collections: GET and POST /api/v1/commerce/collections; GET, PUT and DELETE /api/v1/commerce/collections/{id}. Reads use commerce:read, writes commerce:write. Session writes use managing-role, origin and two-step checks. Collections carry names, descriptions and visibility, with an atomic ordered replacement of up to 1000 distinct same-organisation products. Slugs are unique among live collections, may change on PUT (409 on a live clash), and a deleted collection's slug can be reused at once. Lists have exact counts and page/limit/search controls; deleting a collection preserves products and orders. Invalid fields return 400, unavailable IDs 404, duplicate slugs or unavailable product references 409, and failed reads/writes 503.
- added GET /api/v1/commerce/products accepts optional page, limit (1 to 200, default 24 with paging), literal name/description search up to 200 characters, and ids (up to 60 distinct lowercase UUIDs, comma-separated; default limit is the selected-id count). Paged replies add exact count, page and limit; a request without these parameters retains its existing up to 200 products response. Paging searches the full catalogue, with stable created_at/id order. Repeated or malformed filters return 400. GET /api/v1/commerce/collections/{id}/products pages through active collection products in collection order, with exact counts, page/limit/search and collection_position. Missing, inactive, deleted or foreign collections answer 404; failures answer 503.
- changed PATCH /api/v1/integrations/{id}: an inbound_field_map that names a custom field the workspace does not have yet now creates it only for a workspace-wide API key (not restricted to businesses) that also holds contacts:write, on top of integrations:write. Any other key may map only to existing custom fields, and gets 403
forbiddenwith reasoncustom_field_creation_forbiddennaming the missing fields; nothing is saved. Maps to existing fields are unchanged.
- changed PATCH /api/v1/integrations/{id}: an inbound_field_map that names a custom field the workspace does not have yet now creates it only for a workspace-wide API key (not restricted to businesses) that also holds contacts:write, on top of integrations:write. Any other key may map only to existing custom fields, and gets 403
- added GET /api/v1/suppression can return reason
do_not_contact: the rows the dashboard writes when someone marks a contact Do not contact (channel and topic all). A block your team adds by hand keeps reasonmanual_add, so the two can be told apart.reasonis a string, so typed clients keep compiling; the API does not acceptdo_not_contacton write. Rows written before this keepmanual_add.
- added GET /api/v1/suppression can return reason
- added GET /api/v1/commerce/orders/{id}/messages lists the messages your flows sent because of an order's events, newest first: channel, status, template, failure reason and when it was sent, delivered or failed. No message body or recipient; read a message with GET /api/v1/messages/{id}.
commerce:read.
- added GET /api/v1/commerce/orders/{id}/messages lists the messages your flows sent because of an order's events, newest first: channel, status, template, failure reason and when it was sent, delivered or failed. No message body or recipient; read a message with GET /api/v1/messages/{id}.
- added An integration can call a web system's API through named actions. Give it a base_url (public https, no query or fragment), then add actions with POST /api/v1/integrations/{id}/actions: each is a method (GET, POST, PUT, PATCH or DELETE), a path_template with {{tokens}}, query, headers, a JSON body for writes and a response_map. List, read, update and delete them under /api/v1/integrations/{id}/actions/{actionId}, and run one with sample values with POST /api/v1/integrations/{id}/actions/{actionId}/test. A run's outcome is ok, failed, refused or unconfirmed; an unconfirmed write is never retried.
- added POST and PATCH /api/v1/integrations accept base_url and two more auth_type values: basic (auth_username, with the password as auth_secret) and oauth_client_credentials (auth_token_url, auth_client_id, optional auth_scope and auth_client_auth basic or body, with the client secret as auth_secret). endpoint_url may be left out when base_url is given; it then takes the base_url. Integrations return base_url (null for one with a single endpoint). The password, client secret and OAuth tokens are never returned.
- changed POST /api/v1/templates refuses a URL button that links to the Blueticked dashboard (app.blueticked.com), answering 400 validation_failed with the reason: "That link opens the Blueticked dashboard, which your customer can't sign in to. Link to a page they can open, such as your website or a payment link." WhatsApp opens template buttons in its own browser, where your customer has no Blueticked account. Public pages such as a payment link's receipt (/o/...) and your own website are accepted as before.
- fixed POST /api/v1/templates and Meta setup, phone actions, template edits, imports and library calls now stop after 15 seconds, including reading the response body. A timed-out write says Meta did not confirm the change: check its current status before trying again (WhatsApp Manager for template edits and deletes). No automatic retry is made. Existing HTTP statuses, error codes and response shapes are unchanged.
- fixed POST /api/v1/commerce/orders/{id}/refund keeps the Idempotency-Key when Paystack returns a 5xx, the response cannot be read, or the connection is lost. The 502 answer says the refund is not confirmed: check your Paystack account before trying again. Repeating the same key replays that answer for 24 hours without asking Paystack for another refund. Only a readable 4xx with status false is a confirmed refusal and frees the key. Send an Idempotency-Key for replay protection.
- added POST /api/v1/commerce/products honours the
Idempotency-Keyheader. With a key, a retried request answers the first product for 24 hours instead of making a duplicate on your shop. A repeat while the first is still running answers 409 idempotency_in_progress, and the same key with a different body answers 409 idempotency_conflict. A refusal before the product exists (a duplicate sku, a failed create, a gallery that did not save) frees the key. If the product was created but its options (or its gallery) did not save and it could not be taken back, a retry answers that same error with the product in details.product_id; fix it with PATCH. Requests without the header behave as before. - added POST /api/v1/sites honours the
Idempotency-Keyheader. With a key, a retried request answers the first site for 24 hours instead of making a second one at the next free address and taking another plan slot. A repeat while the first is still running answers 409 idempotency_in_progress, and the same key with a different body answers 409 idempotency_conflict. A refusal before the site exists (an unknown template, a taken slug, a failed create) frees the key. Requests without the header behave as before. - added POST /api/v1/ai-agents/{id}/chat/handoff honours the
Idempotency-Keyheader. With a key, a retried handoff answers the first one for 24 hours instead of filing a second lead for your team. Only the fixed reply and lead_created are kept, never what the visitor typed. A repeat while the first is still running answers 409 idempotency_in_progress, and the same key with a different body answers 409 idempotency_conflict. A refusal frees the key, so send the corrected details with the same one. Browser widgets may send the header: the preflight allows it. Requests without the header behave as before. - added POST /api/v1/businesses/{id}/keys honours the
Idempotency-Keyheader. With a key, a retried request answers the first key for 24 hours instead of minting a second live credential. A replayed answer never repeatskey, which is returned once and never stored; if it was lost, mint a new key with a new Idempotency-Key and revoke the first. A mint that fails frees the key. Requests without the header behave as before. In the SDK, CreatedBusinessKey.key becomes optional: code that reads it as always present will no longer compile, so check it before storing it. - added POST /api/v1/tickets honours the
Idempotency-Keyheader. With a key, a retried request answers the first ticket for 24 hours instead of raising a second one with its own ticket.created webhook, notification and acknowledgement. A repeat while the first is still running answers 409 idempotency_in_progress, and the same key with a different body answers 409 idempotency_conflict. A refusal before the ticket exists frees the key. Requests without the header behave as before.
- added POST /api/v1/commerce/products honours the
- fixed POST /api/v1/commerce/orders/{id}/book-courier keeps the Idempotency-Key when a booking answer is lost, a provider 5xx is returned, or the response body cannot be read. The 502 answer says the booking is not confirmed: check your Courier Guy account before trying again. Repeating the same key replays that answer for 24 hours without booking again. Only a readable provider 4xx refusal frees the key for a retry. Shiplogic key checks, quotes and bookings now stop after 20 seconds, including reading the response body. No automatic retry is made.
- fixed The OpenAPI and SDK Idempotency-Key header description now distinguishes repeat responses: most endpoints replay the first response, one-time secrets are omitted, and agent chat answers 409 chat_already_answered without the reply. Endpoint behaviour is unchanged; see each endpoint's notes for its repeat response.
- added POST /api/v1/ai-agents/{id}/chat honours the
Idempotency-Keyheader, and still stores nothing your end user types. With a key, a retried request never runs the agent or charges a second time. The reply is not kept, so a repeat with the same key within 24 hours answers 409 conflict with reasonchat_already_answeredand the reply's SHA-256 fingerprint indetails.reply_sha256, never the reply itself. Keep the first reply; to ask again, send a new key. A repeat while the first is still running answers 409 idempotency_in_progress, and the same key with a different body answers 409 idempotency_conflict. A refusal frees the key: a budget, the wallet, or the agent unable to answer. Browser widgets may send the header: the preflight allows it. Requests without the header behave as before.
- added POST /api/v1/ai-agents/{id}/chat honours the
- added POST /api/v1/commerce/payment-links honours the
Idempotency-Keyheader. A retry of the same request with the same key answers the first link (the same order id, reference and checkout_url) for 24 hours, instead of creating a second order and a second Paystack transaction. A repeat while the first is still running answers 409 idempotency_in_progress, and the same key with a different body answers 409 idempotency_conflict. A refusal frees the key: a validation error, no payment account, or Paystack declining or not answering in time (that order stays recorded as failed and its link is never offered). Send the corrected request with the same key. Requests without the header behave as before.
- added POST /api/v1/commerce/payment-links honours the
- changed POST /api/v1/messages: a contact with no business belongs to the whole workspace, and a send that names a business (or takes one from its channel) now reaches it when that business has no contact of its own for the recipient. Before, the send answered 404 not_found with reason
recipient_not_found(the case pinned on 2026-10-15, so a thread never landed in the default business), and withto.create: trueit created a second contact without the first one's do-not-contact flag or consent. Now the workspace-level contact is the recipient: its consent counts, its do-not-contact flag refuses the send,to.createcreates nothing, and the conversation takes the business the send names (or its channel's) rather than the default business. A business's own contact still wins, but a workspace-level contact marked do-not-contact also refuses it. When two contacts with no business share the recipient and the business has none of its own, the answer is 400 validation_failed: merge them first. If the contacts cannot be read, the send answers 500 internal_error instead of treating the recipient as not found; send it again. Codes, reasons and response shapes are unchanged.
- changed POST /api/v1/messages: a contact with no business belongs to the whole workspace, and a send that names a business (or takes one from its channel) now reaches it when that business has no contact of its own for the recipient. Before, the send answered 404 not_found with reason
- changed Public API message text now uses a colon after the field path in schema validation errors. The provider_error next step and the chat handoff reply use plain sentences without a dash. Error codes, details.reason, statuses and response shapes are unchanged. Match codes and reasons rather than message text in integrations.
- changed A workspace can have about 500 Flow Studio flows that have not been deleted, switched on or off, across all its businesses. POST /api/v1/studio-flows and Flow Studio check the count before writing: at 500 or more a create is refused (409 conflict, reason
studio_flow_limit_reached, limit 500); delete one first. Simultaneous creates can pass together and exceed this soft cap by a few. Existing-flow edits and switches still work; owner-run classic conversions are outside the cap. If the count cannot be checked, nothing is saved (503 service_unavailable, reasonstudio_flow_count_unavailable); try again.
- changed A workspace can have about 500 Flow Studio flows that have not been deleted, switched on or off, across all its businesses. POST /api/v1/studio-flows and Flow Studio check the count before writing: at 500 or more a create is refused (409 conflict, reason
- fixed The site publish and unpublish response examples and generated SDK types now match the fields those routes return. They do not return primary_domain or public_url. SDK migration: read GET /api/v1/sites for public addresses instead of reading those fields from a publish or unpublish response. The routes themselves are unchanged; draft_schema remains a JSON document and published_schema and business_id can be null for older sites.
- changed An SMS send refuses while the business's SMS channel is switched off: POST /api/v1/messages and POST /api/v1/campaigns answer 503 channel_unavailable with reason
sms_channel_disconnected, and nothing is written, queued or charged. The next step is the SMS sender page (/sms/channel), where an owner or admin switches SMS back on. When the switch cannot be read, the answer is the same code with reasonsms_channel_check_unavailable; send the request again in a moment. A send that names no business is judged by the business of the SMS sender it would use. A sandbox (test key) send is never refused for this. - fixed A flow's PayFast payment link is never sent again once its order is 24 hours old. POST /api/v1/events/send keeps the existing not_sent code payment_link_expired and asks for a new payment request; its reason names PayFast's 24-hour lifetime. Paystack keeps its existing six-day reuse window and refusal wording. A plain payment-link message and the flow run's notice explain PayFast's 24-hour lifetime; an approved WhatsApp template keeps its approved content.
- changed An SMS send refuses while the business's SMS channel is switched off: POST /api/v1/messages and POST /api/v1/campaigns answer 503 channel_unavailable with reason
- fixed POST /api/v1/webhooks/{id}/replay-failed never replays without its replay protection: whenever that protection is unavailable, the call answers 503 service_unavailable before reading or replaying any deliveries. Retry later; deliveries already replayed are still skipped.
- fixed POST /api/v1/events/send refuses a flow's payment-link step when its existing order was started with a different payment provider from the shop's current connection, in either direction. Its not_sent outcome has code
payment_link_provider_changedand says which provider started the order: create a new payment link. It never resends that order's old checkout link or opens a transaction with the other provider.
- added SDK 0.9.0 is on npm: the TypeScript client now calls all 127 documented operations. New: Flow Studio flows (studioFlows: list, get, create, update and switchOff), WhatsApp spend (channels.whatsapp.spend) and Commerce collection (commerce.markOrderReadyForCollection). Removed: client.automations, since the classic automations API answers 410 and its successor is studioFlows. Upgrade with npm install @blueticked/sdk@latest.
- added POST /api/v1/businesses honours an Idempotency-Key header: a retry of the same request within 24 hours answers the first result, so it can never create, or charge for, a second business. A replayed answer never repeats
api_key.key, which is returned once and never stored; mint a new key with POST /api/v1/businesses/{id}/keys if it was lost. A refusal before the business exists (business_charge_required, business_charge_changed, a limit or a bad name) frees the key for the corrected request. In the SDK, CreatedBusiness.api_key.key and MintedBusinessKey.key become optional: a first answer always carries the key, so check it before storing it.billingis typed as nullable. - changed POST /api/v1/contacts and PATCH /api/v1/contacts/{id} record a consent before they apply it. When the record cannot be written, the call answers 503 service_unavailable, reason
consent_evidence_unavailable, with Retry-After: on POST the contact is saved without that consent, on PATCH nothing changes, and the same request sent again records and applies it. A consent that newly lands on a contact holding none now starts the workspace's subscribed journeys once on every path, including an upsert or a PATCH of a contact that already existed (before, only a new contact did). On POST, re-sending consent a contact already holds changes nothing and writes no new record; on PATCH, re-sending WhatsApp or SMS consent refreshes its timestamp and records it again. - changed POST /api/v1/contacts/{id}/erase answers 503 service_unavailable with reason
erasure_retryingand 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. - added Every 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.
- added Flow Studio flows are on the API (automations:read and automations:write): GET /api/v1/studio-flows lists them (each marked
kindautomation or journey, withactivation_blockersandhas_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_failedwithdetails.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. - changed POST /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, reasonsms_type_required, withdetails.issuesnaming each step. Settransactionalfor an order or service update, ormarketingfor anything promotional, which needs SMS consent and keeps quiet hours. Nothing is saved until every one says which. - breaking The 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, reasonautomations_retiredanddetails.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. - changed The 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.
- fixed POST /api/v1/campaigns refuses an email whose blocks (inline
block_content, or the design atemplate_idcarries) still hold a starter template's example link (https://example.com, with or without a path) or placeholder image (placehold.co): 400 validation_failed withdetails.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.
- fixed POST /api/v1/campaigns refuses an email whose blocks (inline
- added Commerce orders carry
paymentin 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) andmethod(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). - added GET /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_categoryandby_country, each with messages and cost, in the currency Meta bills in. It is Meta's own pricing analytics, markedapproximate: 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, on npm since 21 Oct. - changed An order's
emailscan sayreason: "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, on npm since 21 Oct. - added POST /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_atandalready_ready: true). Orders carryfulfilment.ready_for_collection_at(null until marked) in GET /api/v1/commerce/orders, GET /api/v1/commerce/orders/{id} and every commerce order webhook, andemailsgains theready_for_collectionkind. Clients that listemailskinds should accept the new one. - added SDK 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.
- fixed SDK 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.
- added Commerce orders carry
- changed POST /api/v1/email/sending-domain: the validation_failed message for a missing or malformed
from_local_partnow reads "Providefrom_local_part: the part before the @, e.g. accounts." (a colon where it had a dash). The code is unchanged: branch onerror.code, never on the message.
- changed POST /api/v1/email/sending-domain: the validation_failed message for a missing or malformed
- fixed An 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.
- added AI agents carry
effective_model, the model their replies actually run on, besidemodel(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;modelkeeps its meaning.
- added AI agents carry
- added POST /api/v1/automations: an SMS
send_messagestep in a free-formdefinitionmay carrysmsMessageType,marketingortransactional. 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);transactionalis for order or service updates. When it is absent the step goes as transactional, except in a run started bycommerce.order.abandoned(a basket win-back is promotional), where it goes as marketing; send it to say which.
- added POST /api/v1/automations: an SMS
- added Commerce orders carry
customer.sms_marketing_opt_inin 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.
- added Commerce orders carry
- added Commerce 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 asorder.number, and flat asorder_number).GET /api/v1/commerce/orders?q=finds an order by it, with or without its #.referenceis 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.
- added Commerce orders carry
- changed PATCH /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.
- changed A commerce order's
emailsentry with statefailednow says why its last try failed inreason: 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;retryingandsententries still carry null.
- changed A commerce order's
- added Commerce orders carry
emailsin 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 givesstate(sent, retrying, failed or skipped),at,attempts(sends tried so far) and, on a skip only,reasonfrom 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.
- added Commerce orders carry
- added POST /api/v1/documents (contacts:write) uploads one private PDF, up to 4 MB, for one contact as multipart/form-data (
file,contact_id, optionalkind) 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. - added POST /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 contactto.createwould 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.
- added POST /api/v1/documents (contacts:write) uploads one private PDF, up to 4 MB, for one contact as multipart/form-data (
- added GET /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.
- changed media_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.
- changed POST /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.
- added message.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.
- added GET /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.
- added GET /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.
- added GET /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.
- fixed POST /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.
- fixed Commerce 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.
- added GET /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.
- fixed POST /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.
- fixed A 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.
- fixed API 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.
- fixed A 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.
- fixed A 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.
- added Owners 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.
- fixed POST /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. If replay protection is unavailable, the call answers 503 service_unavailable before any replay reads or inserts.
- fixed A 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.
- fixed POST /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.
- added POST /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.
- changed An 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.
- changed email.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.
- fixed An 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.
- added GET /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.
- fixed POST /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.
- fixed PATCH /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.
- fixed POST /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.
- added POST /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.
- added POST /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.
- added A 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.
- added Webhook 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.
- breaking The 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.
- changed PATCH /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.
- fixed POST /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.
- fixed The 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.
- added A 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.
- fixed A 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.
- added Commerce 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.
- changed POST /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.
- changed The 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.
- added POST /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.
- added A 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.
- changed POST /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.
- changed POST /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.
- fixed POST /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.
- fixed POST /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.
- added Flows 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.
- added A 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.
- fixed Journey.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.
- fixed EnrollContactsResult.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.
- fixed Site.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.
- fixed Automation.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.
- fixed Automation.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.
- fixed AutomationRecipe.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.
- fixed The 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.
- fixed The /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.
- changed When 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.
- changed POST /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.
- added POST /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.
- added Commerce 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.
- added Commerce 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.
- changed Commerce 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.
- changed An 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.
- fixed Template.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.
- fixed The 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.
- fixed The 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.
- fixed CreatedBusiness.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.
- fixed Flow.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.
- fixed MessageStatus 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.
- fixed Template.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.
- fixed SendMessageResponse.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.
- fixed The 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.
- fixed The 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.
- changed Campaigns (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.
- changed GET /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).
- added POST /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.
- changed PATCH /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.
- fixed On 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.
- fixed An 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.
- added On 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.
- added Integrations 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.
- added PATCH /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.
- fixed Every /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.
- changed A /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.
- fixed A /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.
- fixed Reports 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.
- fixed WhatsApp 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.
- fixed Incoming 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.
- changed On 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.
- changed POST /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.
- changed POST /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.
- fixed An 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.
- fixed PATCH /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.
- added POST /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.
- changed POST /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.
- changed Suppression 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.
- changed POST /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. - changed channel_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.
- fixed GET /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.
- changed Every 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.
- fixed An 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.
- changed Business-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.
- added GET /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.
- changed POST /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.
- changed POST /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.
- changed PATCH /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.
- changed GET /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.
- added The 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.
- breaking PATCH /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.
- breaking POST /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.
- added POST /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.
- changed Inbox 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.
- added GET /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.
- changed Ticket 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.
- added Tables 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.
- added GET /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.
- fixed A 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.
- fixed POST /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.
- added Ticket 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.
- added GET 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.
- added GET /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.
- fixed SDK 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.
- fixed POST /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.
- added The 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.
- fixed Public 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.
- added New 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. - changed The commerce endpoints (products, discounts, delivery zones, orders, fulfilment, courier bookings) now answer their 409s with code
conflictinstead ofresource_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}.
- changed Reference 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.
- added POST /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.
- added Publishable 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 *.
- added POST /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.
- added DELETE /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.
- added POST /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.
- added Connected 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.
- added Partner 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.
- breaking POST /api/v1/events/send:
channelno 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. - added A 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.
- added message.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.
- changed The 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.
- fixed POST /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.
- added The 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.
- fixed POST /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.
- added POST /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.
- added OAuth 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.
- changed Every MCP tool now declares a human title alongside its read-only/destructive annotations, shown by clients in permission prompts and tool pickers.
- added POST /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.
- changed GET /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.
- added message.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.
- changed Templates 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.
- added Every error now carries error.next_steps — the remedy, as data. Each step has a stable
actionto branch on and abyofcaller,humanorwaiting.humanis 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. - added Sandbox 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. - changed MCP 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.
- added POST /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
referenceto 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. - fixed A 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.
- added GET /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.
- changed Commerce 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. - added New 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.
- fixed MCP: 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.
- fixed The 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.
- added The 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 withretry_unsaferather than risking a duplicate write.error.detailsis surfaced (the error table tells you to read it), andbt.request()reaches any endpoint without a typed method yet. - added GET /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
failedis terminal. - changed Sandbox 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.
- fixed The
documentfield 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. - fixed Form 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.
- added The TypeScript SDK is on npm:
- added Journeys: 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.
- added AI 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.
- added Form 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.
- added Sites: 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.
- added Automations: 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.
- added Commerce configuration over the API: payment links, fulfilment settings and discount codes, alongside the existing product and order endpoints.
- added Per-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.
- changed The 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.)
- fixed spend_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.
- added Documentation for the TypeScript SDK, the MCP server and the agent-readable API documents, under Tools in the docs.
- added Agent-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.
- added MCP 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.
- fixed Some 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.
- added New 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.
- changed POST /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.
- added Every 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).
- changed Rotating 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.
- added New 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.
- changed Sandbox 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.
- fixed List 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'.
- fixed Flow 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.
- added The published OpenAPI spec now documents real success statuses (201/202), the full error-code set, query parameters, and per-event webhook payload schemas.
- fixed The marketing-email documentation example is now a valid request (marketing sends cannot use cc/bcc — send one message per recipient).