Rutba CRM

Custom store events

Send signed order, quote and product events from your own online store to Rutba CRM, with the exact JSON and signature.

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 POST from its server, ideally from a background job.

Create the connection

  1. Open Products in the sidebar, then Store connections.
  2. Under Connect a store, type a Store name.
  3. Set Store platform to Our store platform or custom.
  4. Choose the stage under New quote requests start in, and optionally a person under Assign new deals to.
  5. Leave Webhook key from Shopify or Salla empty.
  6. Select Connect a store.
  7. Copy the Signing secret. It is shown only once.
  8. 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_at is older than the one already stored is ignored. One with the same updated_at, status and total is ignored as a repeat. Send a fresh updated_at with 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

  1. Send an order.created with a test customer and a pending status.
  2. Check that the connection's card shows Last event with the time, and that the customer appears in Contacts with the order.
  3. Send order.updated with "status": "paid" and a later updated_at. The order should now count as a sale.
  4. Send order.updated with "status": "cancelled" and a later updated_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 403 from that moment, so update your store straight away and resend anything refused in between.
  • Turn off stops the connection: events answer 404 until 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