Biklo for developersTESTsandbox-api.biklo.comchange

Webhooks

Register an endpoint and we tell you when something happens, instead of you asking every minute.

curl -X POST https://sandbox-api.biklo.com/v1/webhooks \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"url": "https://erp.example.com/hooks/biklo", "events": ["order.created"]}'

The response contains a signing secret, shown once. An empty events list means every event.

Verify every delivery#

X-Biklo-Signature: t=1757308800,v1=9f86d081884c7d659a2f…

v1 is hmac_sha256(timestamp + "." + raw_body, secret) — the same scheme Stripe uses, so you very likely already have this code:

[$t, $v1] = // parse the header
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
if (!hash_equals($expected, $v1)) { return 400; }

Sign the raw body, before any JSON parsing. The timestamp is inside the signed material, so a captured payload cannot be replayed later.

Payloads are thin on purpose#

{
  "id": "evt_vlvslhcxut3gj4oikfnqyf7n",
  "type": "order.created",
  "created_at": "2026-09-10T07:15:00Z",
  "data": { "order_id": 713, "status": "pending", "reference": "SBXORD-BOYHR5KW" }
}

Ids and status, never the whole resource: the shape stays stable as resources evolve, and a mis-configured endpoint cannot leak buyer data. Fetch the order through the API when you need the detail — where your scopes still apply.

What we send#

Twenty-six events, across orders, products, variant groups, conversations, company settings and money.

The full event reference lists every one with the exact body that arrives and what it means. GET /v1/webhooks returns the live list under available_events.

The ones worth knowing before you subscribe:

We do not echo your own writes back at you#

A change your integration made through the API is not delivered to that integration — you already know, and on a nightly catalogue sync it would be thousands of deliveries telling you what you just did.

It is still delivered to the seller's other integrations. If a stock feed and an accounting system are both connected, a write by one reaches the other.

Changes made in the Biklo dashboard reach everyone.

If your endpoint is behind a gateway#

Plenty of ERPs are not reachable directly: the URL you give us belongs to an API gateway that wants a key of its own before it routes anything to your handler. Register that key with the endpoint and we send it on every delivery.

POST /v1/webhooks
Content-Type: application/json

{
  "url": "https://gateway.example.com/biklo/webhooks",
  "headers": [
    { "name": "X-Api-Key", "value": "the value your gateway expects" }
  ]
}

A plain object works too, if that is easier to build:

{ "headers": { "X-Api-Key": "…", "Authorization": "Basic …" } }

Rules worth knowing before you hit a 400:

PUT /v1/webhooks/42
{ "headers": [ { "name": "X-Api-Key", "value": "the rotated value" } ] }

Send an empty list to remove them all. It replaces rather than merges, because we cannot show you what is there to merge into.

A header is not authentication. A fixed value says nothing about the body it arrived with. Anyone who has ever seen it — a proxy log, a support ticket, a screenshot — can send you whatever they like with it attached. It gets our request past your gateway; X-Biklo-Signature is what tells you the payload is ours and unmodified. Verify it whether or not you use custom headers.

Delivery, retries and what we expect from you#

Testing before you go live#

You do not need a real order to check your endpoint works:

curl -X POST https://sandbox-api.biklo.com/v1/webhooks/1/test -H "Authorization: Bearer $KEY"

That queues a test.ping down exactly the same path as a real event — same queue, same headers, same signature — so a delivery that arrives and verifies here proves the real ones will. It is sent whatever your endpoint subscribes to, and test.ping is not a subscribable event, so you will never get one you did not ask for.

The 202 means queued, not delivered. What your endpoint actually answered shows up a moment later:

curl "https://sandbox-api.biklo.com/v1/webhooks/1/deliveries" -H "Authorization: Bearer $KEY"

Receiving on your own machine#

We POST to your endpoint from our servers, so http://localhost:9001 is not something we can reach. During development you put a public HTTPS address in front of your local process with a tunnel.

Cloudflare Tunnel is the shortest route — one command, no account, no card:

cloudflared tunnel --url http://localhost:9001

It prints a https://<something>.trycloudflare.com address that forwards to your machine. Register that as your endpoint and you are done:

curl -X POST https://sandbox-api.biklo.com/v1/webhooks \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://<something>.trycloudflare.com/hooks", "events": ["order.created"]}'

Then send yourself a test.ping and watch it land.

Installing it:

brew install cloudflared                          # macOS
winget install --id Cloudflare.cloudflared        # Windows
# Linux: the .deb / .rpm from github.com/cloudflare/cloudflared/releases

ngrok http 9001 does the same job if you already have it.

Three things about tunnels that will bite you

The quick-tunnel address changes every time you restart it. It is ephemeral by design. When it changes, your registered endpoint is pointing at nothing — delete it and register the new URL, or the deliveries will pile up against a dead address. If you are going to be at this for more than an afternoon, a named tunnel (cloudflared tunnel create) gives you a stable hostname on your own domain.

A dead tunnel still burns the retry ladder. We retry at 1m, 5m, 30m, 2h, 6h, 24h, and after 20 consecutive failures we switch the endpoint off. Close your laptop for a day with an endpoint registered and you may come back to a disabled one — POST /v1/webhooks/{id}/enable brings it back.

Never point a LIVE key at your laptop. A tunnel on a live integration means real orders are being delivered to a process you are about to stop for lunch. Do this on bk_test_ keys against the sandbox, where nothing you receive is a real customer.

A receiver worth pasting

Small enough to read, and it does the one thing that matters — it actually verifies:

<?php
// php -S 127.0.0.1:9001 receiver.php
$secret = getenv('BIKLO_WEBHOOK_SECRET');
$body   = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_BIKLO_SIGNATURE'] ?? '';

parse_str(strtr($header, ',', '&'), $parts);          // t=…,v1=…
$expected = hash_hmac('sha256', ($parts['t'] ?? '') . '.' . $body, $secret);

if (!hash_equals($expected, $parts['v1'] ?? '')) {
    http_response_code(400);
    exit('bad signature');
}

if (abs(time() - (int) ($parts['t'] ?? 0)) > 300) {   // replay guard
    http_response_code(400);
    exit('stale');
}

$event = json_decode($body, true);
error_log($event['type'] . ' ' . json_encode($event['data']));

http_response_code(200);
echo '{"ok":true}';

Note hash_equals rather than ===: a timing-safe comparison is the point of signing in the first place.

Break it on purpose before you trust it

When you think you missed one#

curl "https://sandbox-api.biklo.com/v1/webhooks/1/deliveries?status=failed" -H "Authorization: Bearer $KEY"
curl -X POST https://sandbox-api.biklo.com/v1/webhooks/deliveries/3/replay -H "Authorization: Bearer $KEY"

The log records every attempt with the status we got back, so "we never received it" is a question with an answer rather than a support thread.