Developers

Webhooks

We POST an event to your server the moment an order moves or a listing changes, so you never have to poll. Signed, retried for about a day, and the same on every route of the API.
On this page
  1. 1Set up an endpoint
  2. 2The event
  3. 3Events
  4. 4Signatures
  5. 5Verify a delivery
  6. 6Retries and order

Set up an endpoint

  1. Add an https route to your server that reads the raw body and answers 2xx within 10 seconds. Do the work after answering.
  2. Create the endpoint with the API, or ask our team. Store the whsec_ secret it answers: it is shown once.
  3. Verify every request (below), then deduplicate on the event id.
  4. 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.
A delivery
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.

order.created
{
  "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.

order.paid
{
  "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.

order.bought
{
  "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.

order.checked
{
  "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.

order.shipped
{
  "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.

product.cost_changed
{
  "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.

product.out_of_stock
{
  "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.

qc.issue
{
  "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:

HeaderWhat it holds
webhook-idThe event id.
webhook-timestampWhen we sent this attempt, in Unix seconds.
webhook-signaturev1, 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:

AttemptAfter the one before
1At once
25 seconds
35 minutes
430 minutes
52 hours
65 hours
710 hours
810 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.