Event Tracker
Send BusinessChat behavioral events — page views, product views, cart activity — from any backend over one HTTP endpoint. Events that BusinessChat understands drive journeys and automations; anything else is recorded for reporting.
Overview#
The Events API is a single endpoint that accepts events about your customers and routes them by name. It is modeled on the shape of a tracking API: one call, an event name, an identity, and a bag of properties.
Every event you send falls into one of two categories:
- Reserved events — a fixed set of names BusinessChat already understands (a product view, a started checkout, and so on). These drive the same journeys and automations that events from the on-site pixel do.
- General events — any other name. These are recorded as they arrived so you can report on them, but BusinessChat has no built-in behavior for them.
| Base URL | https://pixel-gateway.businesschat.io |
| Endpoint | POST /v1/track |
| Authentication | Your track token, sent as Authorization: Bearer <token>. The token identifies your workspace. |
| Content type | application/json |
Your track token is a server-side secret. Anyone holding it can send events as your workspace, so call this endpoint from your backend only — never from a browser or a mobile app — and always over HTTPS. A request without a valid token is refused with 401 and nothing is recorded.
The track call#
Every event — reserved or general — is a POST /v1/track with the same envelope. The event name decides how it is handled; the data object carries the properties for that event.
| Field | Type | Required | Description |
|---|---|---|---|
event | string | Always | The event name. A reserved name is handled by that event's pipeline; any other name is a general event. |
sessionId | string | Reserved events | A stable ID you choose for one visit. Ties a visitor's events together. Required for every reserved event; not used by orders or general events. |
userId | string | Some events | The contact's phone number in E.164 form. Required for general events and for contact_identified; optional but recommended on the rest — see Identity & sessions. |
occurredAt | string | Optional | When the event happened, ISO-8601 with a timezone (e.g. 2026-09-06T10:00:00Z). Defaults to the time we receive it. |
data | object | Optional | Properties for this event. Which keys matter depends on the event; see each event below. Unknown keys are ignored. |
A minimal call and its response:
curl -X POST 'https://pixel-gateway.businesschat.io/v1/track' \
-H 'Authorization: Bearer YOUR_TRACK_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"event": "product_viewed",
"sessionId": "sess-9f2c",
"userId": "+966501234567",
"data": { "productId": "SKU-1", "productName": "Blue Shirt" }
}'A successful call returns 200 with the ID we assigned the event:
{ "eventId": "px-1f3a9c20-6b7e-4a51-9d2c-8e0b4f7a1c33" }The envelope always wins over data: repeating an envelope field like sessionId inside data has no effect. The workspace always comes from your token; a businessId in the body is ignored.
Identity & sessions#
Two fields identify who an event is about and how its events group together.
userId — the contact
userId is the contact's phone number in E.164 form, e.g. +966501234567. Formatting is tolerated — spaces, dashes, brackets, and Arabic-Indic digits are cleaned up — and the endpoint turns away only what is obviously not a number.
A number this endpoint accepts can still be refused later. We check only that a value is plausibly a phone number, and our stricter parser decides afterwards — it is less forgiving for some countries, and a national number with no country code is the common case. When it refuses one, the event is dropped after you have already had a 200. Always send the full international form, and if an event never shows up, quote the eventId from the response to support: it appears in the log line that names the field which failed.
Without a userId, an event is recorded but nobody can be messaged about it. Journeys and abandonment automations only act on a session once it has a contact. Send the phone as soon as you know it — either on the event itself, or with a contact_identified event for the same sessionId. From then on the whole session becomes actionable.
sessionId — the visit
sessionId is a stable string you choose that scopes one visit. Events in your workspace sharing a sessionId are treated as one journey. Use any stable identifier — a cookie value, a server session ID — and keep sending the same one for the life of the visit.
Reserved events#
These are the events BusinessChat acts on. Each needs its sessionId, and some need specific keys inside data — those are listed as required below and are checked when the event is received. Everything else is enrichment: it improves what an automation can say, but its absence never rejects the event.
Required data keys are checked for presence only. A present but wrong value (an unparseable field, an unknown enum name) is accepted here — you still get 200 — but the event is dropped further down and never acted on. The exceptions that are checked up front are called out per event below.
session_started#
The visitor has begun a session. Send it once at the start of a visit.
| Key | In | Required | Description |
|---|---|---|---|
url | data | Yes | The page the session started on. |
curl -X POST 'https://pixel-gateway.businesschat.io/v1/track' \
-H 'Authorization: Bearer YOUR_TRACK_TOKEN' \
-H 'Content-Type: application/json' \
-d '{ "event": "session_started", "sessionId": "sess-9f2c",
"data": { "url": "https://shop.example/home" } }'website_visited#
A page was viewed within a session.
| Key | In | Required | Description |
|---|---|---|---|
url | data | Yes | The page that was viewed. |
curl -X POST 'https://pixel-gateway.businesschat.io/v1/track' \
-H 'Authorization: Bearer YOUR_TRACK_TOKEN' \
-H 'Content-Type: application/json' \
-d '{ "event": "website_visited", "sessionId": "sess-9f2c",
"data": { "url": "https://shop.example/products/blue-shirt" } }'contact_identified#
You have learned the visitor's phone number. Send it to attach the contact to the session; from here on the session can be messaged.
| Key | In | Required | Description |
|---|---|---|---|
userId | envelope | Yes | The contact's phone number. This event is rejected without it. |
curl -X POST 'https://pixel-gateway.businesschat.io/v1/track' \
-H 'Authorization: Bearer YOUR_TRACK_TOKEN' \
-H 'Content-Type: application/json' \
-d '{ "event": "contact_identified", "sessionId": "sess-9f2c",
"userId": "+966501234567" }'product_viewed#
The visitor looked at a product.
| Key | In | Required | Description |
|---|---|---|---|
productId | data | Recommended | The product identifier. A blank or missing productId is accepted but recorded as generic activity rather than a product view. |
productPrice | data | Optional | The product's price. If you send it, you must also send a valid productCurrency. |
productCurrency | data | With price | An uppercase ISO-4217 code (e.g. SAR, USD). Checked when a productPrice is present — an invalid code is rejected with 422. Omit both to send no price. |
Also accepted (enrichment): productName, productUrl, productImageUrl, productStatus, productInStock, productProvider, productJson. | |||
curl -X POST 'https://pixel-gateway.businesschat.io/v1/track' \
-H 'Authorization: Bearer YOUR_TRACK_TOKEN' \
-H 'Content-Type: application/json' \
-d '{ "event": "product_viewed", "sessionId": "sess-9f2c",
"userId": "+966501234567",
"data": { "productId": "SKU-1", "productName": "Blue Shirt",
"productPrice": 49.0, "productCurrency": "SAR" } }'added_to_cart#
The visitor added a product to their cart.
| Key | In | Required | Description |
|---|---|---|---|
productId | data | Yes | The product added. |
cartProducts | data | Strongly recommended | The cart's full current contents — an array of {productId, quantity}. This replaces what we hold for this shopper. Leave it out and we remember only the product in this call, so a recovery message names one item instead of the cart. See how cart recovery works. |
addedAt | data | Recommended | When the cart changed, ISO-8601. This is the idempotency key: the same value resent is ignored, a later value is applied again. Omit it and we substitute the time we received the call, so a retry counts twice. |
Also accepted: qty (defaults to 1), cartId, cartUrl, cartTotal, currency, variantId, cartJson. | |||
curl -X POST 'https://pixel-gateway.businesschat.io/v1/track' \
-H 'Authorization: Bearer YOUR_TRACK_TOKEN' \
-H 'Content-Type: application/json' \
-d '{ "event": "added_to_cart", "sessionId": "sess-9f2c",
"userId": "+966501234567",
"data": { "productId": "SKU-1", "qty": 2, "addedAt": "2026-09-07T10:15:00Z",
"cartProducts": [ { "productId": "SKU-1", "quantity": 2 },
{ "productId": "SKU-9", "quantity": 1 } ],
"cartId": "cart-77", "cartUrl": "https://shop.example/cart",
"cartTotal": "98.00", "currency": "SAR" } }'checkout_started#
The visitor began checkout.
| Key | In | Required | Description |
|---|---|---|---|
checkoutUrl | data | Yes | The URL to resume checkout. |
productsIdAndQtyList | data | Recommended | The cart contents as "productId:qty" strings, e.g. ["SKU-1:2", "SKU-9:1"]. |
Also accepted (enrichment): cartTotal, currency, cartId, cartUrl. | |||
An abandoned-checkout message can only name the cart's contents and value if you send them. Include productsIdAndQtyList (in the "productId:qty" format) along with cartTotal and currency; otherwise the recovery message goes out with no cart details.
curl -X POST 'https://pixel-gateway.businesschat.io/v1/track' \
-H 'Authorization: Bearer YOUR_TRACK_TOKEN' \
-H 'Content-Type: application/json' \
-d '{ "event": "checkout_started", "sessionId": "sess-9f2c",
"userId": "+966501234567",
"data": { "checkoutUrl": "https://shop.example/checkout/9f2c",
"productsIdAndQtyList": ["SKU-1:2", "SKU-9:1"],
"cartTotal": "147.00", "currency": "SAR" } }'removed_from_cart#
The visitor removed a product from their cart.
| Key | In | Required | Description |
|---|---|---|---|
productId | data | Yes | The product removed. |
cartProducts | data | Strongly recommended | The cart's full contents after the removal. Same rule as added_to_cart: it replaces what we hold. Send an empty array when the cart is now empty. |
removedAt | data | Recommended | When the cart changed, ISO-8601. The idempotency key, exactly as addedAt is for added_to_cart. |
Also accepted: qty (defaults to 1), cartId, cartUrl, cartTotal, currency, variantId, cartJson. | |||
curl -X POST 'https://pixel-gateway.businesschat.io/v1/track' \
-H 'Authorization: Bearer YOUR_TRACK_TOKEN' \
-H 'Content-Type: application/json' \
-d '{ "event": "removed_from_cart", "sessionId": "sess-9f2c",
"userId": "+966501234567",
"data": { "productId": "SKU-1", "qty": 1,
"removedAt": "2026-09-07T10:20:00Z",
"cartProducts": [ { "productId": "SKU-9", "quantity": 1 } ] } }'How cart recovery works#
Cart events do more than record activity: they are what makes abandoned-cart recovery possible. It is worth knowing how the timing works, because nothing you send triggers a message directly.
- You send cart events. Each one updates what BusinessChat believes is in this shopper's cart, and refreshes their activity clock.
- The shopper goes quiet. After roughly four hours with no further events, their session is closed.
- If the cart was still open at that moment, recovery runs — the abandoned-cart automation sends its WhatsApp message.
So recovery is inferred from silence, on our side. There is no event you can send that means "send the recovery message now", and none that skips the wait.
Two things decide whether that message is any good, and both are easy to get wrong:
- Send
cartProductsevery time. It replaces the contents we hold. Without it we keep only the product named in the most recent call, and the recovery message reflects that. - Send a stable
addedAt/removedAt. It is what makes a retry safe. Omit it and a repeated call is treated as a second, later change — quantities add up.
An order cancels it. If an order arrives for the shopper before the message goes out, recovery is called off rather than sent — you do not need to tell us to stop.
What the message can say. The recovery template can use the cart link, the cart total, the currency and the cart id. Product names are not available — only product ids reach this stage — so a template cannot list the items by name. Send cartUrl and cartTotal with currency if you want the message to name a value and link back.
heartbeat#
Keeps a session alive while the visitor is active. Sessions also close on their own after inactivity, so heartbeats are optional for a backend integration.
| Key | In | Required | Description |
|---|---|---|---|
state | data | Yes | One of the exact values below. The value is used only to accept the event; send SESSION_STARTED unless you have a reason not to. |
lastActivityAt | data | Optional | When the visitor was last active, ISO-8601. |
Valid state values (uppercase, exact): SESSION_STARTED, SESSION_ENDED, PRODUCT_VIEWED, CART_ADDED, CHECKOUT_STARTED, ORDER_COMPLETED, CART_ABANDONED.
curl -X POST 'https://pixel-gateway.businesschat.io/v1/track' \
-H 'Authorization: Bearer YOUR_TRACK_TOKEN' \
-H 'Content-Type: application/json' \
-d '{ "event": "heartbeat", "sessionId": "sess-9f2c",
"data": { "state": "SESSION_STARTED" } }'Orders#
An order sent here becomes the same order BusinessChat builds from a connected store, so everything that already reacts to an order reacts to this one: the contact and conversation are created, post-purchase and order-status automations run, the customer's journey advances, and the revenue is attributed.
There are two event names:
| Event | When to send it |
|---|---|
order_created | A new order was placed. |
order_updated | Anything about an existing order changed — most often its status. |
There is no order_paid or order_cancelled event. Both events carry a complete snapshot of the order, and the lifecycle lives in its status — so "the order was paid" is order_updated with "status": "paid". Send the whole order every time, not a diff.
Orders carry no sessionId — an order is not a browsing session. They do require userId: the order creates the contact, and every automation it drives needs someone to message.
Fields#
Optional does not mean unchecked. Every value listed below is validated if you send it. An unusable one is rejected with a 422 naming the exact field — it never silently costs you the order. Fields you omit simply take their default.
Required
| Key | In | Type | Rule |
|---|---|---|---|
userId | envelope | string | The customer's phone number, E.164 (+966501234567). |
order_id | data | integer | Must be numeric. Our order id is a number end to end, so a UUID or "ORD-1001" is rejected — put that form in reference_id. |
status | data | string | One of the 26 status values. |
total.amount | data | number | 0 is valid (a free order); omitting it is not. |
total.currency | data | string | ISO-4217, e.g. SAR, USD, EUR. |
created_at | data | string | ISO-8601 with a timezone — 2026-09-06T10:00:00Z or …+03:00. A naive timestamp is rejected. |
Optional — order
| Key | Type | Allowed values / rule | Default if omitted |
|---|---|---|---|
reference_id | string | Any text — your display reference. | The order_id. |
updated_at | string | When the order last changed. ISO-8601 with a timezone, same rule as created_at. Send it whenever you know it — it is what status automations are timed against. | For order_updated, the time we receive the call. For order_created, created_at. |
historical | boolean | true or false — not "true". true records the order without firing automations; use it when backfilling, or customers get messaged about old purchases. | false. |
payment_method | string | Any text. | — |
promo_code | string | Any text. | — |
order_link | string | Any text; a URL is expected but not enforced. | — |
tracking | object | link, number, carrier — all strings, all optional. | — |
shipping_address | object | Must be an object. country, city, street, postal_code — all strings, all optional; a partial address is fine. | — |
products | array | Must be an array of objects. Each entry needs a non-empty id (string); name (string) and quantity (integer) are accepted but not stored — only the ids are kept. | Empty. |
customer | object | Must be an object. See below. | — |
Optional — the customer object
| Key | Type | Allowed values / rule | Default if omitted |
|---|---|---|---|
name | string | Any text. | "-" |
gender | string | Exactly male or female (case-insensitive). Anything else is rejected. | — |
birthday | string | ISO-8601 date, 1990-04-01. A full timestamp is accepted — only the date part is read. | — |
opted_in | boolean | true or false — not "true". Whether the customer may be messaged for marketing. | true |
language | string | en, ar, es, pt_BR (also english, arabic, …). An unrecognised value falls back to English rather than failing. | en |
email | string | Any text; a valid address is expected but not enforced. | — |
external_id | string | Your own customer id. Only kept if it is numeric — a non-numeric value is accepted and dropped. | — |
avatar | string | Any text; a URL is expected but not enforced. | — |
curl -X POST 'https://pixel-gateway.businesschat.io/v1/track' \
-H 'Authorization: Bearer YOUR_TRACK_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"event": "order_created",
"userId": "+966501234567",
"data": {
"order_id": 1001,
"reference_id": "ORD-1001",
"status": "processing",
"total": { "amount": 149.00, "currency": "SAR" },
"created_at": "2026-09-06T10:00:00Z",
"order_link": "https://shop.example/orders/1001",
"products": [ { "id": "SKU-1", "name": "Blue Shirt", "quantity": 2 } ],
"customer": { "name": "Sara" }
}
}'Reporting a status change later is the same call with order_updated and the new status:
curl -X POST 'https://pixel-gateway.businesschat.io/v1/track' \
-H 'Authorization: Bearer YOUR_TRACK_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"event": "order_updated",
"userId": "+966501234567",
"data": {
"order_id": 1001,
"status": "paid",
"total": { "amount": 149.00, "currency": "SAR" },
"created_at": "2026-09-06T10:00:00Z",
"updated_at": "2026-09-06T11:15:00Z"
}
}'Status values#
Send one of these words, lower-case. They are listed in lifecycle order:
new, created, pending, payment_pending, authorized, partially_paid, processing, under_review, preparing, ready, shipped, delivering, paid, delivered, completed, closed, abandoned, restored, restoring, partially_refunded, cancelled, refunded, failed, denied, voided, expired.
A status outside this list is accepted and recorded, but drives nothing: no status automation runs and the order is left out of revenue attribution. Nothing in the response tells you — the request still returns 200 — so send a word from this list.
General events#
Any event name that is not a reserved name is a general event. Use these to record facts BusinessChat has no built-in behavior for — a loyalty tier change, a newsletter signup — so you can report on them alongside everything else.
| Field | Required | Description |
|---|---|---|
event | Yes | Your event name. |
userId | Yes | The contact's phone number. A general event must name a contact. |
data | Optional | Any properties. The whole request body is recorded exactly as you sent it. |
curl -X POST 'https://pixel-gateway.businesschat.io/v1/track' \
-H 'Authorization: Bearer YOUR_TRACK_TOKEN' \
-H 'Content-Type: application/json' \
-d '{ "event": "loyalty_tier_changed", "userId": "+966501234567",
"data": { "tier": "gold", "previous": "silver" } }'Like every accepted event, a general event returns its eventId. There is no sessionId requirement.
Responses#
Every accepted event, reserved or general, returns 200 with the ID we assigned it:
{ "eventId": "px-1f3a9c20-6b7e-4a51-9d2c-8e0b4f7a1c33" }A request without a valid track token returns 401, whatever is wrong with the token, and nothing is recorded:
{ "error": "Missing or invalid track token. Send it as `Authorization: Bearer <token>`." }A request that fails validation returns 422 with a message naming the problem:
{ "error": "Missing field: data.url, required for session_started" }A 200 means the event was accepted, not that every downstream automation ran. A value we can accept but cannot fully interpret is recorded and then dropped from journey processing — see Good to know.
Errors#
A missing, malformed or invalid track token returns 401 before the body is read; see Responses. All validation failures return 422 with an error string. The full set:
| Message | Cause |
|---|---|
Body is not a JSON object | The body did not parse as a JSON object. |
Missing field: event | event is absent or blank. |
Missing field: sessionId, required for a reserved event | A reserved event has no sessionId. |
Missing field: userId, required for <event> | An event that needs a contact (a general event, or contact_identified) has no userId. |
Field userId is not a phone number: <value> | userId is not a recognizable phone number. |
Missing field: data.<field>, required for <event> | A reserved event is missing a required data key. |
Field data.productCurrency is not an ISO-4217 currency code: <value> | A product_viewed sent a productPrice with an invalid productCurrency. |
Field data.<field> must be <expected> | An order field is present but unusable, and the message names it: a non-numeric order_id, a total.amount that is not a number, a total.currency that is not ISO-4217, a timestamp with no timezone, a historical or customer.opted_in that is not true/false, a shipping_address or customer that is not an object, a products that is not an array or whose entry has no id, an unrecognised customer.gender, or an unreadable customer.birthday. |
Field occurredAt is not an ISO-8601 timestamp: <value> | occurredAt could not be parsed. |
Field data must be an object | data was present but not a JSON object. |
Good to know#
- No idempotency. Every accepted call mints a new
eventId. If you retry a request, you create a duplicate event — retry only when you did not receive a200. - Timestamps are for you, timing is on receipt.
occurredAtis recorded as you send it (useful for backfills), but journeys and automations run against the moment we receive the event. - Accepted-then-dropped. Beyond the checks listed above, a value we cannot interpret downstream — an unknown enum name, a wrongly typed field — costs the whole event: you still get
200, but nothing is acted on. Send valid values, and use the exactstatenames and the"productId:qty"format shown above. - The envelope wins over
data. An envelope field repeated insidedatais ignored; you cannot overridesessionIdor the contact throughdata, and the workspace always comes from your token. - Popup events are widget-only.
popup_viewed,cta_clicked, andpopup_dismissedare emitted by the BusinessChat on-site widget and carry IDs only it knows. Sending them from a backend records them as general events.