Narrow.TV developer documentation
Connect API
Connect your own systems to Narrow.TV: send POS data to your accounting package, keep products and stock in sync with your web shop or ERP, and process kiosk and Scan & Order orders. Webhooks tell your system when something changes.
The API is included for customers with at least one screen of type POS, order kiosk or Scan & Order. The account owner creates API tokens and webhooks in the Narrow.TV dashboard, under the account menu → API-toegang.
Conventions
- Requests and responses are JSON in UTF-8. Send
Content-Type: application/jsonwith a request body. - Money is an integer number of cents, with a
currencyfield (for example895with"EUR"is € 8.95). - Times are ISO 8601 in UTC. Ids are UUIDs.
- HTTPS only. The API is meant for server-to-server use and does not send CORS headers, so do not call it from a browser.
- Changes made through the API are attributed to
API: <token name>, for example in the stock history.
Environments
| Environment | Base URL |
|---|---|
| Production | https://connect.narrow.tv/v1 |
| Sandbox | https://connect.sbx.narrow.tv/v1 |
The sandbox is a separate environment with its own dashboard, data and tokens. Build and test your integration there, then create a production token and switch the base URL.
The full machine-readable specification is available as OpenAPI 3.1. You can import it into Postman, Insomnia or a client generator.
Getting started
Two common integrations, step by step. Both start with a token from the dashboard (see Authentication).
1. Daily figures for your accounting package
- Create a token with the scope
pos:readfor the locations you want to book. - Call
GET /locationsto find the location ids. - Once a day, after closing, call
GET /locations/{locationId}/daily-summary?date=YYYY-MM-DD. The summary contains sales, refunds, discounts, the split per VAT rate and per payment method. It is calculated from the journal in the location's time zone (Europe/Amsterdam), so it also works for days without a Z report. - Need the individual receipts? Page through
GET /locations/{locationId}/transactions?from=…&to=…, or subscribe to thepos.transaction.createdwebhook.
2. Stock from your web shop
- Create a token with
catalog:readandstock:write. - Match products by barcode:
GET /locations/{locationId}/products?barcode=…. - After a stock count, set the quantity with
PUT /locations/{locationId}/stock/{productId}. For a delivery or a correction, send the change withPOST /locations/{locationId}/stock/adjustments. - Send an
Idempotency-Keywith every write, so a retry after a network error is never booked twice. - Subscribe to
stock.changedto hear about sales at the register.
Authentication
Every request needs an API token in the Authorization header:
Authorization: Bearer ntk_…
- Create tokens in the dashboard, under the account menu → API-toegang. Only the account owner can do this. Give each integration its own token with a recognisable name.
- The token is shown once, right after you create it. Narrow.TV only stores a hash, so it cannot show the token again. Store it in a secret manager or environment variable, never in source code or a browser.
- The dashboard shows the first characters of each token (its prefix), when it was last used and when it expires, so you can tell tokens apart.
- To rotate a token, create a new token with the same permissions, deploy it, and then revoke the old one. A token can also get an expiry date.
- A missing, unknown, expired or revoked token returns
401 unauthorized. - If the account no longer has a POS, order kiosk or Scan & Order screen, all tokens return
403 api_not_available. Nothing is deleted: the tokens work again once a qualifying screen is added.
Scopes
A token's permissions are set per location, as a set of scopes. A request without the required scope returns 403 forbidden_scope. Each endpoint in the reference shows the scope it needs.
| Scope | Allows |
|---|---|
pos:read | POS transactions, Z reports and daily summaries. |
catalog:read | Categories, products and option groups. |
catalog:write | Create, update and delete the location's categories and products. |
stock:read | Stock levels and stock movements. |
stock:write | Stock counts (set a quantity), deliveries and corrections (add or subtract). |
orders:read | Order kiosk and Scan & Order orders. |
Give a token only the scopes it needs. A web shop that syncs stock does not need pos:read.
Locations
Almost every endpoint lives under a location: /locations/{locationId}/…. A token only has access to the locations that were ticked for it in the dashboard, each with its own scopes. For example, a token can read POS data for one shop and write stock for another.
GET /locationslists the locations the token can access, with the scopes it has for each one.- A location the token has no access to returns
403 forbidden_location. - Dates such as the daily summary's
dateare interpreted in the location's time zone (Europe/Amsterdam). Timestamps in responses are always UTC.
Pagination
List endpoints return a page of results and a cursor for the next page:
limitsets the page size: 50 by default, at most 200.- The response has the shape
{ "data": [ … ], "nextCursor": "…" }. - To get the next page, repeat the same request with
cursor=<nextCursor>. Keep the other query parameters the same. - When
nextCursorisnull, you have reached the last page. - Cursors are opaque: do not parse or build them yourself.
Most lists can also be filtered: updatedSince (ISO 8601) on products and categories, and from / to (an ISO date or date-time) on transactions, Z reports, stock movements and orders. For incremental syncs, store the time of your last successful sync and pass it as updatedSince next time.
Idempotency
Write requests (POST, PUT, PATCH, DELETE) accept an Idempotency-Key header of at most 100 characters. Use a unique value per intended change, such as a UUID or your own delivery number.
- The same key with the same body within 24 hours returns the stored response. The change is not applied a second time.
- Error responses are stored and replayed too. If a request was rejected with a
4xx(for example422 validation_failed), sending it again with the same key returns that same error, even after the cause was fixed on your side. After fixing a rejected request, send it with a new key. 429and5xxresponses are not stored: retry those with the same key.- The same key with a different body returns
409 conflict. - Keys are stored per token and expire after 24 hours.
This makes it safe to retry after a timeout or a dropped connection. It matters most for stock adjustments, where a duplicate would change the stock twice.
Rate limits
| Limit | Requests |
|---|---|
| Per token | 120 per minute |
| Per customer (all tokens together) | 600 per minute |
Every response carries the token's current window in X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (a Unix timestamp).
When you go over a limit, the API returns 429 rate_limited with a Retry-After header: the number of seconds to wait before trying again. Honour it, and add some jitter when several workers retry at the same time.
Repeated requests with an invalid token are limited per IP address as well; wait for Retry-After before trying again.
Prefer webhooks and updatedSince over polling full lists, and use limit=200 for bulk reads.
Bulk import
For large ranges, do not send one request per product: at 120 requests per minute, 20,000 single POSTs take almost three hours. Use the batch endpoints instead. Each batch counts as one request for the rate limit.
| Endpoint | Per request |
|---|---|
POST /locations/{id}/products/batch | Up to 500 products (create or update) |
PUT /locations/{id}/stock | Up to 1000 stock levels (count) |
POST /locations/{id}/option-groups/batch | Up to 200 option groups with their options (see Options) |
POST /locations/{id}/products/option-groups | Link or unlink option groups on up to 1000 products |
Match on your own article code
Give every product an externalId: your own article code or SKU (1–100 letters, digits, spaces and . _ - /, unique per location). The batch matches each item on it (matchBy, default externalId; barcode and id also work). A match is updated with only the fields you send; an unknown article code is created (createMissing, default true). Use categoryName instead of categoryId to find or create a category by name.
Partial success
Items are independent. The response is 200 with counts and one result per item, in request order: created, updated, unchanged (nothing to write) or failed with an error code and message. A failed item never blocks the others, so only resend the failed ones after fixing them. Send an Idempotency-Key per batch so a retry after a timeout returns the stored result instead of writing again.
A batch body may be at most 2 MB (413 payload_too_large). 500 products with normal descriptions fit easily.
Images
Send imageUrl: an HTTPS link to a public JPEG, PNG, WebP or GIF (at most 5 MB). Images are processed in the background, usually within minutes: we download the file, scale it to at most 800 px and store a copy. Until then the product's imageStatus is pending; after that done (and imageUrl points to our copy) or failed with an imageError. Sending the same URL again changes nothing, so a nightly sync does not download images again. GET /locations/{id}/image-jobs shows the progress (?status=failed lists what went wrong).
Syncing 20,000 products
Split the range into 40 batches of 500 and send them one after the other. A batch usually takes about a second, so the whole range is in within a minute or two, far below the rate limit. Then set the stock with PUT /stock in batches of 1000 (20 requests). Webhooks receive one catalog.products.changed event per product batch and one stock.batch_changed event per stock batch, not one per product.
Options
Option groups are the choices a customer makes with a product, such as a sauce or a size. A group belongs to one location and has minSelect (choices required, 0 = optional), maxSelect (choices allowed, 0 = no maximum, otherwise at least minSelect) and 1–100 options, each with a surcharge in priceCents and isAvailable.
- Give groups and options an
externalId(your own code, like a product's article code): unique per location for groups and per group for options. POST /option-groupscreates a group with its options;PATCH /option-groups/{id}changes only the fields you send. Sendingoptionsreplaces the whole list: an option that matches an existing one byid,externalIdor name keeps its id, the rest is created, and options you leave out are deleted. Open orders and receipts keep their own copy, so this is safe.- Change one option with
PATCH /option-groups/{id}/options/{optionId}.{"isAvailable": false}marks it sold out; kiosks and Scan & Order show it but it cannot be chosen. POST /option-groups/batchupserts up to 200 groups byexternalId(orid), with the same per-item results as the product batch.- Link groups to products with
optionGroupIdson a product, or for many products at once withPOST /products/option-groups(add/remove, by id orexternalId). A product can only use option groups of its own location: a group of another location is reported infailedwithforbidden_location. - Deleting a group unlinks it from its products.
Nightly sync
Send all groups in one batch, then set the links. Groups that did not change come back as unchanged and send no webhook.
Errors
Errors use the usual HTTP status codes and a JSON body with a stable, machine-readable code and a human-readable message. Base your logic on the code; the message may change.
| Status and code | Meaning |
|---|---|
400 validation_failed | The request is malformed, for example invalid JSON or a bad query parameter. |
401 unauthorized | The token is missing, unknown, expired or revoked. |
403 forbidden_scope | The token lacks the scope this endpoint needs for this location. |
403 forbidden_location | The token has no access to this location. |
403 api_not_available | The account has no POS, order kiosk or Scan & Order screen (any more). |
404 not_found | The resource does not exist in this location. |
409 conflict | The request conflicts with the current state, or an Idempotency-Key was reused with a different body. |
413 payload_too_large | A batch body is larger than 2 MB. Send fewer items per request. |
422 validation_failed | The body is valid JSON but a field is missing or invalid. |
429 rate_limited | Too many requests. Wait for Retry-After seconds. |
Retry 429 and 5xx responses with backoff (with the same Idempotency-Key for writes). Do not retry other 4xx responses without changing the request, and send the changed request with a new Idempotency-Key: the old key replays the stored error.
Webhooks
Instead of polling, let Narrow.TV call your server when something happens. The account owner adds webhooks in the dashboard, under API-toegang: an HTTPS URL, the events to receive, and optionally which locations (none selected means all locations). You receive a signing secret (whsec_…) once; it can be rolled later.
Events
| Event | Sent when |
|---|---|
pos.transaction.created | A journal row is written at the register: sales, refunds, opening float and cash in/out. |
pos.zreport.created | A Z report is created. |
stock.changed | Stock changed through any stock movement. Bursts are combined: at most one event per product per minute, with the latest quantity. |
order.created | A kiosk or Scan & Order order is created. |
order.status_changed | The status of a kiosk or Scan & Order order changes. |
catalog.product.changed | A product is created, updated or deleted. |
catalog.products.changed | A product batch was written ({ids, created, updated}, one event per batch), background image downloads finished (reason: "images"), or a bulk action in the dashboard changed or deleted products (reason: "bulk", deletions in deletedIds). |
stock.batch_changed | A stock batch was written ({reason, items: [{productId, quantity}]}, one event per batch). |
catalog.optiongroup.changed | An option group or one of its options was created, changed or deleted (the group plus deleted: false, or {id, deleted: true}). An option group batch sends one event {ids, created, updated}. Products whose option groups change get one catalog.products.changed event (reason: "optionGroups"). |
ping | You press Test in the dashboard. |
Payload
Each delivery is a POST with a JSON envelope. data has the same shape as the matching GET endpoint returns.
Delivery and retries
- Events are delivered by a background job that runs every minute, so an event usually arrives within a minute. Do not rely on it being instant.
- Respond with any
2xxstatus within 10 seconds. Redirects are not followed. Do the heavy work after responding, for example in a queue. - Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours. After that, the delivery is given up.
- After 50 failed deliveries in a row, the webhook is switched off and the account owner gets an e-mail. Switch it back on in the dashboard once your endpoint works again.
- Deliveries are at least once and may arrive out of order. Use the event
idto skip duplicates, andcreatedAtor a follow-upGETto get the latest state. - The dashboard shows the latest deliveries with their status code and lets you send one again.
- Webhook URLs must use HTTPS and must resolve to a public address.
Signature verification
Every delivery carries a NarrowTV-Signature header, so you can check that it really comes from Narrow.TV and was not changed or replayed:
NarrowTV-Signature: t=<unix timestamp>,v1=<signature>
The signature is the hex-encoded HMAC-SHA256 of the timestamp, a dot and the raw request body, with your webhook secret (the full value, including whsec_) as the key:
v1 = hex(hmac_sha256(secret, t + "." + raw_body))
- Read the raw request body, before any JSON parsing. Re-encoded JSON will not match.
- Split the header on
,and each part on the first=. Taketand thev1value(s). Ignore parts you do not know. - Reject the request if
tis more than 5 minutes away from your current time. This blocks replayed deliveries. - Compute the expected signature and compare it with a constant-time comparison. Accept the request if any
v1matches. - Respond with
400or401when the check fails.
When you roll the secret in the dashboard, the new secret applies to the next deliveries. Update your server right away.