Narrow.TV Connect API v1 openapi.json

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/json with a request body.
  • Money is an integer number of cents, with a currency field (for example 895 with "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

EnvironmentBase URL
Productionhttps://connect.narrow.tv/v1
Sandboxhttps://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

  1. Create a token with the scope pos:read for the locations you want to book.
  2. Call GET /locations to find the location ids.
  3. 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.
  4. Need the individual receipts? Page through GET /locations/{locationId}/transactions?from=…&to=…, or subscribe to the pos.transaction.created webhook.

2. Stock from your web shop

  1. Create a token with catalog:read and stock:write.
  2. Match products by barcode: GET /locations/{locationId}/products?barcode=….
  3. After a stock count, set the quantity with PUT /locations/{locationId}/stock/{productId}. For a delivery or a correction, send the change with POST /locations/{locationId}/stock/adjustments.
  4. Send an Idempotency-Key with every write, so a retry after a network error is never booked twice.
  5. Subscribe to stock.changed to 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.

ScopeAllows
pos:readPOS transactions, Z reports and daily summaries.
catalog:readCategories, products and option groups.
catalog:writeCreate, update and delete the location's categories and products.
stock:readStock levels and stock movements.
stock:writeStock counts (set a quantity), deliveries and corrections (add or subtract).
orders:readOrder 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 /locations lists 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 date are 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:

  • limit sets 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 nextCursor is null, 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 example 422 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.
  • 429 and 5xx responses 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

LimitRequests
Per token120 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.

EndpointPer request
POST /locations/{id}/products/batchUp to 500 products (create or update)
PUT /locations/{id}/stockUp to 1000 stock levels (count)
POST /locations/{id}/option-groups/batchUp to 200 option groups with their options (see Options)
POST /locations/{id}/products/option-groupsLink 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-groups creates a group with its options; PATCH /option-groups/{id} changes only the fields you send. Sending options replaces the whole list: an option that matches an existing one by id, externalId or 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/batch upserts up to 200 groups by externalId (or id), with the same per-item results as the product batch.
  • Link groups to products with optionGroupIds on a product, or for many products at once with POST /products/option-groups (add / remove, by id or externalId). A product can only use option groups of its own location: a group of another location is reported in failed with forbidden_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 codeMeaning
400 validation_failedThe request is malformed, for example invalid JSON or a bad query parameter.
401 unauthorizedThe token is missing, unknown, expired or revoked.
403 forbidden_scopeThe token lacks the scope this endpoint needs for this location.
403 forbidden_locationThe token has no access to this location.
403 api_not_availableThe account has no POS, order kiosk or Scan & Order screen (any more).
404 not_foundThe resource does not exist in this location.
409 conflictThe request conflicts with the current state, or an Idempotency-Key was reused with a different body.
413 payload_too_largeA batch body is larger than 2 MB. Send fewer items per request.
422 validation_failedThe body is valid JSON but a field is missing or invalid.
429 rate_limitedToo 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

EventSent when
pos.transaction.createdA journal row is written at the register: sales, refunds, opening float and cash in/out.
pos.zreport.createdA Z report is created.
stock.changedStock changed through any stock movement. Bursts are combined: at most one event per product per minute, with the latest quantity.
order.createdA kiosk or Scan & Order order is created.
order.status_changedThe status of a kiosk or Scan & Order order changes.
catalog.product.changedA product is created, updated or deleted.
catalog.products.changedA 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_changedA stock batch was written ({reason, items: [{productId, quantity}]}, one event per batch).
catalog.optiongroup.changedAn 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").
pingYou 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 2xx status 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 id to skip duplicates, and createdAt or a follow-up GET to 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))
  1. Read the raw request body, before any JSON parsing. Re-encoded JSON will not match.
  2. Split the header on , and each part on the first =. Take t and the v1 value(s). Ignore parts you do not know.
  3. Reject the request if t is more than 5 minutes away from your current time. This blocks replayed deliveries.
  4. Compute the expected signature and compare it with a constant-time comparison. Accept the request if any v1 matches.
  5. Respond with 400 or 401 when the check fails.

When you roll the secret in the dashboard, the new secret applies to the next deliveries. Update your server right away.