Skip to main content
← API reference

Webhooks

The service tells your server about orders with two signed webhooks. Delivery is at least once, so your receiver verifies the signature, deduplicates on the event id and answers quickly.

Paths, field names, error codes, header names and code samples stay in English: they are the contract.

Events

Subscribe by publishing channels.webhooks[] in your shop configuration (an id, a url and the events). The URL must be public https; private, loopback and internal addresses are refused when the configuration is saved and again at send time.

EventSent when
order.createdA live order was stored (POST /cfg/v1/order).
order.status_changedYou moved an order to another status in the console.

The body

The body is canonical JSON (RFC 8785) and byte-identical on every attempt. data.order is the full signed order.v1 document; data.fromStatus exists only on order.status_changed.

{
  "createdAt": "2026-10-19T09:30:00.000Z",
  "data": {
    "fromStatus": "received",
    "order": {
      "…": "order.v1"
    },
    "orderId": "<order id>",
    "status": "confirmed"
  },
  "id": "<event id>",
  "schemaVersion": 1,
  "tenantId": "<tenant id>",
  "type": "order.status_changed"
}

Headers

Every delivery carries five headers. Deduplicate on CutOptim-Event-Id: a retry and a redelivery repeat it, while CutOptim-Delivery-Id differs.

HeaderDescription
CutOptim-SignatureThe timestamp and one or two HMAC-SHA256 signatures of the raw body.
CutOptim-Event-IdThe event id, equal to the body's id. Deduplicate on it.
CutOptim-Event-TypeThe event name.
CutOptim-Delivery-IdThis delivery; a retry or a redelivery gets a new one.
CutOptim-Delivery-AttemptThe attempt number, starting at 1.

Verifying the signature

The signature is t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>. Accept the delivery when any v1 value matches one of your secrets and t is within 300 seconds of your clock; compare in constant time. Verify the raw bytes before you parse them: a framework that parses and re-serialises the body changes it, and every check fails.

The two receivers below are the reference examples, unchanged: Node 18+ with node:crypto only, and PHP 7.4+ without Composer. Both read the secret from CUTOPTIM_WEBHOOK_SIGNING.

configurator/adapters/reference/node/example-server.mjs

// example-server.mjs - a webhook endpoint on node:http, to read and adapt.   CUTOPTIM_WEBHOOK_SIGNING=whsec_... node example-server.mjs
// (whsec_REPLACE_ME is a placeholder: the real secret comes from the CutOptim admin route.)
import { createServer } from 'node:http';
import { createWebhookReceiver } from './cutoptim-verify.mjs';

const secret = process.env.CUTOPTIM_WEBHOOK_SIGNING;
if (!secret) { console.error('set CUTOPTIM_WEBHOOK_SIGNING (whsec_REPLACE_ME is a placeholder)'); process.exit(1); }

const receive = createWebhookReceiver({
  secrets: [secret],
  async onEvent(event) {
    // event.type === 'order.created'; event.data.order is the signed order document. Store it in YOUR order table here,
    // keyed by event.data.orderId, in the same transaction that records the event id as seen.
    console.log('order.created', event.data.orderId);
  },
});

createServer(async (req, res) => {
  if (req.method !== 'POST') { res.writeHead(405).end(); return; }
  const chunks = []; let size = 0;
  for await (const c of req) { size += c.length; if (size > 2_000_000) { res.writeHead(413).end(); return; } chunks.push(c); }
  const out = await receive({ rawBody: Buffer.concat(chunks), headers: req.headers });   // the RAW bytes, not parsed JSON
  res.writeHead(out.status, { 'Content-Type': 'application/json' }).end(JSON.stringify({ result: out.reason }));
}).listen(Number(process.env.PORT ?? 8787));

configurator/adapters/reference/php/example-webhook.php

<?php
/**
 * example-webhook.php - a webhook endpoint in plain PHP, to read and adapt.
 * CUTOPTIM_WEBHOOK_SIGNING=whsec_... is read from the environment (whsec_REPLACE_ME is a placeholder; the real secret comes from the
 * CutOptim admin route). Replace MemorySeen with a table that has a UNIQUE key on the event id.
 */
require __DIR__ . '/CutOptimVerify.php';

use CutOptim\CutOptimVerify;
use CutOptim\MemorySeen;

$secret = getenv('CUTOPTIM_WEBHOOK_SIGNING');
if ($secret === false || $secret === '') {
    http_response_code(500);
    exit('CUTOPTIM_WEBHOOK_SIGNING is not set');
}

$rawBody = file_get_contents('php://input');   // the RAW bytes, not decoded JSON
$headers = function_exists('getallheaders') ? getallheaders() : [];
$out = CutOptimVerify::handleWebhook($rawBody, $headers, [$secret], new MemorySeen(), static function (array $event): void {
    // $event['type'] === 'order.created'; $event['data']['order'] is the signed order document. Store it in YOUR order table
    // keyed by $event['data']['orderId'], in the same transaction that records the event id as seen.
});

http_response_code($out['status']);
header('Content-Type: application/json');
echo json_encode(['result' => $out['reason']]);

Retries, dead letters and redelivery

A non-2xx answer, a timeout and a redirect are failed attempts. A delivery is tried 6 times in all, with growing waits; after the last failure it is a dead letter. A URL that resolves to a private address is a dead letter at once. Your server can read the delivery log and ask for a redelivery with the secret key (/cfg/v1/admin/deliveries); a redelivery keeps the event id.

After attemptNext try in
11 min
25 min
325 min
42.08 h
56 h

Test orders

An order placed with a test key queues no webhook and no e-mail: your ERP and your shoppers never see test traffic.

There is no "try it" button here on purpose: the secret key belongs on your server, never in a browser. The demo shop shows the embed and the order hand-off working end to end.

LegalConfigurator terms ·Data processing agreement ·Shopper privacy notice ·Third-party licences