NinjaBuy Business API
Base URL
Every route on this site lives under one base URL. The Shopify app and everything you build call the same routes, so whatever the app does, your code can do too.
https://www.ninjabuy.com/api/v1
Prefer to ask an AI assistant? The same account works over MCP at https://www.ninjabuy.com/mcp: AI assistants (MCP).
- ImportsA link in, its full cost of selling out.
- ProductsWhat you sell, and where it is listed.
- VariantsEach option, tied to the seller's SKU.
- Inventory levelsWhat you can sell right now.
- Sync rulesWhat happens when a seller changes a price.
- OrdersA sale we buy, check and ship.
- Fulfillment ordersThe lines that leave in one parcel.
- ShipmentsParcels on their way, with tracking.
- BalanceThe money orders are paid from.
- Webhook endpointsWhere we send events.
curl "https://www.ninjabuy.com/api/v1/balance" \
-H "Authorization: Bearer $NINJABUY_API_KEY" \
-H "NinjaBuy-Version: 2026-11"Authentication
Send an API key as a bearer token on every request. Keys start nb_. During the preview our team issues them: once your Business account is set up we send each key once, over a channel you choose, with the scopes you asked for. Ask us to revoke one and it stops working on the next request.
Keep keys on your server. A key in a browser, an app or a public repository can be read by anyone, and it can spend your balance.
An app that acts for other NinjaBuy sellers uses OAuth 2.1 instead: dynamic client registration, the authorization code flow with PKCE (S256), and the seller's own approval screen. Its access token goes in the same header and works on the same routes. Access tokens last an hour; refresh tokens renew them for seven days. AI assistants connect the same way (MCP).
products:readscope- Read imports, products, variants and sync rules.
products:writescope- Import, edit, push and delete products; change sync rules.
inventory:readscope- Read inventory levels.
orders:readscope- Read orders, fulfillment orders and shipments.
orders:writescope- Create, hold, release and cancel orders. Spends your balance.
balance:readscope- Read the balance and its transactions.
webhooks:writescope- Manage webhook endpoints.
A call outside a key's scopes answers 403 insufficient-scope and names the scope it needs.
Authorization: Bearer nb_7Qk2...vX9eHTTP/2 401
{
"type": "https://www.ninjabuy.com/api/v1/problems/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "The API key is missing, wrong, expired or revoked.",
"request_id": "req_7Hq2Lw9c"
}Versioning
The path carries the major version, /v1. Inside it, dated versions change fields and behaviour. Pin the one you built against with the NinjaBuy-Version header; every answer names the version it used in the same header.
2026-11 is the current version, and it is a preview: names and fields can still change before it is marked stable. Every change is in the changelog, and we tell every key holder by email before a change that could break them.
Within a version we only add: new routes, new optional parameters, new fields in answers and new event types. Build your client to ignore fields it does not know.
GET /api/v1/orders
NinjaBuy-Version: 2026-11
HTTP/2 200
NinjaBuy-Version: 2026-11Requests and answers
- Send JSON with
Content-Type: application/json; answers are JSON in UTF-8. Errors areapplication/problem+json. - Money is an object with a decimal string and a currency:
{ "amount": "264.20", "currency": "CNY" }. What NinjaBuy charges is in CN¥; your selling prices are in your store's currency. - Ids are strings with a prefix that names their kind (
prd_,ord_,shp_). Treat them as opaque. - Times are ISO 8601 in UTC. Countries are two-letter ISO codes.
- Every answer carries an
X-Request-Idheader, and every error carries the same id asrequest_id. Quote it when you write to us.
{
"id": "ord_3n7q",
"created_at": "2026-11-01T09:14:02Z",
"total": { "amount": "264.20", "currency": "CNY" },
"suggested_price": { "amount": "49.99", "currency": "USD" }
}Pagination
Lists answer newest first, as data, has_more and cursor. Send that cursor back for the next page while has_more is true. A cursor only works with the filters it came from.
limitinteger- 1 to 100. 25 when left out.
cursorstring- The previous page's cursor. Leave it out for the first page.
curl "https://www.ninjabuy.com/api/v1/orders?limit=25&cursor=c_2v9kq" \
-H "Authorization: Bearer $NINJABUY_API_KEY" \
-H "NinjaBuy-Version: 2026-11"HTTP/2 200 OK
{
"data": [
{ "id": "ord_3n7q", "status": "checked" },
{ "id": "ord_3n6z", "status": "shipped" }
],
"has_more": true,
"cursor": "c_1q4ea"
}Rate limits
Limits are per account, shared by all its keys, OAuth tokens and its MCP connection. Every answer says where you stand in the IETF RateLimit-Policy and RateLimit headers; a refusal is 429 rate-limited with Retry-After in seconds.
| Window | What counts |
|---|---|
| "create-links";q=30;w=60 | Calls that read a listing from the marketplace: creating or refreshing an import, pushing a product. 30 a minute, shared with the links API and MCP. |
| "default";q=120;w=60 | Every other call. 120 a minute. |
HTTP/2 429
Content-Type: application/problem+json
Retry-After: 12
RateLimit-Policy: "create-links";q=30;w=60
RateLimit: "create-links";r=0;t=12
{
"type": "https://www.ninjabuy.com/api/v1/problems/rate-limited",
"title": "Too many requests",
"status": 429,
"detail": "Try again in 12 seconds.",
"request_id": "req_7Hq2Lw9c"
}Idempotency
Send an Idempotency-Key (1 to 255 printable ASCII characters; a UUID is ideal) on any POST, and a retry after a timeout cannot place a second order. For 24 hours the same key, on the same route with the same body, answers the first answer again byte for byte, with Idempotent-Replayed: true, without running again or counting against a limit.
- The same key with a different body or route:
422 idempotency-key-reused. - The first request still running:
409 idempotency-key-in-progress, withRetry-After. - A rate-limit refusal is not an answer: the key stays free, so retrying after
Retry-Afterruns the request.
Orders also carry your own external_id: a second order with the same one is refused with 409 duplicate-order, whatever key it was sent with.
POST /api/v1/orders
Idempotency-Key: 4f0c2b4e-9d1a-4e0f-b0a8-5d1d3c7e2f61
HTTP/2 201
Idempotent-Replayed: true
X-Request-Id: req_7Hq2Lw9cErrors
Errors are problem JSON (RFC 9457): type is a stable URL to branch on, title names it, detail explains this case in words you can show, and request_id finds it in our logs. A body that fails validation adds errors, each with a JSON pointer to the field.
| Status | Type | When |
|---|---|---|
| 400 | bad-json | The body is not JSON. |
| 400 | bad-body | A field is missing or wrong; errors[] points at it. |
| 400 | bad-idempotency-key | The Idempotency-Key is empty, too long or not printable ASCII. |
| 401 | unauthorized | The key or token is missing, wrong, expired or revoked. |
| 403 | insufficient-scope | The key lacks the scope the route needs. |
| 404 | not-found | No such object on this account. Another account's ids answer the same. |
| 405 | method-not-allowed | The route exists, not with this method; Allow lists the ones it takes. |
| 409 | duplicate-order | An order with this external_id exists; order names it. |
| 409 | idempotency-key-in-progress | The first request with this key is still running. |
| 409 | invalid-state | The object cannot do that now, for example cancelling a shipped order. |
| 415 | unsupported-media-type | The body is not application/json. |
| 422 | idempotency-key-reused | The key was used for a different request. |
| 422 | unprocessable | Valid JSON we cannot act on: an unread link, a sold-out variant. detail says which. |
| 429 | rate-limited | Too many calls in the window. Wait Retry-After seconds. |
| 500 | internal-error | Our fault. Retry with the same Idempotency-Key. |
| 503 | service-unavailable | We could not check the key or reach a store right now. Retry after Retry-After. |
HTTP/2 400
{
"type": "https://www.ninjabuy.com/api/v1/problems/bad-body",
"title": "Invalid request body",
"status": 400,
"detail": "1 field is invalid.",
"request_id": "req_7Hq2Lw9c",
"errors": [
{
"pointer": "/ship_to/country",
"detail": "Must be a two-letter country code."
}
]
}