For developers

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/v1
Code language
On this page

Quickstart

  1. 1In the Capi Agent dashboard open Integrations → Custom store and create a test key first.
  2. 2Check the key with GET /ping.
  3. 3Send your products with POST /products, every order with POST /orders, and status changes with PATCH /orders/{id}.
  4. 4When every call succeeds with the test key, create a live key and swap it in.
  5. 5Optional: add an events endpoint to receive coupons, order confirmations and opt-outs.
  6. 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»).
Request
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.

  1. 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.
  2. 15.10.2026
    • Signed customer identity in AISA.identify (hash), to join their conversations across devices.
  3. 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.
  4. 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.
  5. 05.10.2026
    • POST /carts for abandoned carts.
    • The coupon.create event.
  6. 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.

unauthorizedMissing, unknown or revoked key.
rate_limitedOver the limit. Wait for Retry-After.
invalid_jsonThe body is not valid JSON.
invalid_bodyThe request's shape is wrong (e.g. no products array).
not_foundNo item with this id.
all_failedEvery item failed; the message carries the first reason.
payload_too_largeOver 4 MB — split it.
already_usedThe coupon was already redeemed (409).
expiredThe coupon has expired (410).
not_redeemedThere is no redemption to release (409).
internal_errorOur 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

GEThttps://capiagent.com/api/ingest/v1/ping

Returns the brand's name, its currency and the key's mode (live or test).

Request
curl -X GET https://capiagent.com/api/ingest/v1/ping \
  -H "Authorization: Bearer capi_live_xxxxxxxxxxxxxxxxxxxxxxxx"
Response
JSON
{
  "ok": true,
  "mode": "live",
  "store": {
    "name": "Lumi Store",
    "currency": "SAR"
  }
}

POSTCreate or update products

POSThttps://capiagent.com/api/ingest/v1/products

Up to 500 per request; only id and name are required. Send complete: true only when the request holds your whole catalogue.

