Developers
Webhooks
Set up an endpoint
- Add an https route to your server that reads the raw body and answers
2xxwithin 10 seconds. Do the work after answering. - Create the endpoint with the API, or ask our team. Store the
whsec_secret it answers: it is shown once. - Verify every request (below), then deduplicate on the event
id. - Send yourself a test event of each type you handle.
The event
Every event has the same envelope. data holds the type's own fields.
idstring- Starts evt_. The same on every retry, and in the webhook-id header.
typestring- One of the events below.
api_versionstring- The version data is written in: your endpoint's.
created_atstring- When it happened, which can be before you receive it.
testboolean- true for test events.
dataobject- Ids to fetch the full object with, and what changed.
POST /hooks/ninjabuy HTTP/1.1
Host: acme-goods.com
Content-Type: application/json
User-Agent: NinjaBuy-Webhooks/1.0
webhook-id: evt_4m2x
webhook-timestamp: 1793685600
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
"id": "evt_4m2x",
"type": "order.shipped",
"api_version": "2026-11",
"created_at": "2026-11-03T06:00:00Z",
"test": false,
"data": {
"order": "ord_3n7q",
"external_id": "1012",
"shipment": "shp_5r8w",
"carrier": "YunExpress",
"tracking_number": "YT2630812345678",
"tracking_url": "https://t.17track.net/en#nums=YT2630812345678",
"shipping_charged": { "amount": "134.52", "currency": "CNY" },
"pushed_to_channel": true
}
}Events
order.created
An order reached us, from your Shopify store or from the API. Paid, or awaiting_payment.
{
"id": "evt_1a7c",
"type": "order.created",
"api_version": "2026-11",
"created_at": "2026-11-01T09:14:02Z",
"test": false,
"data": {
"order": "ord_3n7q",
"external_id": "1012",
"source": "shopify",
"status": "buying",
"payment_status": "paid",
"lines": [
{
"line": "lin_9a2c",
"variant": "var_41hz",
"sku": "5187209554",
"quantity": 1
}
]
}
}order.paid
An order was paid from your balance. Fires for orders that waited for a top-up, too.
{
"id": "evt_1a9f",
"type": "order.paid",
"api_version": "2026-11",
"created_at": "2026-11-01T11:02:40Z",
"test": false,
"data": {
"order": "ord_3n8a",
"external_id": "1013",
"amount": { "amount": "57.00", "currency": "CNY" },
"paid_from": "balance",
"was_awaiting_payment": true
}
}order.bought
We paid the seller. The lines are with_seller until they reach our warehouse.
{
"id": "evt_2c4e",
"type": "order.bought",
"api_version": "2026-11",
"created_at": "2026-11-01T13:30:05Z",
"test": false,
"data": {
"order": "ord_3n7q",
"external_id": "1012",
"lines": [
{
"line": "lin_9a2c",
"sku": "5187209554",
"quantity": 1,
"status": "with_seller"
}
]
}
}order.checked
The items are in our warehouse, checked, with their photos. Packing comes next.
{
"id": "evt_9w1c",
"type": "order.checked",
"api_version": "2026-11",
"created_at": "2026-11-02T03:20:11Z",
"test": false,
"data": {
"order": "ord_3n7q",
"external_id": "1012",
"fulfillment_order": "fo_7k2m",
"lines": [
{
"line": "lin_9a2c",
"qc": "passed",
"photos": [
"https://media.ninjabuy.com/qc/ord_3n7q/1.webp",
"https://media.ninjabuy.com/qc/ord_3n7q/2.webp"
]
}
]
}
}order.shipped
A parcel left with tracking; the shipping charge is final. The store the order came from has the tracking too.
{
"id": "evt_4m2x",
"type": "order.shipped",
"api_version": "2026-11",
"created_at": "2026-11-03T06:00:00Z",
"test": false,
"data": {
"order": "ord_3n7q",
"external_id": "1012",
"shipment": "shp_5r8w",
"carrier": "YunExpress",
"tracking_number": "YT2630812345678",
"tracking_url": "https://t.17track.net/en#nums=YT2630812345678",
"shipping_charged": { "amount": "134.52", "currency": "CNY" },
"pushed_to_channel": true
}
}product.cost_changed
A seller changed a price, and what your sync rule did about it: repriced, paused or notified.
{
"id": "evt_8q0d",
"type": "product.cost_changed",
"api_version": "2026-11",
"created_at": "2026-11-04T02:10:00Z",
"test": false,
"data": {
"product": "prd_1x0a",
"variant": "var_2b7k",
"sku": "4410923871",
"cost": {
"from": { "amount": "42.00", "currency": "CNY" },
"to": { "amount": "48.00", "currency": "CNY" }
},
"action": "repriced",
"price": {
"from": { "amount": "18.99", "currency": "USD" },
"to": { "amount": "20.99", "currency": "USD" }
},
"rule": "rule_default"
}
}product.out_of_stock
The seller ran out of a variant and your shelf has none: we set it to 0 on your channels.
{
"id": "evt_2b9n",
"type": "product.out_of_stock",
"api_version": "2026-11",
"created_at": "2026-11-04T02:11:30Z",
"test": false,
"data": {
"product": "prd_8f2k",
"variant": "var_41j0",
"sku": "5187209555",
"warehouse_on_hand": 0,
"channels_updated": [
{
"channel": "shopify",
"shop": "acme-goods.myshopify.com",
"available": 0
}
]
}
}qc.issue
Our check found a problem with an item: wrong colour or size, damage, a missing part. Choose by respond_by; with no answer we hold it and write to you.
{
"id": "evt_6c3h",
"type": "qc.issue",
"api_version": "2026-11",
"created_at": "2026-11-02T03:20:11Z",
"test": false,
"data": {
"order": "ord_3n7q",
"external_id": "1012",
"fulfillment_order": "fo_7k2m",
"line": "lin_9a2c",
"sku": "5187209554",
"issue": "wrong_color",
"note": "Received grey, ordered black.",
"photos": ["https://media.ninjabuy.com/qc/ord_3n7q/3.webp"],
"choices": ["ship_anyway", "exchange", "refund"],
"respond_by": "2026-11-05T03:20:11Z"
}
}Signatures
Deliveries are signed the Standard Webhooks way, so a library you may already use verifies them. Three headers come with every delivery:
| Header | What it holds |
|---|---|
| webhook-id | The event id. |
| webhook-timestamp | When we sent this attempt, in Unix seconds. |
| webhook-signature | v1, then a comma and the base64 HMAC-SHA256 of id.timestamp.body under your secret. During a rotation, two of them, separated by a space. |
The key is your secret after whsec_, base64-decoded. Sign the raw body exactly as it arrived; refuse a timestamp more than five minutes off, so a captured request cannot be replayed.
Verify a delivery
With the standardwebhooks library, or in a few lines of your own:
// npm install standardwebhooks express
import express from "express"
import { Webhook } from "standardwebhooks"
const webhook = new Webhook(process.env.NINJABUY_WEBHOOK_SECRET) // whsec_...
const app = express()
// The raw body: a parsed and re-serialised body no longer matches.
app.post("/hooks/ninjabuy", express.raw({ type: "application/json" }), (req, res) => {
let event
try {
event = webhook.verify(req.body.toString("utf8"), req.headers)
} catch {
return res.sendStatus(400)
}
res.sendStatus(204) // answer first, then do the work
queue.push(event) // deduplicate on event.id: a retry repeats it
})Retries and order
Anything but a 2xx within 10 seconds is a failure, and we try again, each time with the same webhook-id and a new timestamp and signature:
| Attempt | After the one before |
|---|---|
| 1 | At once |
| 2 | 5 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 hours |
| 8 | 10 hours, the last |
- After the last attempt the delivery is failed. Send it again from the deliveries list.
- An endpoint whose every delivery failed for five days is disabled, after an email to you. Enabling it again sends what it missed in the last three days.
- Events can arrive twice and out of order. Deduplicate on id, and when order matters, fetch the object and trust its status rather than the event's.