Capi Agent API — Custom store
Your store's system sends its products, orders, abandoned carts and customers, and Capi Agent's assistant, campaigns and automations work exactly as they do on a Salla or Shopify store. The other way round, your system receives signed events: discount codes, customers confirming or cancelling orders, and opt-outs.
Paste the guide into Claude or ChatGPT to connect your store, or import the OpenAPI file into Postman or Insomnia.
https://capiagent.com/api/ingest/v1On this page
Quickstart
- 1In the Capi Agent dashboard open Integrations → Custom store and create a test key first.
- 2Check the key with GET /ping.
- 3Send your products with POST /products, every order with POST /orders, and status changes with PATCH /orders/{id}.
- 4When every call succeeds with the test key, create a live key and swap it in.
- 5Optional: add an events endpoint to receive coupons, order confirmations and opt-outs.
- 6Add the widget line to every page, call AISA.track for the three events and AISA.identify for the logged-in customer (see «Widget & browser events»).
curl -X GET https://capiagent.com/api/ingest/v1/ping \
-H "Authorization: Bearer capi_live_xxxxxxxxxxxxxxxxxxxxxxxx"Authentication & keys
Send the key as Authorization: Bearer <key> on every request, or as X-API-Key when your client cannot set Authorization. A key is shown once, at creation; we store only its SHA-256.
A key opens exactly one brand; no request can name another.
Test keys
A key starting with capi_test_ validates every request and answers with exactly the same results — but writes nothing: no products, no orders, no messages. Every answer to it carries "test": true.
Conventions
- Money
- Major units (199.99), not cents — as a JSON number, not a string: any string amount (even "199.99") is read by guesswork from the currency and draws a warning. Converted by the brand's currency — three decimals for KWD, BHD and OMR.
- Phone numbers
- International, digits only (966501234567). It is the customer's WhatsApp identity and how the assistant finds their orders. A local number is refused with a warning.
- Idempotency
- Every write is matched on your own id, so sending the same request again updates instead of duplicating. Retry freely.
- Dates
- ISO 8601 with a time zone (2026-10-06T09:00:00Z). A date without a zone is read in the server's time and draws a warning.
- Results
- A 200 carries one result per item; a bad item never sinks the rest of the request. A failed item has an errorCode, and any field name we do not know comes back as a warning in warnings.
- Omitted vs null
- POST /products and POST /orders replace the record: send every field each time. POST /customers merges and never clears. PATCH changes only what you send, and null clears a field — except costPrice, shipment and refunded, where null is ignored (send 0 to reset refunded); a null inside shipment clears that one sub-field. statusName is read only together with status.
- Text vs codes
- Messages and warnings are human text and may be reworded. Branch your code on error.code, errorCode and warningCodes only.
- Event order
- Events are not guaranteed to arrive in order — a retry can land after a newer event. Deduplicate on id, and order by the event's own time (respondedAt, unsubscribedAt or createdAt).
- Compatibility
- Within v1 nothing is removed or renamed — fields, events, statuses and error codes are only added. Make your code ignore anything it does not know, and it keeps working with every addition.
Changelog
Everything added to the API, newest first. Nothing has been removed from v1.
- 16.10.2026
- errorCode on every failed item in a batch.
- Warnings in warnings: an unknown field name, an amount sent as a string, a date without a time zone, an unsupported currency.
- apiVersion in every webhook event, and the X-Capi-Api-Version header.
- Redeeming a coupon again for the same orderId answers success (replayed) instead of already_used.
- warningCodes next to warnings, reconciled with complete, and every item's result on 422 all_failed.
- The signature header may carry several v1 (for secret rotation); it is valid when any matches.
- discountType on coupons, and usageLimit as a number.
- Published rules: omitted vs null, text vs codes, event order.
- PATCH /products: sending price with salePrice: null ends a sale and clears the price before discount.
- 15.10.2026
- Signed customer identity in AISA.identify (hash), to join their conversations across devices.
- 08.10.2026
- tracking on POST /orders, so ad sales are counted.
- items[].productId and couponCode on orders.
- The widget and browser events on the storefront.
- The store currency is set from the first data that arrives.
- 06.10.2026
- Test keys (capi_test_).
- POST /customers and PATCH /products/:id.
- order.cod_confirmed, order.cod_cancelled and customer.unsubscribed events, with retries.
- Coupon check, redeem and release.
- The OpenAPI file and the developers page.
- 05.10.2026
- POST /carts for abandoned carts.
- The coupon.create event.
- 21.09.2026
- First release: GET /ping, POST /products, POST /orders, PATCH /orders/:id and DELETE /products/:id.
Errors
Branch on error.code; error.message is human-readable Arabic text — do not parse it.
unauthorized | Missing, unknown or revoked key. |
rate_limited | Over the limit. Wait for Retry-After. |
invalid_json | The body is not valid JSON. |
invalid_body | The request's shape is wrong (e.g. no products array). |
not_found | No item with this id. |
all_failed | Every item failed; the message carries the first reason. |
payload_too_large | Over 4 MB — split it. |
already_used | The coupon was already redeemed (409). |
expired | The coupon has expired (410). |
not_redeemed | There is no redemption to release (409). |
internal_error | Our side. Retry shortly. |
Partial success
When some items succeed and some fail, the answer is 200 with the reason for each failed item in results. Do not treat a 200 as complete success before reading failed.
Limits
- 300 requests a minute per key. Over that you get 429 with Retry-After.
- 4 MB per request.
- 500 products, 100 orders, 100 carts or 500 customers per request.
Reference
Every endpoint with its request shape and ready-made samples in four languages.
GETCheck your key
https://capiagent.com/api/ingest/v1/pingReturns the brand's name, its currency and the key's mode (live or test).
curl -X GET https://capiagent.com/api/ingest/v1/ping \
-H "Authorization: Bearer capi_live_xxxxxxxxxxxxxxxxxxxxxxxx"{
"ok": true,
"mode": "live",
"store": {
"name": "Lumi Store",
"currency": "SAR"
}
}POSTCreate or update products
https://capiagent.com/api/ingest/v1/productsUp to 500 per request; only id and name are required. Send complete: true only when the request holds your whole catalogue.
| Field | Type | Required | Notes |
|---|---|---|---|
products | object[] | Yes | |
products[].id | string | Yes | Your own product id. Every request is matched on it — re-sending the same id updates the product, never duplicates it. |
products[].name | string | Yes | Product name as the customer sees it. |
products[].description | string | null | Plain text or HTML. HTML is stripped for the assistant. | |
products[].price | number | string | null | Major units (199.99), as a JSON number (a string is accepted but read by guesswork and warned, `string_amount`). When `salePrice` is lower, this reads as the price before discount. | |
products[].salePrice | number | string | null | The current price when the product is discounted. | |
products[].compareAtPrice | number | string | null | An explicit "was" price, when you keep one separately. | |
products[].costPrice | number | string | null | What the item costs you (cost of goods). Send it — profit, margin and the ads stock/margin rules need it; never shown to customers. Per variant too, when sizes cost differently. | |
products[].currency | string | null | ISO 4217 code. Defaults to the brand's currency. | |
products[].sku | string | null | Stock keeping unit. | |
products[].url | string (uri) | null | Public product page — the link the assistant sends. | |
products[].images | string (uri)[] | Image URLs, main image first. Up to 20. | |
products[].categories | string[] | Category names, broadest first. | |
products[].stock | integer | null | Units available. Decides availability; 0 means out of stock. | |
products[].availability | "in_stock" | "out_of_stock" | null | Used only when `stock` is not sent. Without either, availability is "unknown" and the assistant never claims it is sold out. | |
products[].published | boolean | `false` archives the product immediately (it stays in order history). | |
products[].variants | object[] | Sizes, colours and other options, each with its own id, price and stock. | |
products[].variants[].id | string | Yes | |
products[].variants[].name | string | null | ||
products[].variants[].sku | string | null | ||
products[].variants[].price | number | string | null | ||
products[].variants[].compareAtPrice | number | string | null | ||
products[].variants[].costPrice | number | string | null | ||
products[].variants[].stock | integer | null | ||
complete | boolean | `true` says this request is your whole catalogue: products missing from it are archived. Ignored if any item fails. Use it only when the catalogue fits in one request, and never together with a catalog feed link. |
curl -X POST https://capiagent.com/api/ingest/v1/products \
-H "Authorization: Bearer capi_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"products": [
{
"id": "SKU-1001",
"name": "Cotton shirt",
"price": 199.99,
"salePrice": 149.99,
"costPrice": 80,
"stock": 12,
"url": "https://mystore.com/p/1001",
"images": [
"https://mystore.com/img/1001.jpg"
],
"categories": [
"Clothing"
],
"variants": [
{
"id": "SKU-1001-M",
"name": "M",
"price": 149.99,
"costPrice": 80,
"stock": 5
}
]
}
]
}'{
"ok": true,
"succeeded": 1,
"failed": 0,
"results": [
{
"id": "SKU-1001",
"ok": true
}
]
}PATCHpatchProduct
https://capiagent.com/api/ingest/v1/products/{id}| Field | Type | Required | Notes |
|---|---|---|---|
price | number | string | null | ||
salePrice | number | string | null | ||
compareAtPrice | number | string | null | ||
costPrice | number | string | null | ||
stock | integer | null | ||
availability | "in_stock" | "out_of_stock" | null | ||
published | boolean |
curl -X PATCH https://capiagent.com/api/ingest/v1/products/SKU-1001 \
-H "Authorization: Bearer capi_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"price": 199.99,
"salePrice": 149.99,
"costPrice": 80,
"stock": 3
}'{
"ok": true,
"succeeded": 1,
"failed": 0,
"results": [
{
"id": "SKU-1001",
"ok": true
}
]
}DELETEArchive a product
https://capiagent.com/api/ingest/v1/products/{id}Takes it out of the catalogue; order history keeps it.
curl -X DELETE https://capiagent.com/api/ingest/v1/products/SKU-1001 \
-H "Authorization: Bearer capi_live_xxxxxxxxxxxxxxxxxxxxxxxx"{
"ok": true,
"succeeded": 1,
"failed": 0,
"results": [
{
"id": "SKU-1001",
"ok": true
}
]
}POSTCreate or update orders
https://capiagent.com/api/ingest/v1/ordersSend every new order. backfill: true imports old orders without firing messages or automations.
| Field | Type | Required | Notes |
|---|---|---|---|
orders | object[] | Yes | |
orders[].id | string | Yes | Your order number. Re-sending the same id updates the order; its items are replaced in full. |
orders[].status | string | Yes | Order status: pending, processing, shipped, delivered, cancelled or refunded — or your system's own status as is (e.g. awaiting_pickup). An unknown one is kept and the merchant decides once in the dashboard what it means; until then it sends nothing. A change fires order automations and the customer's status message, as on any platform. |
orders[].statusName | string | null | Optional. The status's name as your store's admin shows it (e.g. "Awaiting pickup") — what the merchant sees when deciding. Defaults to the code. | |
orders[].total | number | string | Yes | Order total in major units. |
orders[].currency | string | ISO 4217 code. Defaults to the brand's currency. | |
orders[].placedAt | string | ISO 8601. A date in the future is refused. | |
orders[].customer | object | Who ordered. The phone must be international, digits only (966501234567) — it is the customer's WhatsApp identity and how the assistant finds their orders. | |
orders[].customer.name | string | null | ||
orders[].customer.phone | string | null | ||
orders[].customer.email | string (email) | null | ||
orders[].items | object[] | Order lines. Send each line's `productId` (the id you used on /products) to link it to the catalogue; without it the name is matched exactly. | |
orders[].items[].productId | string | null | ||
orders[].items[].name | string | Yes | |
orders[].items[].quantity | integer | Yes | |
orders[].items[].price | number | string | Yes | |
orders[].subtotal | number | string | null | ||
orders[].discount | number | string | null | ||
orders[].tax | number | string | null | ||
orders[].shipping | number | string | null | ||
orders[].refunded | number | string | null | ||
orders[].shipment | object | Carrier and tracking — the assistant answers "where is my order?" from these fields. | |
orders[].shipment.carrier | string | null | ||
orders[].shipment.trackingNumber | string | null | ||
orders[].shipment.trackingUrl | string (uri) | null | ||
orders[].shipment.shippedAt | string | null | ||
orders[].shipment.deliveredAt | string | null | ||
orders[].shipment.estimatedDeliveryAt | string | null | ||
orders[].paymentMethod | string | null | `cod` (or `cash_on_delivery`) enables cash-on-delivery confirmation over WhatsApp for this order. | |
orders[].paymentUrl | string (uri) | null | An https link where the customer pays this same order online with your prepaid discount. Sent after they confirm a COD order, when that offer is on. | |
orders[].couponCode | string | null | ||
orders[].backfill | boolean | `true` stores the order without firing automations, notifications or messages — for importing history. | |
orders[].tracking | object | Optional. The buyer's browser at checkout — landingUrl (with the ad's fbclid/gclid/ttclid/ScCid), clickIds, the pixel cookies fbp/fbc, userAgent and ip — so the ad platforms can count a sale from an ad that never went through the chat. The IP is shared only when the merchant turned on enhanced matching. If the store has a cookie consent banner, send it only when the buyer accepted marketing cookies — the condition is the consent, not the pixel loading (a blocked pixel still sends, without fbp/fbc). | |
orders[].tracking.landingUrl | string (uri) | null | ||
orders[].tracking.userAgent | string | null | ||
orders[].tracking.ip | string | null | ||
orders[].tracking.fbp | string | null | ||
orders[].tracking.fbc | string | null | ||
orders[].tracking.clickIds | object | ||
orders[].tracking.clickIds.fbclid | string | ||
orders[].tracking.clickIds.gclid | string | ||
orders[].tracking.clickIds.gbraid | string | ||
orders[].tracking.clickIds.wbraid | string | ||
orders[].tracking.clickIds.ttclid | string | ||
orders[].tracking.clickIds.ScCid | string |
curl -X POST https://capiagent.com/api/ingest/v1/orders \
-H "Authorization: Bearer capi_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"orders": [
{
"id": "10045",
"status": "processing",
"total": 349.98,
"currency": "SAR",
"placedAt": "2026-10-06T09:00:00Z",
"customer": {
"name": "Sara",
"phone": "966501234567",
"email": "sara@example.com"
},
"items": [
{
"productId": "SKU-1001",
"name": "Cotton shirt",
"quantity": 2,
"price": 174.99
}
],
"couponCode": "CAPI-7K3M9",
"paymentMethod": "cod",
"paymentUrl": "https://mystore.com/pay/10045"
}
]
}'{
"ok": true,
"succeeded": 1,
"failed": 0,
"results": [
{
"id": "10045",
"ok": true
}
]
}PATCHUpdate an order's status or shipping
https://capiagent.com/api/ingest/v1/orders/{id}Only the fields you send change. A status change sends the customer's order-status message per the merchant's settings.
| Field | Type | Required | Notes |
|---|---|---|---|
status | string | New status — one of ours or your system's own, as on POST /orders. Fires order.status_changed like any platform. | |
statusName | string | null | Optional. The status's name in your store's admin. | |
shipment | object | Only the fields you send change; a status-only patch keeps the stored tracking number. | |
shipment.carrier | string | null | ||
shipment.trackingNumber | string | null | ||
shipment.trackingUrl | string (uri) | null | ||
shipment.shippedAt | string | null | ||
shipment.deliveredAt | string | null | ||
shipment.estimatedDeliveryAt | string | null | ||
refunded | number | string | null | Amount refunded so far, in major units. | |
paymentMethod | string | null | The customer paid online after all (e.g. `card`): the order is no longer cash on delivery. | |
paymentUrl | string (uri) | null | Replace the order's online payment link. |
curl -X PATCH https://capiagent.com/api/ingest/v1/orders/10045 \
-H "Authorization: Bearer capi_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"status": "shipped",
"shipment": {
"carrier": "SMSA",
"trackingNumber": "290012345678",
"trackingUrl": "https://track.example.com/290012345678"
}
}'{
"ok": true,
"succeeded": 1,
"failed": 0,
"results": [
{
"id": "10045",
"ok": true
}
]
}POSTSend carts
https://capiagent.com/api/ingest/v1/cartsSend the whole cart every time it changes. An empty cart means it was closed.
| Field | Type | Required | Notes |
|---|---|---|---|
carts | object[] | Yes | |
carts[].id | string | Yes | Your cart or session id. Re-sending the same id updates the cart. |
carts[].currency | string | null | ||
carts[].total | number | string | null | ||
carts[].items | object[] | The whole cart every time, never a delta. An empty array closes the cart (bought or cleared). | |
carts[].items[].productId | string | null | The same id you send on POST /products — how the line links to the catalogue. | |
carts[].items[].name | string | null | ||
carts[].items[].quantity | integer | null | ||
carts[].items[].price | number | string | null | ||
carts[].customer | object | Contact for the recovery message. A phone is not marketing consent; sending is governed by the customer's subscription. | |
carts[].customer.name | string | null | ||
carts[].customer.phone | string | null | ||
carts[].customer.email | string (email) | null | ||
carts[].checkoutUrl | string (uri) | null | Your "resume this checkout" link, sent as-is in the recovery message. https only. | |
carts[].updatedAt | string | null | When the shopper last touched the cart (ISO 8601). Reminder timing is computed from it; a future value is clamped to now. |
curl -X POST https://capiagent.com/api/ingest/v1/carts \
-H "Authorization: Bearer capi_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"carts": [
{
"id": "cart-7f3a",
"currency": "SAR",
"total": 199.99,
"items": [
{
"productId": "SKU-1001",
"name": "Cotton shirt",
"quantity": 1,
"price": 199.99
}
],
"customer": {
"name": "Sara",
"phone": "966501234567"
},
"checkoutUrl": "https://mystore.com/checkout/cart-7f3a",
"updatedAt": "2026-10-06T08:40:00Z"
}
]
}'{
"ok": true,
"succeeded": 1,
"failed": 0,
"results": [
{
"id": "cart-7f3a",
"ok": true
}
]
}POSTSend customers
https://capiagent.com/api/ingest/v1/customersRegistered customers who may not have ordered yet, and opt-outs kept in sync from your system to ours.
| Field | Type | Required | Notes |
|---|---|---|---|
customers | object[] | Yes | |
customers[].id | string | Yes | Your own customer id — echoed back in the results. Customers are matched on phone, then email. |
customers[].name | string | null | ||
customers[].phone | string | null | International, digits only (966501234567). A local number is refused with a warning. | |
customers[].email | string (email) | null | Used as the identity when there is no phone. | |
customers[].country | string | null | ||
customers[].city | string | null | ||
customers[].acceptsMarketing | boolean | null | The customer's marketing choice in your system. `false` unsubscribes them at once. `true` confirms a subscription but never re-subscribes a customer who opted out themselves — a "STOP" on WhatsApp stands. |
curl -X POST https://capiagent.com/api/ingest/v1/customers \
-H "Authorization: Bearer capi_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"customers": [
{
"id": "U-501",
"name": "Sara",
"phone": "966501234567",
"email": "sara@example.com",
"city": "Riyadh",
"acceptsMarketing": true
},
{
"id": "U-502",
"email": "omar@example.com",
"acceptsMarketing": false
}
]
}'{
"ok": true,
"succeeded": 2,
"failed": 0,
"results": [
{
"id": "U-501",
"ok": true
},
{
"id": "U-502",
"ok": true
}
]
}GETcheckCoupon
https://capiagent.com/api/ingest/v1/coupons/{id}curl -X GET https://capiagent.com/api/ingest/v1/coupons/CAPI-7K3M9 \
-H "Authorization: Bearer capi_live_xxxxxxxxxxxxxxxxxxxxxxxx"{
"ok": true,
"valid": true,
"coupon": {
"code": "CAPI-7K3M9",
"discountType": "percent",
"percent": 10,
"expiresAt": "2026-10-12T18:00:00.000Z",
"usageLimit": 1
}
}POSTredeemCoupon
https://capiagent.com/api/ingest/v1/coupons/{id}/redeem| Field | Type | Required | Notes |
|---|---|---|---|
orderId | string | Your order id (a string; a number is accepted; longer than 120 characters it is cut to 120). Send it: a retry for the same order (the first answer was lost) is answered 200 with `replayed: true`, not `already_used`. The body is optional — a missing or malformed body is ignored, it never blocks the redeem. |
curl -X POST https://capiagent.com/api/ingest/v1/coupons/CAPI-7K3M9/redeem \
-H "Authorization: Bearer capi_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"orderId": "10045"
}'{
"ok": true,
"redeemed": true,
"coupon": {
"code": "CAPI-7K3M9",
"discountType": "percent",
"percent": 10,
"expiresAt": "2026-10-12T18:00:00.000Z",
"usageLimit": 1,
"redeemedAt": "2026-10-06T10:00:00.000Z"
}
}DELETEreleaseCoupon
https://capiagent.com/api/ingest/v1/coupons/{id}/redeemcurl -X DELETE https://capiagent.com/api/ingest/v1/coupons/CAPI-7K3M9/redeem \
-H "Authorization: Bearer capi_live_xxxxxxxxxxxxxxxxxxxxxxxx"{
"ok": true,
"released": true
}Webhooks
One endpoint in your system receives every event. Each is a signed POST; answer 2xx.
Setup
- Under Custom store in the dashboard, save your endpoint URL (https only).
- Copy the signing secret shown once (capi_whsec_…) into your system.
- Press "Send test ping" and make sure your endpoint verifies the signature and answers 2xx.
Events
coupon.create | The assistant needs a discount code for a customer. Create it and answer within 10 seconds. Not retried. |
order.cod_confirmed | The customer confirmed a cash-on-delivery order on WhatsApp. |
order.cod_cancelled | The customer cancelled a cash-on-delivery order. When cancelRequested is true, cancel it and restock. |
customer.unsubscribed | The customer opted out of marketing with us. Unsubscribe them in your list too. |
ping | A test event from the dashboard button. |
POST https://your-store.com/capi/webhook
Content-Type: application/json
X-Capi-Event: order.cod_cancelled
X-Capi-Api-Version: v1
X-Capi-Event-Id: evt_cm9x2k1a80001
X-Capi-Delivery-Attempt: 1
X-Capi-Signature: t=1759740720,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
"id": "evt_cm9x2k1a80001",
"event": "order.cod_cancelled",
"apiVersion": "v1",
"createdAt": "2026-10-06T09:12:00.000Z",
"data": {
"orderId": "10045",
"outcome": "cancelled",
"cancelRequested": true,
"note": "Capi Agent: the customer cancelled this cash-on-delivery order on WhatsApp.",
"channel": "whatsapp",
"respondedAt": "2026-10-06T09:12:00.000Z"
}
}Verifying the signature
X-Capi-Signature carries t (a timestamp) and v1 (HMAC-SHA256 of t.<raw body> with your secret). Compute it yourself, compare in constant time, and reject a t older than 5 minutes. The header may carry several v1 (during a secret rotation) and keys you do not know: it is valid when any v1 matches; ignore the rest. Use the raw body exactly as received, not a re-serialised object.
Retries
Every event other than coupon.create and ping is retried up to 8 times over a day and a half (1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h) with the same id. Store the ids you handled and skip repeats. When retries run out the merchant is notified and can resend from the event log.
A complete receiver
Verify, skip duplicates, act, answer 2xx — in that order.
import crypto from "node:crypto";
// Use the RAW request body — not a re-serialised object.
// The header may carry several v1= (while a secret is being rotated) and keys
// you do not know yet: it is valid when ANY v1 matches. Ignore the rest.
export function verifyCapiSignature(rawBody, header, secret) {
const parts = header.split(",").map((p) => p.trim().split("="));
const t = Number(parts.find(([key]) => key === "t")?.[1]);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // older than 5 minutes
const expected = Buffer.from(crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"));
return parts.some(
([key, value]) => key === "v1" && Buffer.byteLength(value ?? "") === expected.length && crypto.timingSafeEqual(expected, Buffer.from(value)),
);
}app.post("/capi/webhook", express.raw({ type: "application/json" }), async (req, res) => {
const raw = req.body.toString("utf8");
if (!verifyCapiSignature(raw, req.get("X-Capi-Signature") ?? "", process.env.CAPI_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(raw);
if (await alreadyHandled(event.id)) return res.status(200).end(); // retries keep the same id
switch (event.event) {
case "coupon.create": {
const coupon = await createCoupon(event.data); // answer within 10 seconds
await markHandled(event.id);
return res.json({ code: coupon.code, id: coupon.id });
}
case "order.cod_cancelled":
if (event.data.cancelRequested) await cancelOrder(event.data.orderId);
else await addOrderNote(event.data.orderId, event.data.note);
break;
case "order.cod_confirmed":
await markConfirmed(event.data.orderId);
break;
case "customer.unsubscribed":
await unsubscribe(event.data.phone, event.data.email);
break;
}
await markHandled(event.id);
res.status(200).end();
});Widget & browser events
This part runs on the store pages themselves (browser JavaScript), not on your server. It shows the chat assistant, and it ties what the visitor does — viewing a product, adding to cart, starting checkout — to that same visitor, which live-chat campaigns and their sales attribution rely on.
Install the widget
Paste the line before the end of the body tag in the template every page shares, including checkout and the thank-you page. Replace STORE_ID with your store id from the dashboard. Visitors see the widget after «Publish now».
<script src="https://capiagent.com/widget.js" data-store-id="STORE_ID" async></script>Purchase-funnel events — required
PRODUCT_VIEW | When every product page loads, and on every navigation to another product in an SPA store. |
ADD_TO_CART | After every successful add to cart, from any button: product page, listings, quick-view, «buy now». |
CHECKOUT_STARTED | When the checkout page opens, then again right after the order is created, with orderExternalId. |
Rules that must hold
- productExternalId is exactly the product id from POST /products — not the sku, not the variant id.
- orderExternalId is exactly the order id from POST /orders — not the cart id. It is how the order is credited to a live-chat campaign.
- If your code may run before the widget loads, use AISA.q.push as in the sample; the event is kept and sent on load.
- A no-JavaScript alternative: data-capi-product-id on the widget tag on the product page, and data-capi-checkout="1" on checkout.
- A purchase event is not accepted from the browser: orders and amounts come from POST /orders only.
- If the merchant set allowed domains for the widget, list every store domain or events are refused (403).
// Product page — on load (and on every SPA navigation to a product)
AISA.track("PRODUCT_VIEW", { productExternalId: "SKU-1001" });
// After every successful add to cart, from any button
AISA.track("ADD_TO_CART", { productExternalId: "SKU-1001", quantity: 1 });
// Checkout page opened
AISA.track("CHECKOUT_STARTED", {});
// Thank-you page — the order id you send to POST /orders
AISA.track("CHECKOUT_STARTED", { orderExternalId: "10045" });
// Safe before widget.js has loaded (queued, sent on load):
(window.AISA = window.AISA || {}).q = window.AISA.q || [];
window.AISA.q.push(["ADD_TO_CART", { productExternalId: "SKU-1001", quantity: 1 }]);The logged-in customer — AISA.identify
Call it on every page while the customer is logged in, with their international phone or email (the name is optional). The widget sends it once, then again only when the details change. The customer is then never asked for their details in the pre-chat form, and their chats, orders and campaign credit join one customer. Nobody is subscribed to marketing by this call: consent arrives through acceptsMarketing on POST /customers.
A guide ready for your coding assistant
In the Capi dashboard ← Custom store, the «Copy developer guide» button copies a complete guide with your store id: what each feature needs, a required checklist, every field, and a ready request for Claude or ChatGPT that carries out the integration and reports back item by item.
// Every page, while the customer is logged in (phone or email required)
AISA.identify({ name: "Sara", phone: "966501234567", email: "sara@example.com" });
// Or, before widget.js has loaded:
(window.AISA = window.AISA || {}).q = window.AISA.q || [];
window.AISA.q.push(["identify", { name: "Sara", phone: "966501234567", email: "sara@example.com" }]);
// Signed (recommended): add "hash", computed on YOUR server — see SIGNED_IDENTIFY_SAMPLE.
// Then the same customer keeps one chat history in every browser.
AISA.identify({ name: "Sara", email: "sara@example.com", hash: "<?= $capiHash ?>" });Support
A question this page does not answer, or a request refused for a reason you cannot see? Write to us with the request and the full response.
Contact support