FieldTypeRequiredNotes
productsobject[]Yes
products[].idstringYesYour own product id. Every request is matched on it — re-sending the same id updates the product, never duplicates it.
products[].namestringYesProduct name as the customer sees it.
products[].descriptionstring | nullPlain text or HTML. HTML is stripped for the assistant.
products[].pricenumber | string | nullMajor 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[].salePricenumber | string | nullThe current price when the product is discounted.
products[].compareAtPricenumber | string | nullAn explicit "was" price, when you keep one separately.
products[].costPricenumber | string | nullWhat 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[].currencystring | nullISO 4217 code. Defaults to the brand's currency.
products[].skustring | nullStock keeping unit.
products[].urlstring (uri) | nullPublic product page — the link the assistant sends.
products[].imagesstring (uri)[]Image URLs, main image first. Up to 20.
products[].categoriesstring[]Category names, broadest first.
products[].stockinteger | nullUnits available. Decides availability; 0 means out of stock.
products[].availability"in_stock" | "out_of_stock" | nullUsed only when `stock` is not sent. Without either, availability is "unknown" and the assistant never claims it is sold out.
products[].publishedboolean`false` archives the product immediately (it stays in order history).
products[].variantsobject[]Sizes, colours and other options, each with its own id, price and stock.
products[].variants[].idstringYes
products[].variants[].namestring | null
products[].variants[].skustring | null
products[].variants[].pricenumber | string | null
products[].variants[].compareAtPricenumber | string | null
products[].variants[].costPricenumber | string | null
products[].variants[].stockinteger | null
completeboolean`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.
Request
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
        }
      ]
    }
  ]
}'
Response
JSON
{
  "ok": true,
  "succeeded": 1,
  "failed": 0,
  "results": [
    {
      "id": "SKU-1001",
      "ok": true
    }
  ]
}

PATCHpatchProduct

PATCHhttps://capiagent.com/api/ingest/v1/products/{id}
FieldTypeRequiredNotes
pricenumber | string | null
salePricenumber | string | null
compareAtPricenumber | string | null
costPricenumber | string | null
stockinteger | null
availability"in_stock" | "out_of_stock" | null
publishedboolean
Request
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
}'
Response
JSON
{
  "ok": true,
  "succeeded": 1,
  "failed": 0,
  "results": [
    {
      "id": "SKU-1001",
      "ok": true
    }
  ]
}

DELETEArchive a product

DELETEhttps://capiagent.com/api/ingest/v1/products/{id}

Takes it out of the catalogue; order history keeps it.

Request
curl -X DELETE https://capiagent.com/api/ingest/v1/products/SKU-1001 \
  -H "Authorization: Bearer capi_live_xxxxxxxxxxxxxxxxxxxxxxxx"
Response
JSON
{
  "ok": true,
  "succeeded": 1,
  "failed": 0,
  "results": [
    {
      "id": "SKU-1001",
      "ok": true
    }
  ]
}

POSTCreate or update orders

POSThttps://capiagent.com/api/ingest/v1/orders

Send every new order. backfill: true imports old orders without firing messages or automations.

FieldTypeRequiredNotes
ordersobject[]Yes
orders[].idstringYesYour order number. Re-sending the same id updates the order; its items are replaced in full.
orders[].statusstringYesOrder 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[].statusNamestring | nullOptional. 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[].totalnumber | stringYesOrder total in major units.
orders[].currencystringISO 4217 code. Defaults to the brand's currency.
orders[].placedAtstringISO 8601. A date in the future is refused.
orders[].customerobjectWho 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.namestring | null
orders[].customer.phonestring | null
orders[].customer.emailstring (email) | null
orders[].itemsobject[]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[].productIdstring | null
orders[].items[].namestringYes
orders[].items[].quantityintegerYes
orders[].items[].pricenumber | stringYes
orders[].subtotalnumber | string | null
orders[].discountnumber | string | null
orders[].taxnumber | string | null
orders[].shippingnumber | string | null
orders[].refundednumber | string | null
orders[].shipmentobjectCarrier and tracking — the assistant answers "where is my order?" from these fields.
orders[].shipment.carrierstring | null
orders[].shipment.trackingNumberstring | null
orders[].shipment.trackingUrlstring (uri) | null
orders[].shipment.shippedAtstring | null
orders[].shipment.deliveredAtstring | null
orders[].shipment.estimatedDeliveryAtstring | null
orders[].paymentMethodstring | null`cod` (or `cash_on_delivery`) enables cash-on-delivery confirmation over WhatsApp for this order.
orders[].paymentUrlstring (uri) | nullAn 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[].couponCodestring | null
orders[].backfillboolean`true` stores the order without firing automations, notifications or messages — for importing history.
orders[].trackingobjectOptional. 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.landingUrlstring (uri) | null
orders[].tracking.userAgentstring | null
orders[].tracking.ipstring | null
orders[].tracking.fbpstring | null
orders[].tracking.fbcstring | null
orders[].tracking.clickIdsobject
orders[].tracking.clickIds.fbclidstring
orders[].tracking.clickIds.gclidstring
orders[].tracking.clickIds.gbraidstring
orders[].tracking.clickIds.wbraidstring
orders[].tracking.clickIds.ttclidstring
orders[].tracking.clickIds.ScCidstring
Request
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"
    }
  ]
}'
Response
JSON
{
  "ok": true,
  "succeeded": 1,
  "failed": 0,
  "results": [
    {
      "id": "10045",
      "ok": true
    }
  ]
}

PATCHUpdate an order's status or shipping

PATCHhttps://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.

FieldTypeRequiredNotes
statusstringNew status — one of ours or your system's own, as on POST /orders. Fires order.status_changed like any platform.
statusNamestring | nullOptional. The status's name in your store's admin.
shipmentobjectOnly the fields you send change; a status-only patch keeps the stored tracking number.
shipment.carrierstring | null
shipment.trackingNumberstring | null
shipment.trackingUrlstring (uri) | null
shipment.shippedAtstring | null
shipment.deliveredAtstring | null
shipment.estimatedDeliveryAtstring | null
refundednumber | string | nullAmount refunded so far, in major units.
paymentMethodstring | nullThe customer paid online after all (e.g. `card`): the order is no longer cash on delivery.
paymentUrlstring (uri) | nullReplace the order's online payment link.
Request
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"
  }
}'
Response
JSON
{
  "ok": true,
  "succeeded": 1,
  "failed": 0,
  "results": [
    {
      "id": "10045",
      "ok": true
    }
  ]
}

POSTSend carts

POSThttps://capiagent.com/api/ingest/v1/carts

Send the whole cart every time it changes. An empty cart means it was closed.

FieldTypeRequiredNotes
cartsobject[]Yes
carts[].idstringYesYour cart or session id. Re-sending the same id updates the cart.
carts[].currencystring | null
carts[].totalnumber | string | null
carts[].itemsobject[]The whole cart every time, never a delta. An empty array closes the cart (bought or cleared).
carts[].items[].productIdstring | nullThe same id you send on POST /products — how the line links to the catalogue.
carts[].items[].namestring | null
carts[].items[].quantityinteger | null
carts[].items[].pricenumber | string | null
carts[].customerobjectContact for the recovery message. A phone is not marketing consent; sending is governed by the customer's subscription.
carts[].customer.namestring | null
carts[].customer.phonestring | null
carts[].customer.emailstring (email) | null
carts[].checkoutUrlstring (uri) | nullYour "resume this checkout" link, sent as-is in the recovery message. https only.
carts[].updatedAtstring | nullWhen the shopper last touched the cart (ISO 8601). Reminder timing is computed from it; a future value is clamped to now.
Request
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"
    }
  ]
}'
Response
JSON
{
  "ok": true,
  "succeeded": 1,
  "failed": 0,
  "results": [
    {
      "id": "cart-7f3a",
      "ok": true
    }
  ]
}

POSTSend customers

POSThttps://capiagent.com/api/ingest/v1/customers

Registered customers who may not have ordered yet, and opt-outs kept in sync from your system to ours.

FieldTypeRequiredNotes
customersobject[]Yes
customers[].idstringYesYour own customer id — echoed back in the results. Customers are matched on phone, then email.
customers[].namestring | null
customers[].phonestring | nullInternational, digits only (966501234567). A local number is refused with a warning.
customers[].emailstring (email) | nullUsed as the identity when there is no phone.
customers[].countrystring | null
customers[].citystring | null
customers[].acceptsMarketingboolean | nullThe 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.
Request
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
    }
  ]
}'
Response
JSON
{
  "ok": true,
  "succeeded": 2,
  "failed": 0,
  "results": [
    {
      "id": "U-501",
      "ok": true
    },
    {
      "id": "U-502",
      "ok": true
    }
  ]
}

GETcheckCoupon

GEThttps://capiagent.com/api/ingest/v1/coupons/{id}
Request
curl -X GET https://capiagent.com/api/ingest/v1/coupons/CAPI-7K3M9 \
  -H "Authorization: Bearer capi_live_xxxxxxxxxxxxxxxxxxxxxxxx"
Response
JSON
{
  "ok": true,
  "valid": true,
  "coupon": {
    "code": "CAPI-7K3M9",
    "discountType": "percent",
    "percent": 10,
    "expiresAt": "2026-10-12T18:00:00.000Z",
    "usageLimit": 1
  }
}

POSTredeemCoupon

POSThttps://capiagent.com/api/ingest/v1/coupons/{id}/redeem
FieldTypeRequiredNotes
orderIdstringYour 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.
Request
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"
}'
Response
JSON
{
  "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

DELETEhttps://capiagent.com/api/ingest/v1/coupons/{id}/redeem
Request
curl -X DELETE https://capiagent.com/api/ingest/v1/coupons/CAPI-7K3M9/redeem \
  -H "Authorization: Bearer capi_live_xxxxxxxxxxxxxxxxxxxxxxxx"
Response
JSON
{
  "ok": true,
  "released": true
}

Webhooks

One endpoint in your system receives every event. Each is a signed POST; answer 2xx.

Setup

  1. Under Custom store in the dashboard, save your endpoint URL (https only).
  2. Copy the signing secret shown once (capi_whsec_…) into your system.
  3. Press "Send test ping" and make sure your endpoint verifies the signature and answers 2xx.

Events

coupon.createThe assistant needs a discount code for a customer. Create it and answer within 10 seconds. Not retried.
order.cod_confirmedThe customer confirmed a cash-on-delivery order on WhatsApp.
order.cod_cancelledThe customer cancelled a cash-on-delivery order. When cancelRequested is true, cancel it and restock.
customer.unsubscribedThe customer opted out of marketing with us. Unsubscribe them in your list too.
pingA test event from the dashboard button.
Event shape
HTTP
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.

Verifying the signature
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)),
  );
}
A complete receiver
Node.js (Express)
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».

Install the widget
HTML
<script src="https://capiagent.com/widget.js" data-store-id="STORE_ID" async></script>

Purchase-funnel events — required

PRODUCT_VIEWWhen every product page loads, and on every navigation to another product in an SPA store.
ADD_TO_CARTAfter every successful add to cart, from any button: product page, listings, quick-view, «buy now».
CHECKOUT_STARTEDWhen 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).
Purchase-funnel events — required
JavaScript
// 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.

The logged-in customer — AISA.identify
JavaScript
// 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