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:
order.tracking_updatedis the highest-volume event — one per courier scan, on every shipment in flight. Filter onis_finalif you only care that it arrived.notification.sentis the next highest: one per email and SMS we send about your orders.- Product edits collapse. A write touching several columns sends one event, not
one per column — a lifecycle change wins outright, otherwise stock and price are
announced on their own, otherwise a single
product.updated. order.cancelledandreturn.requestedare also sent asorder.status_changed. If you handle that, you can ignore both.
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:
- At most five. Names must be valid HTTP tokens — letters, digits and
-. Values are capped at 500 characters and cannot contain line breaks. - You cannot override ours.
Content-Type,User-Agent,X-Biklo-*and the transport headers are rejected (invalid_header). Overriding them would change the bytes you are meant to be verifying. - Values are write-only. Stored encrypted, never returned, never written to a
delivery record.
GET /v1/webhooksshows the name and the first four characters — enough to tell two credentials apart. Keep your own copy. - Rotating one does not cost you your signing secret.
PUT /v1/webhooks/{id}replaces the whole set in place:
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-Signatureis 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#
- Answer 2xx quickly. Queue the work; do not process before replying.
- Delivery is at-least-once. Deduplicate on
id— a replay reuses it. - We retry at 1m, 5m, 30m, 2h, 6h, 24h. After 20 consecutive failures we disable the
endpoint and email the seller;
POST /v1/webhooks/{id}/enablebrings it back. - Production endpoints must be HTTPS.
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
- Use the wrong secret. Your receiver should answer
400. If it answers200anyway you have the appearance of verification, not verification. - Answer
500. Watch the delivery log walk the retry ladder. - Sleep 25 seconds before replying. We give up after 20, so anything that does its work before answering will eventually look broken to us.
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.
