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.
- 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.
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. |
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.
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. |
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.