If your online store is not WooCommerce, Shopify, Salla or Zid, it can still send its orders, quote requests and products to your workspace. Your store posts a small signed JSON document each time something changes, and each order becomes a contact, a store order and, when paid, a sale in your reports. This page is the contract for the developer of that store. For WooCommerce, follow WooCommerce instead.
Before you start
- Store connections are available when your plan includes Store connections. Your plan may also limit how many active connections you can have.
- Only owners and administrators can create, turn off and re-key a connection.
- You need at least one pipeline, because quote requests open deals in it.
- Your store must be able to send an HTTPS
POSTfrom its server, ideally from a background job.
Create the connection
- Open Products in the sidebar, then Store connections.
- Under Connect a store, type a Store name.
- Set Store platform to Our store platform or custom.
- Choose the stage under New quote requests start in, and optionally a person under Assign new deals to.
- Leave Webhook key from Shopify or Salla empty.
- Select Connect a store.
- Copy the Signing secret. It is shown only once.
- Copy the Address for your store from the connection's card.
Store both values in your store's environment settings, not in its code:
STORE_EVENTS_URL=STORE_ADDRESS
STORE_EVENTS_SECRET=YOUR_STORE_SECRET
The request
Send each event as a POST to the connection's address, with the JSON document as the body and two headers:
| Header | Value |
|---|---|
X-CRM-Timestamp |
The current Unix time in seconds |
X-CRM-Signature |
sha256= followed by the lower-case hexadecimal HMAC-SHA256 of the timestamp, a full stop and the exact body, keyed with the signing secret |
Content-Type |
application/json |
The timestamp must be within 5 minutes of the server's clock, so keep your store's clock synchronised. The signature is checked against the raw bytes you send, so sign the same string you put in the body.
BODY='{"event":"order.created","order":{"id":"1001","number":"1001","status":"pending","currency":"SAR","total":"1250.50","placed_at":"2026-11-06T10:00:00Z","customer":{"name":"Ali Hassan","email":"ali@example.com"}}}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "YOUR_STORE_SECRET" | sed 's/^.* //')
curl -X POST "STORE_ADDRESS" \
-H "Content-Type: application/json" \
-H "X-CRM-Timestamp: $TS" \
-H "X-CRM-Signature: sha256=$SIG" \
--data "$BODY"
Events
event |
Document key | Effect |
|---|---|---|
order.created |
order |
Saves the order and finds or creates its customer |
order.updated |
order |
The same as order.created. Send it whenever the status or total changes |
quote.requested |
quote |
Opens a deal with the requested lines in the chosen stage, once per quote ID |
product.upserted |
product |
Creates or updates a product in the catalogue |
Any other event is refused with 422.
The order document
{
"event": "order.updated",
"order": {
"id": "1001",
"number": "SO-1001",
"status": "paid",
"currency": "SAR",
"total": "1250.50",
"placed_at": "2026-11-06T10:00:00Z",
"updated_at": "2026-11-06T10:05:00Z",
"customer": {
"name": "Ali Hassan",
"email": "ali@example.com",
"phone": "+966500000002",
"company": "Hassan Cafe"
},
"items": [
{"sku": "ESP-1", "name": "Espresso machine", "quantity": 1, "total": "1250.50"}
],
"attribution": {
"source": "google",
"medium": "cpc",
"campaign": "ramadan-sale"
}
}
}
| Field | Required | Rules |
|---|---|---|
id |
Yes | Your store's order ID, up to 100 characters. It identifies the order on every later event |
number |
No | The order number customers see, up to 60 characters |
status |
No | Up to 40 characters, lower-cased. Defaults to pending. See the statuses below |
currency |
Yes | USD, EUR, GBP, TRY, SAR or AED |
total |
Yes | A string such as "1250.50". A JSON number is refused |
placed_at |
Yes | When the order was placed, as an ISO 8601 date and time |
updated_at |
No | When the order last changed. Defaults to placed_at |
customer.name |
Yes | Up to 160 characters |
customer.email |
One of the two | A valid address, up to 254 characters |
customer.phone |
One of the two | Up to 40 characters. A phone that is not 6 to 15 digits after clean-up is dropped |
customer.company |
No | Up to 160 characters |
items |
No | Up to 200 lines, each with name (required, up to 200 characters), quantity (a whole number from 1 to 1000000), total (a string) and an optional sku |
attribution |
No | source, medium and campaign, each up to 120 characters. Shown as the order's campaign in reports |
recovery_url |
No | An https link of up to 2000 characters where the customer can finish paying. Used by unfinished-checkout reminders |
consent |
No | An object of up to 50 order fields. When Order field that holds WhatsApp consent is set in Alerts and automations, a yes in that field on a pending or failed order that has a recovery_url records the customer's WhatsApp consent |
Capture the campaign tags (utm_source, utm_medium, utm_campaign) when the visitor arrives and save them with the order, so attribution can carry them.
Statuses
| Status | Treated as |
|---|---|
paid, processing, completed, delivered |
A sale. Counts in sales reports, turns a lead into a customer, and fires A store order is confirmed the first time |
pending, failed |
Not paid yet. With a recovery_url it can start unfinished-checkout reminders when they are turned on |
Anything else, such as cancelled or refunded |
Not a sale. Stops the reminder for that order only when the event still carries its recovery_url, so keep sending it |
How an order is applied
- Newer wins. An event whose
updated_atis older than the one already stored is ignored. One with the sameupdated_at, status and total is ignored as a repeat. Send a freshupdated_atwith every change. - The customer is matched, never overwritten. An email matches an existing active contact first. A phone matches only a contact with no other email, so a shared phone does not join two people. With no match, a new contact is created as a lead from the website, owned by the person chosen under Assign new deals to, and that fires A new lead arrives.
- The customer is fixed by the first event. Later events for the same order ID keep the same contact.
- Every event is safe to repeat. Resending the same document changes nothing.
The quote document
quote.requested uses the same fields as an order, under the key quote. It creates one deal titled with the quote number and the customer's name, in the stage chosen for the connection, with one line for each item. The deal amount is the sum of the item totals, or total when there are no items. A second event with the same quote id is ignored.
The product document
{
"event": "product.upserted",
"product": {
"id": "P-77",
"name": "Espresso machine",
"sku": "ESP-1",
"price": "1250.50",
"currency": "SAR",
"active": true,
"updated_at": "2026-11-06T09:00:00Z"
}
}
| Field | Required | Rules |
|---|---|---|
id |
Yes | Your product ID, up to 100 characters |
name |
Yes | Up to 200 characters |
sku |
No | Up to 100 characters. A SKU already used by a different product is refused |
price |
No | A string. Saved only together with currency |
currency |
No | One of the supported currencies |
active |
No | false archives the product |
updated_at |
No | An event older than the stored one is ignored |
A product is found by its id, or else by its sku, so a product typed in by hand earlier is linked rather than duplicated.
Responses
| Status | Body | Meaning |
|---|---|---|
200 |
{"status": "received"} |
Saved, or recognised as a repeat |
200 |
{"status": "paused"} |
Signed correctly, but the plan does not include Store connections or the company is suspended. Nothing is saved, and the screen counts the event as refused |
403 |
Wrong signature, or a timestamp more than 5 minutes off | |
404 |
The connection does not exist or is turned off. For quote.requested, also when the connection's stage or its pipeline has been archived |
|
422 |
{"errors": ["customer.email", "total"]} |
The document is not valid. The list names the fields at fault. A body that is not a JSON object answers 422 with no list |
429 |
More than 600 events a minute from one address |
Sending from your store
Send from a queued job after the order is saved, so a slow or unavailable connection never delays the purchase. Retry on 5xx, 429 and connection errors with a growing pause. Do not retry 403 without fixing the clock or the secret, and do not resend a 422 unchanged.
final class StoreEvents
{
public function send(string $event, string $key, array $document): void
{
$body = json_encode(['event' => $event, $key => $document], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
$time = (string) time();
$signature = 'sha256=' . hash_hmac('sha256', $time . '.' . $body, getenv('STORE_EVENTS_SECRET'));
$curl = curl_init(getenv('STORE_EVENTS_URL'));
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-CRM-Timestamp: ' . $time, 'X-CRM-Signature: ' . $signature],
]);
curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
if ($status === 0 || $status === 429 || $status >= 500) {
throw new RuntimeException('Store event not delivered, retry later.');
}
}
}
Test the connection
- Send an
order.createdwith a test customer and apendingstatus. - Check that the connection's card shows Last event with the time, and that the customer appears in Contacts with the order.
- Send
order.updatedwith"status": "paid"and a laterupdated_at. The order should now count as a sale. - Send
order.updatedwith"status": "cancelled"and a laterupdated_at. The order should leave the sales figures.
Good to know
- Past orders are not imported for custom stores. Only events you send from now on arrive.
- Replace the secret makes a new secret at once. Events signed with the old one are refused with
403from that moment, so update your store straight away and resend anything refused in between. - Turn off stops the connection: events answer
404until it is turned on again. - An erased customer stays erased. Later events for their orders keep the totals right but bring no personal data back.
Troubleshooting
| Problem | What to check |
|---|---|
Always 403 |
The secret, the clock, and that you sign the exact body you send, with the timestamp and a full stop before it |
422 with total |
The total was sent as a number, or uses a comma as the decimal mark |
422 with customer |
The order has neither a valid email nor a phone of 6 to 15 digits |
422 with currency |
The currency is not one of the supported six |
| Events refused appears on the card | Events arrived while the plan did not include Store connections. They were not saved, so resend them |
| An update did not change the order | Its updated_at was not later than the stored one |
Last updated 2026-11-08