Biklo for developersTESTsandbox-api.biklo.comchange

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.

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:

  1. A lifecycle change wins outright — deleted, sold, archived, approved, rejected — and nothing else is sent.
  2. Otherwise stock_changed and price_changed fire, and both can.
  3. 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.