Webhook event reference
Every event we send, what it means, and the body that arrives. The envelope is always
the same; only data differs:
{
"id": "evt_qwzmrryulk3n8txno0dw836h",
"type": "product.created",
"created_at": "2026-09-21T11:42:08Z",
"data": { "product_id": 237337, "external_id": "WH-REAL-PATH-2", "sku": null }
}
Payloads are thin on purpose — enough to find the thing and act on it, then you re-read it through the API where your scopes still apply. That keeps the shape stable as resources evolve, and means a mis-configured endpoint cannot leak buyer data.
Every product event carries external_id, your own identifier, so you can act without
a lookup.
Subscribe with POST /v1/webhooks; GET /v1/webhooks returns the live list under
available_events. See Webhooks for signing, retries and testing.
Orders#
order.created#
A buyer placed an order with you. Sent once the order and its lines are saved, so a
GET /v1/orders/{id} straight after always finds them.
{
"order_id": 725, "status": "pending", "reference": "ERP-4471", "is_return": false,
"items": [{ "id": 1140, "product_id": 237224, "quantity": 3 }]
}
reference is your external_order_id if you set one. is_return tells you this is
a normal order rather than a return coming back. items are the order lines with how
many pieces of each were ordered.
order.items_changed#
The lines changed after the order was placed: the seller shipped fewer pieces than
were ordered (a short shipment) and the buyer was refunded the missing ones. Sent
once the courier booked the parcel, before the order turns accepted.
{
"order_id": 725, "reason": "short_shipped",
"items": [{ "id": 1140, "product_id": 237224, "quantity": 2, "quantity_ordered": 3, "quantity_short": 1 }],
"refund": { "amount": 14000, "currency": "RON" }
}
quantity is what ships; update your copy of the order and give the missing pieces
back to your stock if you reserved them.
order.status_changed#
The order's own status changed: pending → accepted, then confirmed when the buyer
confirms, or one of declined, expired, returned, cancelled.
The order status does not follow the parcel: it stays accepted while the courier
picks it up, carries and delivers it, so no order.status_changed fires for those. Read
the parcel's progress from GET /v1/orders/{id}/tracking (or delivered_at on the
order). The sandbox works the same way: POST /v1/sandbox/orders/{id}/advance with
picked_up, in_delivery or delivered adds courier events and leaves the order
accepted.
{
"order_id": 725,
"status": "accepted",
"previous_status": "pending",
"reference": null,
"is_return": false
}
A return is an order in its own right and emits this event too, with
parent_order_id pointing at what is being returned:
{
"order_id": 499,
"status": "picked_up",
"previous_status": "accepted",
"reference": null,
"is_return": true,
"parent_order_id": 471
}
order.cancelled · return.requested#
The same payload as order.status_changed, sent in addition to it when the status
becomes cancelled (13) or returned (12). They exist because an ERP routes those two
differently from an ordinary status move — if you already handle
order.status_changed, you can ignore both.
return.delivered · return.escalated · return.resolved#
The seller's inspection of a returned product. The money for the returned lines is held from the return request until a decision — nothing is refunded or paid out on a timer.
return.delivered— the return parcel is back with you. Check the product, thenPOST /v1/returns/{order_id}/accept(the buyer is refunded) or/reject(with areason; the Biklo team decides) beforeinspection.deadline_at(48 h).return.escalated— the Biklo team decides now:disputed(you rejected it) orawaiting_admin(the deadline passed).return.resolved— decided.accepted: refunded in full;partially_refunded: part refunded, the rest released to you;rejected: no refund, released to you.inspection.refundedis what the buyer got back. Update your copy of the order and your stock.
All three carry the same body as an item of GET /v1/returns:
{
"order_id": 711, "return_order_id": 742, "reference": "ERP-4471",
"inspection": {
"status": "awaiting_seller", "delivered_at": "2026-11-03T09:10:00Z", "deadline_at": "2026-11-05T09:10:00Z",
"refund": { "amount": 179900, "currency": "RON" }, "refunded": null,
"decided_at": null, "decided_by": null, "can_accept": true, "can_reject": true
},
"items": [{ "id": 1123, "product_id": 237224, "quantity": 1,
"total_price": { "amount": 179900, "currency": "RON" }, "return_status": "requested" }]
}
order.awb_created#
A waybill was bought. This is what a warehouse prints against.
{
"order_id": 725,
"reference": null,
"awb_number": "TEST-AWB-9001",
"tracking_url": "https://track.example/TEST-AWB-9001",
"courier": "EColet"
}
Sent once per order. Buying a label is billable and irreversible, so a second event would mean a second parcel.
order.tracking_updated#
A new scan from the courier — finer-grained than order.status_changed, in the
courier's own wording.
{
"order_id": 723,
"reference": null,
"awb_number": "00543178",
"event": "Out for delivery",
"location": "Brasov",
"happened_at": "2026-09-21T11:18:23Z",
"is_final": false
}
The highest-volume event here. A parcel produces several scans and every shipment
in flight is polled. If you only care that it arrived, filter on is_final rather than
skipping the subscription — you still get the audit trail.
Products#
One edit produces one event. A write that touches several columns does not send several deliveries:
- A lifecycle change wins outright —
deleted,sold,archived,approved,rejected— and nothing else is sent. - Otherwise
stock_changedandprice_changedfire, and both can. - Otherwise one
product.updated, naming what moved.
So a sync that changes a title and the stock sends product.stock_changed only.
product.created#
{ "product_id": 237337, "external_id": "ERP-1001", "sku": "MTB-29-L" }
product.updated#
Something changed that has no more specific event. changed lists the fields.
{ "product_id": 237331, "external_id": "81073", "sku": null, "changed": ["title", "description"] }
Un-archiving arrives here as "changed": ["archived"] — a listing coming back is an
update, not an archive.
product.stock_changed#
{ "product_id": 237331, "external_id": "81073", "sku": null, "stock": 7, "previous_stock": 1 }
product.price_changed#
Minor units, like every amount in this API — 12345 is 123,45.
{ "product_id": 237331, "external_id": "81073", "sku": null, "price": 12345, "previous_price": 1799900 }
product.sold · product.archived · product.deleted#
{ "product_id": 237331, "external_id": "81073", "sku": null }
sold means Biklo orders took the last pieces: it is no longer buyable. Each paid
order before that sends product.stock_changed with the new stock, so a sell-out
arrives as stock_changed (to 0) followed by sold. Send stock above 0 and the
listing is back on sale. archived is the seller taking it off the marketplace and is
recoverable. deleted is not.
product.approved · product.rejected#
Moderation. is_live says which way it went.
{ "product_id": 237331, "external_id": "81073", "sku": null, "is_live": true }
A rejection is a takedown from your side: the listing was live and has been pulled back for review.
Variant groups#
A group is a set of listings sharing an external_group_id — the same shoe in six
sizes. These report the set changing, not a product.
product_group.created#
The first listing formed the group.
{ "external_group_id": "SHOE-8841", "product_id": 237331, "external_id": "81073", "members": 1 }
product_group.updated#
A listing joined the group, or a variant's size, colour or gender changed.
{ "external_group_id": "SHOE-8841", "product_id": 237331, "external_id": "81073", "members": 2 }
product_group.deleted#
A listing left. remaining: 0 means the group itself is gone, not just that
variant — which saves you a call to find out.
{ "external_group_id": "SHOE-8841", "product_id": 237331, "external_id": "81073", "remaining": 1 }
Conversations#
message.received#
A buyer wrote in a private thread about one of your listings.
{ "thread_id": 4412, "message_id": 90233, "product_id": 154393 }
question.asked#
A public question appeared on a listing. Sent when it becomes published — a question held for moderation and released an hour later arrives then, not before.
{ "question_id": 31, "product_id": 154393 }
question.answered#
A published answer appeared. Buyers answer public questions too, so from_seller
tells you whether this was the shop replying.
{
"question_id": 31,
"answer_id": 17,
"product_id": 154393,
"external_id": null,
"from_seller": true
}
Company#
company.updated#
The shop profile changed. changed names the fields; re-read GET /v1/company for
the values.
{ "company_id": 7795, "changed": ["phone"] }
Only fields you can read back are watched — slugs, timestamps and counters change on their own and are not announced.
company.bands_updated#
The delivery absorption bands moved: which order values the company pays shipping on. This changes what a buyer is quoted at checkout, so a storefront or price feed has to react to it.
{ "company_id": 7795, "country_id": 190, "scope": "local" }
The bands themselves are not in the payload — one edit rewrites several rows, so the
values would arrive in pieces. Re-read GET /v1/company/shipping-absorption once.
Money#
payout.updated#
A payout lot moved. event is the normalised name, status the raw one.
{
"lot_id": 8821,
"order_id": 725,
"event": "paid_out",
"status": "paid_out",
"amount": 124900,
"currency": "RON",
"withdrawal_id": "po_1Qx…"
}
event is one of:
released |
funds moved to the seller's connected account |
payout_requested |
a withdrawal is on its way to the bank |
paid_out |
it landed |
debt |
the seller owes the platform |
blocked_pending_onboarding |
the money cleared and then stopped — payout onboarding is unfinished and a human has to complete it in a browser |
needs_review |
something went wrong and a person is looking at it |
amount is in minor units.
invoice.issued#
A fee invoice was issued to the company, with its number and PDF.
{
"invoice_number": "BK-2026-000123",
"invoice_series": "BK",
"invoice_url": "https://biklo.ro/invoices/BK-2026-000123.pdf",
"order_id": 708,
"platform_fee_amount": 499,
"shipping_fee_amount": 0,
"currency": "RON",
"issued_at": "2026-09-21T11:18:31Z"
}
Operational#
notification.sent#
An email or SMS went out about one of your orders. Useful in the sandbox, where nothing is actually delivered and this is the only way to know a notification happened at all; in production it answers "did the buyer hear from us".
{
"notification_id": 4471,
"channel": "email",
"status": "sent",
"template": "order_confirmed_buyer",
"order_id": 9901
}
High volume for a busy seller — one per message we send.
test.ping#
Sent only when you ask, with POST /v1/webhooks/{id}/test. Not subscribable, so you
will never receive one you did not request.
{ "endpoint_id": 1, "app_id": 1, "nonce": "coigosdnysjr" }
Handling them#
One handler, switched on type. Verify first, answer 2xx fast, queue the work:
$payload = file_get_contents('php://input');
if (!verifySignature($payload, $_SERVER['HTTP_X_BIKLO_SIGNATURE'], $secret)) {
http_response_code(400);
exit;
}
$event = json_decode($payload, true);
// At-least-once delivery: a replay reuses the same id, so deduplicate on it.
if (alreadyProcessed($event['id'])) {
http_response_code(200);
exit;
}
switch ($event['type']) {
case 'product.stock_changed':
// Your own id is right there — no lookup needed.
updateStock($event['data']['external_id'], $event['data']['stock']);
break;
case 'order.created':
queueOrderImport($event['data']['order_id']);
break;
case 'order.tracking_updated':
if ($event['data']['is_final']) {
markDelivered($event['data']['order_id']);
}
break;
case 'payout.updated':
if ($event['data']['event'] === 'blocked_pending_onboarding') {
alertFinanceTeam($event['data']); // needs a human in a browser
}
break;
}
http_response_code(200);
We do not echo your own writes#
A change your integration made through the API is not delivered back to you. You already know, and on a nightly catalogue sync it would be thousands of deliveries telling you what you just did.
It still reaches 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.
