Receive events from your app
Send your app's events to a connection's callback, read what happened, and retry without sending twice.
There is no test mode. Every event runs the connection's live flows, which can message the customer and spend credit; an API test key's sandbox does not apply here. Build against a connection whose flows message only you, and agree with the business before pointing your app at a connection real customers hear from.
Your callback URL
Each connection in Integrations has its own URL.
The token in it is the credential: anyone with the URL can deliver events to that connection, so keep it in your app's secrets, never in client code or a screenshot.
https://app.blueticked.com/api/integrations/inbound/{connection_id}/{token}The business copies it for you from the connection's menu ("Make an address": the dashboard calls it the Blueticked address). Blueticked shows it once, when it is made: the token is stored only as a hash. Lost it? "Rotate address" makes a new one. The old address keeps working for 24 hours, then stops. Give your app the new one before then.
Send an event
POST JSON (a form post works too), at most 128,000 bytes.
The event's type is the first of "event", "event_type", "type", "status" the body has as text (or "callback.received"), and it must be a name the connection's flows listen for: agree the names with the business, or the answer is no_flow. The record the event is about is the first of "id", "booking_id", "appointment_id", "lead_id", "reference". Everything else is your app's own shape.
const response = await fetch(process.env.BLUETICKED_CALLBACK_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
event: "appointment.created",
id: "booking-1234",
customer: {
phone: "+27800000001",
name: "Carol",
},
appointment: {
service: "Oxygen assessment",
slot: "Tuesday 09:30",
branch: "Hillcrest",
},
}),
});
const result = await response.json();{
"ok": true,
"accepted": true,
"duplicate": false,
"contact_id": "6231b839-06d1-410c-80b9-8b85ca6d6598",
"automation_handled": true,
"outcome": {
"kind": "queued",
"outcomes": [
{
"flowId": "94e2ca27-32ac-4e1c-92ef-a25e29630a65",
"channel": "whatsapp",
"kind": "queued",
"reason": null,
"messageIds": [
"917a6fc7-744e-45b7-b9b7-5a5c7cde7d9f",
"7ab18428-aa25-4ea5-aa23-205484dfd123"
]
}
]
}
}202 means accepted. Whether a message went out is in outcome, below; queued is not yet delivered.
How Blueticked finds the customer
By phone or email.
If the business's field map for the connection says where they are, that wins. Otherwise Blueticked takes the first of these it finds, and searches nested objects for a phone and an email too.
- Phone
- phone, phone_number, mobile, cell, contact_number, customer_phone, data.phone, customer.phone, contact.phone, billing.phone, payload.phone, cell_number, text_reminder_number
- email, email_address, contact_email, customer_email, data.email, customer.email, contact.email, billing.email, payload.email, email_address_1
A phone is international (+27821234567) or a South African national number (0821234567), which Blueticked turns into the international form. The contact is matched by its phone when the event has one, otherwise by its email, and created if it is new. With neither, nobody is messaged: when flows listen to the connection, the answer's outcome is no_contact. Receiving an event does not record anyone's marketing consent.
Retries and duplicates
Retry with the same request: an event is handled once.
Blueticked names each delivery by your Idempotency-Key header if you send one, else by the event's type and record (for example appointment.created and booking-1234), and remembers it for seven days. The same delivery again is accepted as a duplicate and sends nothing, because the first copy already ran. A later event about the same record (appointment.updated) is a new event and runs.
const response = await fetch(process.env.BLUETICKED_CALLBACK_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
event: "appointment.created",
id: "booking-1234",
customer: {
phone: "+27800000001",
name: "Carol",
},
appointment: {
service: "Oxygen assessment",
slot: "Tuesday 09:30",
branch: "Hillcrest",
},
}),
});
const result = await response.json();{
"ok": true,
"accepted": true,
"duplicate": true,
"contact_id": null,
"automation_handled": false
}Keep the same Idempotency-Key on every retry of one delivery: a new key is a new delivery, and runs again. An event with no record and no key is never treated as a duplicate, so its retries all run.
{
"ok": false,
"error": {
"code": "processing_failed",
"message": "This event could not be processed; nothing was sent. It is held, and the sender's retries will not run it."
}
}{
"ok": true,
"accepted": true,
"duplicate": false,
"contact_id": null,
"automation_handled": false,
"outcome": {
"kind": "not_sent",
"outcomes": []
}
}A held event is never run by your retries, so a half-finished attempt never sends twice; stop retrying it. The same retry answer can follow a 202 when Blueticked accepted an event but could not finish it. The event stays on the connection's record, where the business can see what happened.
Signed requests
Turn on "Require signed requests" on the connection to have Blueticked check that each event came from you.
It shows you a signing secret once and makes a new callback URL (so does turning it off, or rotating the secret). Sign the exact body you send: an HMAC-SHA256 with the secret, in hex, in the x-signature header (a sha256= prefix is accepted).
import { createHmac } from "node:crypto";
const body = JSON.stringify({
event: "appointment.created",
id: "booking-1234",
customer: {
phone: "+27800000001",
name: "Carol",
},
appointment: {
service: "Oxygen assessment",
slot: "Tuesday 09:30",
branch: "Hillcrest",
},
});
const signature = createHmac("sha256", process.env.BLUETICKED_CALLBACK_SIGNING_SECRET)
.update(body)
.digest("hex");
const response = await fetch(process.env.BLUETICKED_CALLBACK_URL, {
method: "POST",
headers: { "content-type": "application/json", "x-signature": signature },
body,
});
const result = await response.json();{
"ok": true,
"accepted": true,
"duplicate": false,
"contact_id": "6231b839-06d1-410c-80b9-8b85ca6d6598",
"automation_handled": true,
"outcome": {
"kind": "queued",
"outcomes": [
{
"flowId": "94e2ca27-32ac-4e1c-92ef-a25e29630a65",
"channel": "whatsapp",
"kind": "queued",
"reason": null,
"messageIds": [
"917a6fc7-744e-45b7-b9b7-5a5c7cde7d9f",
"7ab18428-aa25-4ea5-aa23-205484dfd123"
]
}
]
}
}Sign the bytes you send, not a re-serialised copy: a body changed by a single character answers 401.
WooCommerce's setup ping
When a WooCommerce webhook is saved as active, WooCommerce first sends a ping with only a webhook_id.
A connection made from the WooCommerce order preset (and still named for it) answers it and records it as a test event; it creates no contact and runs nothing.
curl -X POST "$BLUETICKED_CALLBACK_URL" \
-H "content-type: application/x-www-form-urlencoded" \
--data "webhook_id=42"{
"ok": true,
"setup_ping": true
}The callback contract
The whole contract in one place, the same text llms-full.txt and the agent kit carry.
Two doors, different authorization and contact behavior:
- POST /api/integrations/inbound/{id}/{token} is not an API-key endpoint. Enable incoming events on a Connected app and copy its private callback URL into BLUETICKED_CALLBACK_URL. The connection fixes organization and business scope; never send those to select another workspace.
- POST /api/v1/events/send uses an API key or OAuth access with events:send. events/send never creates a contact. Create the contact first using POST /api/v1/contacts with contacts:write, then send its contact_id or matching phone/email in the same business.
- Callback ingestion can create or update a contact from the mapped payload. Receiving an event does not grant consent. Never invent opt-ins; suppression and channel send rules still apply.
Send a callback
The URL contains a credential. Do not print it, include it in screenshots, or commit it. Rotating the callback token requires updating the sender. The request body limit is 128,000 bytes; larger bodies return 413. Use application/json for nested source data or application/x-www-form-urlencoded for flat string fields, with the owner's field mapping. The callback reads the first nonempty event-type value from event, event_type, type, status, in that order. Without one it uses callback.received. Example body (use your connection and flow's event/field names):
{
"event": "appointment.created",
"id": "booking-1234",
"customer": {
"phone": "+27800000001",
"name": "Carol"
},
"appointment": {
"service": "Oxygen assessment",
"slot": "Tuesday 09:30",
"branch": "Hillcrest"
}
}The same event as a form post, its fields flat strings (sign the raw form body the same way when signing is on):
Content-Type: application/x-www-form-urlencoded
event=appointment.created&id=booking-1235&phone=%2B27800000001&name=CarolTurn on "Require signed requests" on the connection. Blueticked shows the signing secret once and creates a new callback URL; toggling signed requests or rotating the signing secret also rotates the URL. Update the sender with that URL and keep the signing secret in BLUETICKED_CALLBACK_SIGNING_SECRET. For generic signing, send x-signature as the hex HMAC-SHA256 of the EXACT raw body bytes (sha256= is accepted). Do not reserialize after signing. Vendor signing schemes are different; match the configured scheme rather than substituting this example.
import { createHmac } from "node:crypto";
const url = process.env.BLUETICKED_CALLBACK_URL;
const secret = process.env.BLUETICKED_CALLBACK_SIGNING_SECRET;
if (!url || !secret) throw new Error("Callback configuration missing");
const rawBody = JSON.stringify({"event":"appointment.created","id":"booking-1234","customer":{"phone":"+27800000001","name":"Carol"},"appointment":{"service":"Oxygen assessment","slot":"Tuesday 09:30","branch":"Hillcrest"}});
const signature = createHmac("sha256", secret).update(rawBody).digest("hex");
const response = await fetch(url, { method: "POST", headers: {
"Content-Type": "application/json", "x-signature": signature,
"Idempotency-Key": "booking-1234:appointment.created:revision-1",
}, body: rawBody });
const result = await response.json();
// Inspect response.status and result.outcome; do not assume a send.Read the response and retry safely
202 means accepted, not necessarily queued; outcome is absent on duplicates and on connections with no flow. When present, inspect outcome.kind and each outcome.outcomes line: queued means reserved for asynchronous delivery, ran means non-send actions ran, not_sent means a refusal or failure, no_flow means no matching listener, no_contact means no usable contact, withheld means contact suppression, already_sent means the line was previously completed. A line that queued a message and then had a later step refused still reads queued, with the ids of what was queued; the refusal is kept in the flow's run history. A line whose step failed for any other reason (a lost queue publication, an unexpected error) reads not_sent and still lists every message row it made in messageIds; some of those may have gone, so reconcile them before any retry. A queued result is not provider delivery; reconcile messageIds through delivery events or the message status endpoint. A not_sent line the flow refused also carries code, from a closed set, beside its reason: 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. Send one stable Idempotency-Key for the same delivery and a different delivery key for a later revision, even on the same record. Without that header, the callback falls back to event plus the record reference; two same-event revisions can otherwise look identical. Replaying a completed receipt returns duplicate: true without repeating effects. HTTP 503 receipt_busy or 503 receipt_unavailable returns Retry-After: 10; a paused connection returns 503 integration_paused with Retry-After: 300. Honor Retry-After with bounded backoff, not a new key. Failed or uncertain effects are held for review instead of being repeated blindly: processing_failed can return 500, and a later held receipt returns 202 with outcome.kind not_sent. Changing the delivery key to force another attempt can double a send. Read the connection's delivery log and reconcile first. A 401 signature_invalid or a 404 not_found (Callback not found) needs configuration repair; a 400 invalid_json or a 413 body limit needs payload repair, not blind retries. HTTP 429 has the flat body {"ok": false, "error": "rate_limited"} and Retry-After: 60. Authenticated requests share a 1,000-per-minute budget per connection across sender addresses; invalid credentials are limited to 30 per address per minute. Honor Retry-After without changing the delivery key. WooCommerce setup is not a live event: an unsigned application/x-www-form-urlencoded body containing only one positive-integer webhook_id, with neither x-wc-webhook-topic nor x-wc-webhook-signature, gets HTTP 200 and {"ok": true, "setup_ping": true} on a WooCommerce connection. It creates only a test log, never a contact, flow action or connected status. If that test log cannot be written, the ping gets HTTP 503 setup_ping_unavailable with Retry-After: 10; save the webhook in WooCommerce again. Signed WooCommerce deliveries still require their configured vendor signature.
Events API: create the contact first
These are example UUIDs, not workspace IDs you may reuse. Discover your business ID and substitute it in BOTH requests. All resource IDs are UUIDs, never prefixed strings. The event endpoint accepts international and South African national phone forms, including spaces and punctuation, using the callback's normalizer. Contact creation still requires canonical E.164 phone values. Do not pass a nested contact object to events/send: its identifiers are flat contact_id, email or phone. POST /api/v1/contacts (contacts:write):
{
"business_id": "71000000-0000-4000-9000-000000000002",
"phone": "+27800000001",
"first_name": "Sam"
}POST /api/v1/events/send (events:send), only after the contact request succeeds:
{
"business_id": "71000000-0000-4000-9000-000000000002",
"channel": "whatsapp",
"event": "rental.booked",
"phone": "+27800000001",
"data": {
"product": "CPAP",
"reference": "R-1842",
"date": "2026-10-06",
"customer": {
"department": "Home care"
}
}
}Do not add consent:true just to make an example send. Preserve the customer's actual consent evidence. Free-text WhatsApp still needs the customer's open 24-hour reply window. When it is closed, configure an approved template in the flow; not_sent is not permission to bypass the window. Use a test key/test_mode for events evaluation before live access; its 200 response has outcome: null and queues nothing. Callback URLs do not inherit an API test key's sandbox: a callback can run live flow actions. Use an isolated test connection and explicit human approval before any callback that could contact a recipient or spend credit.
Each connection is under Connected apps, Integrations. Its record holds the address and secret, the setup, and its Deliveries, where each event shows what happened. Incoming callbacks are separate from the webhooks Blueticked sends to your server.
What the answer tells you
contact_id is the contact the event created or updated (null when it named nobody usable).
automation_handled says whether a flow took the event. When flows listen to this connection, outcome says what happened: kind is the worst word across its lines. A line is one flow, with its channel, its word, a reason when it did not send, and the ids of any messages it queued. When the answer is about the whole event (nobody found, the customer opted out, a held retry), kind says so and that has no line; the connection's record in Blueticked keeps the reason.
- not_sent
- A message could not be sent for a reason someone at the business can fix (a channel not connected, a paused template, the 24-hour window closed, a missing field, no credit). The line's reason says which.
- no_contact
- The event carried no phone or email the connection could use, so nobody was messaged.
- no_flow
- Flows listen to this connection, but none to this event's type. Nothing was sent, and that can be intended.
- withheld
- The customer opted out or is on the do-not-contact list. Nothing was sent, by their choice.
- queued
- A message was queued for the customer. messageIds carries its ids; queued is not yet delivered.
- already_sent
- An earlier delivery of this event already sent it, so this one sent nothing.
- ran
- The flow ran and did what it was set to do without a message: a cooldown holding back a second message, the team handling the conversation, or a tag or a ticket.
{
"ok": true,
"accepted": true,
"duplicate": false,
"contact_id": "6231b839-06d1-410c-80b9-8b85ca6d6598",
"automation_handled": false,
"outcome": {
"kind": "no_flow",
"outcomes": []
}
}outcome is left out of a duplicate's answer, and when nothing at all is set to run for this connection.
Refusals and limits
A refused event is not processed.
Blueticked checks in this order. For a 4xx, fix the request before sending it again; for a 429 or a 5xx, send the same request again after Retry-After, or after a short wait when there is none (any other 5xx is Blueticked's, and is answered the same way, except a held event's processing_failed, whose retries run nothing).
| Status | Body | When |
|---|---|---|
| 429 · Retry-After 60 | {"ok": false, "error": "rate_limited"} | More than 1,000 authenticated requests a minute to one connection, across sender addresses, or more than 30 refused ones from one address (a wrong URL or signature). Note that error is a word here, not an object. |
| 404 | {"ok": false, "error": {"code": "not_found", "message": "Callback not found"}} | The connection does not exist, its incoming events are off, or the token is wrong. All three answer the same, so a guessed URL learns nothing. |
| 503 · Retry-After 300 | {"ok": false, "error": {"code": "integration_paused", "message": "Integration is paused"}} | The connection is paused. Resume it on its record; a well-behaved sender retries after five minutes. |
| 413 | {"ok": false, "error": {"code": "payload_too_large", "message": "Callback payload is too large"}} | The body is over 128,000 bytes. |
| 503 · Retry-After 10 | {"ok": false, "error": {"code": "setup_ping_unavailable", "message": "The setup test could not be recorded. Please try again."}} | WooCommerce's setup ping could not be recorded. Save the webhook in WooCommerce again. |
| 401 | {"ok": false, "error": {"code": "signature_invalid", "message": "Invalid signature"}} | Signed requests are required and the x-signature header is missing or does not match the body. The signature is checked before the body is parsed, so an unsigned body that is not JSON answers this, not 400. |
| 500 | {"ok": false, "error": {"code": "misconfigured", "message": "Signature verification unavailable"}} | Blueticked could not read the connection's signing secret. Nothing was processed; try again later. |
| 400 | {"ok": false, "error": {"code": "invalid_json", "message": "Invalid JSON"}} | The body is not JSON (and not a form post). |
| 503 · Retry-After 10 | {"ok": false, "error": {"code": "receipt_busy", "message": "This event is still being processed. Retry in a moment."}} | The same event arrived again while its first copy was still running. |
| 503 · Retry-After 10 | {"ok": false, "error": {"code": "receipt_unavailable", "message": "Could not confirm this event's receipt. Retry in a moment."}} | Blueticked could not check whether it had seen this event. Nothing was processed. |
Send again, from the connection's record
Anyone on the team can open an event on the connection's record; owners and admins can send it again.
When an event, or one of its lines, did not reach anyone (not_sent, no_contact or no_flow), Send again runs it once more from the payload Blueticked kept: only the lines that did not send, never a line that queued, already sent, ran or was withheld. The new attempt is a new event at the top of the log. An event refused on arrival (a wrong token or signature, a paused connection, a body that could not be read) cannot be sent again; your app has to send it once more.
Sending from your own server instead? The events API (POST /api/v1/events/send) starts the same flows with an API key and your own request shape.