Start here
Five steps, and the first four cost nothing and touch no money.
- Register as an integrator. You get a developer account and sandbox keys immediately. Nothing you do with a
sk_test_key is real, so there is nothing to be careful about yet. - Prove the key works.
GET /pingneeds no scope and no business — it is the one call you can make on day one. - Ask a business to authorise you. You invite it; it accepts in its own portal. Until it does, every call
naming that business is refused
operator_not_linked. That is not a bug: consent is theirs to give. - Issue in the sandbox. Same endpoints, same validation, same document, same webhooks — and a sandbox document never accrues to the levy ledger.
- Ask to go live. Two different people at the platform review it. Then the same code, with a
sk_live_key, is issuing real fiscal documents.
curl https://tourismrevenue.co/api/v1/ping \
-H "Authorization: Bearer sk_test_your_key_here"
# {"ok":true,"data":{"pong":true,"environment":"sandbox"},"request_id":"01JB…"}curl -X POST https://tourismrevenue.co/api/v1/invoices \
-H "Authorization: Bearer sk_test_your_key_here" \
-H "X-Operator-Code: LAG-HTL-0042" \
-H "Idempotency-Key: 8f14e45f-ea2b-4b6e-9a1f-2d6b1c0a77e1" \
-H "Content-Type: application/json" \
-d '{
"kind": "B2C",
"external_ref": "folio-10482",
"customer": { "name": "Adaeze Okafor", "phone": "+2348030000000" },
"lines": [
{ "category": "ROOM_NIGHT", "description": "Deluxe room, 2 nights",
"quantity": 2, "unit_price": 4500000 },
{ "category": "SERVICE_CHARGE", "description": "Service charge",
"quantity": 1, "unit_price": 450000 }
],
"payment_status": "paid"
}'<?php
// One sale, one Idempotency-Key. Generate it BEFORE the first attempt and reuse
// it for every retry of that same sale — that is what makes a retry safe.
$idempotencyKey = bin2hex(random_bytes(16));
$body = json_encode([
'kind' => 'B2C',
'external_ref' => 'folio-10482',
'customer' => ['name' => 'Adaeze Okafor', 'phone' => '+2348030000000'],
// unit_price is KOBO. ₦45,000.00 is 4500000. Never a float.
'lines' => [
['category' => 'ROOM_NIGHT', 'description' => 'Deluxe room, 2 nights', 'quantity' => 2, 'unit_price' => 4500000],
],
'payment_status' => 'paid',
], JSON_UNESCAPED_SLASHES);
$ch = curl_init($apiBase . '/invoices');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $secretKey,
'X-Operator-Code: ' . $operatorCode,
'Idempotency-Key: ' . $idempotencyKey,
'Content-Type: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// A timeout is NOT a failure to issue. Retry the same request with the same
// Idempotency-Key: if the first attempt landed, you get its answer back.
if ($raw === false) {
return retry_later($idempotencyKey, $body);
}
$answer = json_decode($raw, true);
if (!($answer['ok'] ?? false)) {
// Branch on the code, never on the message.
if (in_array($answer['error']['code'], ['gateway_error', 'service_unavailable', 'rate_limited'], true)) {
return retry_later($idempotencyKey, $body); // nothing was issued
}
throw new RuntimeException($answer['error']['code'] . ': ' . $answer['error']['message']);
}
store_document_uid($answer['data']['uid']);// Node 18+ — one sale, one key, reused on every retry of that sale.
import { randomUUID } from 'node:crypto';
async function issueInvoice(sale, { apiBase, secretKey, operatorCode }) {
const idempotencyKey = sale.idempotencyKey ?? (sale.idempotencyKey = randomUUID());
const res = await fetch(`${apiBase}/invoices`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${secretKey}`,
'X-Operator-Code': operatorCode,
'Idempotency-Key': idempotencyKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
kind: 'B2C',
external_ref: sale.folio,
customer: { name: sale.guestName },
// Kobo integers. Math.round(naira * 100), never a float on the wire.
lines: sale.lines.map(l => ({
category: l.category,
description: l.description,
quantity: l.quantity,
unit_price: Math.round(l.unitPriceNaira * 100),
})),
payment_status: 'paid',
}),
});
const answer = await res.json();
if (!answer.ok) {
const retryable = ['gateway_error', 'service_unavailable', 'rate_limited', 'request_in_flight'];
if (retryable.includes(answer.error.code)) {
// Nothing was issued. Queue it and send the SAME body with the SAME key.
return { retry: true, after: Number(res.headers.get('Retry-After') ?? 5) };
}
throw new Error(`${answer.error.code}: ${answer.error.message}`);
}
return answer.data;
}HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
X-Api-Environment: sandbox
X-Request-Id: 01JBQ8Z9K3M7T0V6WQ2XN4YCPD
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 119
{
"ok": true,
"data": {
"uid": "01JBQ8Z9K3M7T0V6WQ2XN4YCPD",
"environment": "sandbox",
"invoice_type_code": "380",
"status": "confirmed",
"irn": "INV0042-A1B2C3D4-20260904",
"payable_amount": 9450000,
"tdc_base_amount": 9000000,
"tdc_amount": 90000,
"tdc_rate_bp": 100,
"verify_url": "https://tourismrevenue.co/verify/irn/INV0042-A1B2C3D4-20260904"
},
"request_id": "01JBQ8Z9K3M7T0V6WQ2XN4YCPD"
}Authentication
Every call carries a secret key as a bearer token. The mode is in the key itself, in the clear, because the first question anyone debugging asks is "am I on the test key?".
| Header | Required | What it is |
|---|---|---|
Authorization | Always | Your secret key: Bearer sk_live_… for real traffic, Bearer sk_test_… for the sandbox. |
X-Operator-Code | Integrator keys | The business you are acting for. Its code is on its own dashboard; it is not a secret, and it is not enough on its own. |
Idempotency-Key | Every POST | A unique key for this attempt, 8–190 printable characters, no spaces. A UUID is ideal. |
Content-Type | Requests with a body | application/json. Anything else is refused unsupported_media_type. |
Scopes
A scope is a resource and a verb, so a till that only ever sells can hold a key that can do nothing else. The business you act for can narrow what you hold — never widen it — so the effective permission is the intersection of the two.
| Scope | What it permits | On a new key by default |
|---|---|---|
invoices:read | Read the invoices this business has issued. | Yes |
invoices:write | Issue, credit and correct fiscal invoices in this business's name. | Yes |
credit_notes:read | Read credit notes. | No |
credit_notes:write | Issue credit notes against this business's invoices. | No |
customers:read | Read the buyer records this business holds. | No |
customers:write | Create and correct buyer records. | No |
documents:read | Read fiscal documents this business has received from its suppliers. | Yes |
operators:read | Read this business's own registered particulars and its reference data. | Yes |
levy:read | Read the levy accrued and what is owed. | No |
webhooks:manage | Register and manage the endpoints platform events are delivered to. | No |
Keeping a key safe
- Never in a browser. A secret key in front-end code is a published secret. Call this API from your server.
- Pin it to your addresses. A key can carry an IP allowlist; a key used from anywhere else is then refused
ip_not_allowedeven if it is otherwise perfect. - One key per deployment. Rotation issues a new key and leaves the old one working until you revoke it, so a rotation is a deployment window rather than an outage.
- Watch the prefix, not the key. Logs, tickets and dashboards name a key by its prefix
(
sk_test_a1b2c3d4). If you ever see a whole key in one of ours, tell us.
Acting for a business
An integrator key is not a master key. It works for exactly the businesses that have said yes to you, and it stops working for one the moment that business changes its mind — with no notice to you, no approval from us, and no counter-signature. Consent that cannot be withdrawn unilaterally was never consent.
- You invite the business from your portal. It gets a one-time code, shown once, valid for 168 hours.
- Somebody signed in at that business accepts it, choosing which of your requested scopes to grant. Their name and the moment are recorded against the grant.
- Your calls carrying that business's
X-Operator-Codestart working. Nothing else changes about your key.
operator_mismatch. It never needs an invitation:
it is already acting for itself.
Two refusals are deliberately indistinguishable: a business that has never invited you and a business that does not
exist both answer operator_not_linked. Otherwise the door could be used to find out which businesses
are on the register, one guess at a time.
Sandbox and live
There is one API and two worlds, and the key decides which one you are in. A sk_test_ key
resolves to the sandbox — every time, with no way to opt out of it and no header that overrides it.
| Sandbox (sk_test_…) | Live (sk_live_…) | |
|---|---|---|
| Documents | Issued, validated and signed by a simulator | Issued and lodged with the Nigeria Revenue Service |
| The levy | Never accrues. Not on the ledger, not in a remittance, not in any figure a state sees | Accrues, remits and is reconciled |
| Webhooks | Delivered to your test endpoints only | Delivered to your live endpoints only |
| Available from | The minute you register | When two people at the platform have approved you |
X-Api-Environment: sandbox or
live. An integration that believes it is in the sandbox and is not is the one failure this header exists
to make impossible to miss — and a sandbox invoice a production system booked as revenue is the one it exists to
prevent.
Money on the wire
Every amount, in both directions, is an integer number of kobo. ₦45,000.00 is 4500000. There are
no decimal amounts in this API and no floats: a float over JSON is a rounding error waiting for a month end, and the
month end it waits for is a tax month.
- Convert once, at the edge:
round(naira * 100)going out, divide by 100 for display only. - Never sum the naira you displayed. Sum the kobo you sent.
- The levy is quoted in basis points (
tdc_rate_bp): 100 basis points is 1%. Read the rate fromGET /tariff; it is set by an approved tariff version and it can change between one booking and the next. - A line marked
pass_throughis money you collect for somebody else. It is on the invoice and it is out of the levy base.
Idempotency — a retry is not a second sale
A till on a bad connection sends the same POST five times. Every one of those is the same sale. So every POST to
this API requires an Idempotency-Key — previews and quotes included, because one rule with no
exceptions is the only kind anyone implements correctly at two in the morning.
Mint the key before the first attempt, store it with the sale, and reuse it for every retry of that sale. A new sale gets a new key. That is the whole discipline.
| You send | You get |
|---|---|
| A new key | The work is done once. The answer is stored for 7 days. |
| The same key, the same body | The first answer, byte for byte, with Idempotent-Replay: true. Nothing is issued again. |
| The same key, a different body | 422 idempotency_mismatch. Never obeyed — nothing was issued. A new request needs a new key. |
| The same key while the first is still running | 409 request_in_flight with Retry-After. Wait, then retry unchanged. Do not send it as a new request. |
| No key at all | 400 idempotency_key_required. Nothing was issued. |
# First attempt — the network died before the answer came back.
curl -X POST https://tourismrevenue.co/api/v1/invoices \
-H "Authorization: Bearer sk_test_your_key_here" \
-H "X-Operator-Code: LAG-HTL-0042" \
-H "Idempotency-Key: till-3-2026-09-04-0001" \
-H "Content-Type: application/json" \
-d @sale.json
# Second attempt — the SAME key, the SAME body. No second document.
# HTTP/1.1 201 Created
# Idempotent-Replay: true ← the first answer, byte for byte
# X-Request-Id: 01JC… ← this call's own id, for support
# A DIFFERENT body under that key is a client bug, and is refused:
# HTTP/1.1 422
# {"ok":false,"error":{"code":"idempotency_mismatch",
# "message":"This Idempotency-Key was already used with a different request body…"}}external_ref is a second exactly-once guard: a sale sent again under a fresh key is refused
409, and the answer names the document that already exists.
Errors
Every response is the same envelope. A success carries data; a refusal carries error. Both
carry a request_id — log it, and quote it if you ever open a ticket with us.
{ "ok": true, "data": { … }, "request_id": "01JB…" }
{ "ok": false, "error": { "code": "invalid_request",
"message": "Some fields could not be accepted. See error.fields.",
"fields": { "lines.0.unit_price": "Must be a whole number of kobo." } },
"request_id": "01JB…" }Branch on error.code, never on the message. The codes are a contract and will not change under
you; the messages are written for humans and are improved whenever we learn something from a support ticket.
| Code | Status | What it means | What to do |
|---|---|---|---|
unauthorized | 401 | No usable API key was presented. Send it as "Authorization: Bearer sk_…". | Do not retry. Fix the credential. |
key_revoked | 401 | This API key has been revoked. Issue a new one in the developer portal — a revoked key is never restored. | Do not retry. Mint a new key; a revoked key is never restored. |
key_expired | 401 | This API key has expired. Issue a new one in the developer portal. | Do not retry. Mint a new key. |
forbidden | 403 | This key is not authorised to do that. | Do not retry. |
insufficient_scope | 403 | This key does not carry the scope this endpoint requires. | Do not retry. Add the scope to the key, or ask the business to widen its grant. |
operator_not_linked | 403 | That operator has not authorised you to act on its behalf. Ask it to accept your link request, then retry. | Do not retry until the business accepts your link request. |
operator_mismatch | 403 | This key belongs to one operator and cannot act for another. Send its own X-Operator-Code, or use an integrator key. | Do not retry. Send this key's own operator code, or use an integrator key. |
ip_not_allowed | 403 | This key may only be used from the addresses on its allowlist. | Do not retry from this address. |
integrator_suspended | 403 | This integrator account is suspended. Please contact platform support. | Do not retry. Contact platform support. |
live_not_enabled | 403 | This integrator is not approved for live traffic yet. Use a sandbox key (sk_test_…) until it is. | Do not retry live. Carry on in the sandbox until you are approved. |
sandbox_only | 403 | That is not available to a sandbox key. A sandbox document never touches money. | Do not retry with a sandbox key. |
idempotency_key_required | 400 | Every POST needs an Idempotency-Key header, so that a retry on a bad network cannot become a second sale. | Retry with an Idempotency-Key header. Nothing was issued. |
invalid_idempotency_key | 400 | The Idempotency-Key must be 8 to 190 printable characters with no spaces. A UUID is ideal. | Retry with a valid key (8–190 printable characters, no spaces). Nothing was issued. |
malformed_json | 400 | The request body is not valid JSON. | Do not retry unchanged. Nothing was issued. |
not_found | 404 | No such resource. | Do not retry. |
method_not_allowed | 405 | That method is not allowed on this endpoint. | Do not retry. |
conflict | 409 | That resource is in a state which does not allow this. | Do not retry. Read the answer: it names the document that already exists. |
request_in_flight | 409 | The first request carrying this Idempotency-Key is still running. Wait the interval given and retry it unchanged. | Wait for Retry-After, then retry UNCHANGED with the same Idempotency-Key. |
operator_not_ready | 409 | That operator is not set up to issue fiscal documents yet. Nothing was issued. | Do not retry until the business is set up. Nothing was issued. |
payload_too_large | 413 | The request body is larger than this endpoint accepts. | Do not retry unchanged. Send fewer rows. |
unsupported_media_type | 415 | Send the body as application/json. | Retry with Content-Type: application/json. |
invalid_request | 422 | Some fields could not be accepted. See error.fields. | Do not retry unchanged. Fix the fields named in error.fields. Nothing was issued. |
idempotency_mismatch | 422 | This Idempotency-Key was already used with a different request body. A new request needs a new key; nothing was issued. | Do not retry with this key. A different request needs a NEW key. Nothing was issued. |
unprocessable | 422 | The request was understood but could not be carried out. | Do not retry unchanged. |
rate_limited | 429 | Too many requests. Retry after the interval given. | Wait for Retry-After, then retry with the same Idempotency-Key. |
server_error | 500 | Something went wrong at our end. Quote the request_id when you report it. | Retry with the SAME Idempotency-Key. Quote request_id if it persists. |
gateway_error | 502 | The tax service could not be reached. Nothing was issued — retry with the SAME Idempotency-Key. | Nothing was issued. Retry with the SAME Idempotency-Key. |
service_unavailable | 503 | The service is briefly unavailable. Retry with the SAME Idempotency-Key. | Nothing was issued. Retry with the SAME Idempotency-Key after the interval given. |
gateway_error and
service_unavailable both mean the document does not exist — retry with the same Idempotency-Key.
You never need a "reconcile by hand" branch for either.
Limits
Read live from this platform's own settings — these are the numbers the door actually enforces today, not numbers somebody typed into a document once.
| Limit | Value | What happens |
|---|---|---|
| Calls per key | 120 per minute | 429 rate_limited with Retry-After. Your key may carry its own higher limit; X-RateLimit-Limit on every response is the truth. |
| Calls per address | 600 per minute | The same, counted per source address across all keys. |
| Failed authentications | 10 per 15 minutes | 429 too_many_auth_failures. Fix the credential rather than retrying it. |
| Idempotent answers kept | 7 days | After that a replayed key is treated as a new request. |
| In-flight lock | 120 seconds | A request that dies mid-flight unblocks its key after this, so a crash can never wedge a till for ever. |
| API keys per account | 10 live at once | Revoke one to mint another. |
| Webhook endpoints | 10 per account | Per owner, across both modes. |
Rate limits are a fixed window of 60 seconds. Respect
Retry-After: it is given in seconds precisely so your clock and ours never have to agree.
API reference
Everything below is generated from the platform's own route table and the scope rule the door applies, so this list
is what is deployed rather than what was last written down. Base URL: https://tourismrevenue.co/api/v1.
Diagnostics
Answers 200 for any key that is valid, unrevoked and unexpired. It needs no scope and no X-Operator-Code, so it is the first call to make on the day a key is issued — before any business has authorised you. The answer says which environment the key resolves to; if that says sandbox when you expected live, stop and fix it here rather than three endpoints later.
The key's owner, environment and scopes — and, when X-Operator-Code is present, the business it is acting for and the scopes that business has actually granted, which may be narrower. Read this before debugging a 403; quote it in a support ticket. The key itself is never echoed, only its public prefix.
Invoices
Prices the lines against the tariff in force at the tax point, builds the canonical fiscal document and puts it through the tax service inline, so a till gets the IRN and the QR back in the same round trip it made the sale in. Send "async": true to have the lodgement queued instead and poll the document — either way the DOCUMENT exists before this returns. This is the call that charges the levy, so it is idempotent by construction: the same Idempotency-Key with the same body returns the first answer and issues nothing further.
Runs exactly the pricing the issue call would run — the same tariff version, the same exemptions, the same rounding — and returns the totals and the priced lines. No document, no IRN, no levy. Show the guest this total; issue the same body afterwards and the figures will match. It still takes an Idempotency-Key: one POST helper on your side, one rule to remember.
For a till that has been offline: send the sales it took while it could not reach us, oldest first. Every row is priced and validated BEFORE any document is issued, the per-row outcome is durable (poll the batch long after this request has gone), and a batch whose content has been sent before is refused rather than issued twice. The documents are created in this request; the tax service half is queued, so watch the batch or subscribe to invoice.signed.
The documents this business has issued, newest first. A sandbox key sees sandbox documents and a live key sees live ones: the two money worlds never appear in one list, and the count is deliberately per-page rather than a total, because a total across both worlds would tell a test key how many real documents exist.
One document in full: its lines, its taxes, its parties, its levy and where it has got to with the tax service. Poll this after an async issue if you would rather not wait for the invoice.confirmed webhook.
The customer's copy, with the QR code and the verification address on it. The only endpoint here that does not answer JSON — a PDF base64'd into an envelope is a third larger and has to be decoded before it can reach a printer. Every REFUSAL on this route is still the usual envelope, so your error handling is unchanged.
JSON by default: a data URI a till can drop straight into its receipt template, plus the verification URL the QR encodes. Add ?format=png for the image bytes if you would rather not decode base64.
The canonical document as it was signed, with the tax service's answer. This is the endpoint an accounting package archives. The document arrives as a STRING, not a nested object, and deliberately so: the signature covers THOSE bytes, and re-serialising them through our encoder would reorder keys and reformat decimals until the archive you kept was no longer the thing that was signed.
The only way to reverse or reduce a document that has already been lodged. It is a fiscal document in its own right (type 381), it references the original, and the levy it reverses is credited back on the ledger. Omit the lines to credit the invoice in full.
A further charge on a document that has already been lodged (type 383) — a minibar bill found after checkout, a late cancellation fee. It carries its own levy and it references the original.
Replaces a document's content with corrected content (type 384), referencing what it corrects. Use it when the figures were right but the document was wrong — a mistyped TIN, the wrong buyer. Where the money changes, credit and reissue instead.
The outcome of a bulk submission, row by row: what was issued, what was refused and why. It stays answerable long after the request that created it, which is what makes an offline catch-up auditable rather than hopeful.
Documents
Fiscal documents OTHER businesses have issued to this one — what a bookkeeping integration reconciles payables against. ?response=pending returns exactly the ones still owed an answer, which is the query worth running on a timer, and each row carries the seconds left on its 72-hour deadline so you can escalate without doing date arithmetic against our clock.
The buyer agrees the document is correct. The answer goes on to the tax service and stops the deadline clock.
The buyer disputes it. A reason is required and is sent on: rejection obliges the supplier to credit and reissue, and "rejected" with no explanation turns a two-day correction into a fortnight of telephone calls.
Operators
The register's record of the operator named in X-Operator-Code (or the one an operator-owned key is bound to): its code, its legal name, its trade, its premises. Codes and names, never our primary keys — an id you stored would be a number that means nothing outside our database and that we have never promised to keep.
"ready" is the whole answer a till needs before it offers to issue: true only when a credential actually resolves for this business in THIS environment. When it is false the reason is given as text, because the fix is always somebody's action — enter your tax-service keys, finish onboarding — and a machine code would not tell them which. Call it at start-up.
Raises a link request against a business by its operator code. It grants nothing: the business accepts it in its own portal, and until it does, every call naming that business is refused operator_not_linked. THE INVITE CODE IS NOT RETURNED — an answer to a POST is stored for idempotent replay, and a code stored anywhere is a code somebody else could redeem. "No such business" and "that business is not open to linking" answer identically, so a vendor cannot walk the operator register from a refusal message.
Reference
The Tourism Development Contribution schedule that governs today, already narrowed to the pilot and the state of the business you are acting for. Cells are published by CODE, never by our primary keys, and "*" in a dimension means every value of it. Never hard-code a rate: it is set by an approved tariff version and it can change between one booking and the next.
The charge lines this business may put on an invoice, with the ones its trade uses first, and whether each attracts the levy. A till renders its buttons from this rather than from a list someone typed into its configuration two versions ago.
The code lists a document is VALIDATED against before it can be signed — currencies, invoice types, payment means, tax categories. Build your dropdowns from these and you cannot send us a code we will reject. They are served for the KEY'S environment: the sandbox catalogue and the live one are different lists, and a sandbox key must never be shown the live one and believe it has been checked.
What currency the levy is computed in, and the rule for a document that is not in it. The platform does not invent an exchange rate: a document in another currency carries the rate the SELLER used, and it is rejected without one. Where no reference rate has been published, this says so plainly rather than handing your till a number we made up — a levy computed against an invented rate is a reconciliation ticket with a date on it.
Remittances
What this business owes, what it has paid and when the next payment falls due — the figures behind the "TDC due" line an accounting integration shows its user, reconciled to the levy ledger.
Standing permission to collect the levy from the business's own account. AN INTEGRATOR KEY IS REFUSED HERE whatever it is linked for: the consent a business gives a vendor is permission to handle its fiscal documents, and taking money out of its bank account is not a side effect of that. A business that wants its vendor to set this up mints an operator-owned key, which is a decision it takes deliberately and can revoke on its own screen. Consent must be evidenced, and only the masked account is ever stored.
What the period bills, what has landed against it, and the account it is being collected on — with the evidence that reconciles the two.
Hands back how to pay a period: the account, the reference and the amount. A second call with the same Idempotency-Key replays the first answer, and a call with a NEW key still does not open a second account — the business may already have typed that account number into its banking app, and changing it underneath them is how a payment goes to a dead reference.
Tickets
For attractions: admissions in bulk, up to a whole session at a time. The WHOLE batch is validated before any of it is recorded — a row with a malformed price must not leave half a morning's admissions in the ledger and half rejected, because nobody can tell from the day-sheet which half is missing. Once recording starts, a row the domain refuses is reported against its group and the rest still land.
The recent premises-days and where each one stands: still open, or closed with the day-sheet document it produced. ?premises_id adds the live running total for that gate, which is the screen a gate app renders behind its +1 buttons.
Webhooks
This key's registered endpoints, plus everything needed to build a receiver without opening the documentation: the event catalogue, the signature scheme, the retry ladder and the limits. The scheme is published beside the endpoints because a receiver that verifies signatures wrongly fails in the one way nobody notices until an event matters. Signing secrets are never in this list.
Registers an HTTPS URL and mints its signing secret. THE SECRET IS RETURNED ONCE, in this response, and can never be read again — store it before you close the connection. The URL is vetted before anything is stored and again before every delivery: https only, a public address, no userinfo, no private or link-local host. A webhook is an outbound request WE make on your say-so, so an unvetted URL would be a request forgery with our network behind it.
Delivers a signed webhook.ping to one of your endpoints so you can prove your signature check before a real invoice depends on it. It is a normal delivery — same signing, same retry ladder, same log — because a test that took a different path would prove nothing about the path that matters. The ping is not subscribable and is never fanned out to anybody else.
The endpoint goes; its deliveries do not. What we sent is evidence and is append-only, including for an endpoint somebody has since removed. Deleting twice is not an error, so this needs no Idempotency-Key.
The machine-readable contract carries the request and response schemas for every one of these: OpenAPI 3.1 · Postman collection. Both are generated from the same table you are reading, so they cannot disagree with it.
Webhooks
Do not poll us. Register an HTTPS endpoint, subscribe to what you care about, and we will tell you — with a signed request you can prove came from here.
The delivery
POST /hooks/tourismrevenue HTTP/1.1
Content-Type: application/json
X-TR-Signature: t=1788528060,v1=9f2c… ← HMAC-SHA512 over "t.body"
X-TR-Event: invoice.confirmed
X-TR-Event-Id: 01HZY6M4QK8R2T7VBN3XPD5WGA
X-TR-Delivery-Id: 91422
X-TR-Delivery-Attempt: 1
X-TR-Mode: test
{
"id": "01HZY6M4QK8R2T7VBN3XPD5WGA",
"type": "invoice.confirmed",
"mode": "test",
"created_at": "2026-09-04T19:41:00+01:00",
"subject": { "type": "document", "ref": "01JBQ8Z9K3M7T0V6WQ2XN4YCPD" },
"data": {
"uid": "01JBQ8Z9K3M7T0V6WQ2XN4YCPD",
"irn": "INV0042-A1B2C3D4-20260904",
"payable_amount": 9450000,
"tdc_amount": 90000
}
}Verifying the signature
X-TR-Signature is t=<unix seconds>,v1=<HMAC-SHA512>, computed over
t, a full stop, and the exact bytes of the body, with the signing secret you were given once when the
endpoint was created.
<?php
declare(strict_types=1);
/**
* Verify a TourismRevenue webhook. Two rules:
* 1. hash the RAW body — not a re-encoded array, not a trimmed string;
* 2. compare in constant time.
*/
function tr_verify(string $body, string $header, string $secret): bool
{
$t = null;
$v1 = '';
foreach (explode(',', $header) as $part) {
$bits = explode('=', trim($part), 2);
if (count($bits) !== 2) {
continue;
}
if ($bits[0] === 't' && ctype_digit($bits[1])) {
$t = (int) $bits[1];
} elseif ($bits[0] === 'v1') {
$v1 = $bits[1];
}
}
if ($t === null || $v1 === '' || $secret === '') {
return false;
}
$expected = hash_hmac('sha512', $t . '.' . $body, $secret);
return hash_equals($expected, $v1); // never ===
}
$body = (string) file_get_contents('php://input');
$header = (string) ($_SERVER['HTTP_X_TR_SIGNATURE'] ?? '');
$secret = (string) getenv('TR_WEBHOOK_SECRET'); // shown once, at registration
if (!tr_verify($body, $header, $secret)) {
http_response_code(400);
exit; // do NOT process it
}
$event = json_decode($body, true);
// DEDUPLICATE ON THE EVENT ID, NOT ON THE TIMESTAMP. A redelivery replays the
// original bytes — including the original t — so an age check would reject our
// own retry. The id is the identity of the FACT: seeing it twice means the same
// thing happened once.
if (already_processed($event['id'])) {
http_response_code(200);
exit;
}
// Answer 2xx quickly, then do the work. We time out and retry; a receiver that
// posts to its own queue and returns is never the reason a delivery fails.
http_response_code(200);
fastcgi_finish_request();
handle($event['type'], $event['data']);// Express — mount the RAW body parser on this route, or the signature will
// never match: express.json() re-encodes, and re-encoded is not identical.
import express from 'express';
import crypto from 'node:crypto';
const app = express();
app.post('/hooks/tourismrevenue',
express.raw({ type: 'application/json' }),
(req, res) => {
const header = req.get('X-TR-Signature') || '';
const secret = process.env.TR_WEBHOOK_SECRET;
let t = null, v1 = '';
for (const part of header.split(',')) {
const [k, v] = part.trim().split('=', 2);
if (k === 't' && /^\d+$/.test(v)) t = v;
else if (k === 'v1') v1 = v;
}
if (t === null || !v1) return res.sendStatus(400);
const expected = crypto.createHmac('sha512', secret)
.update(`${t}.${req.body.toString('utf8')}`)
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(v1, 'utf8');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString('utf8'));
// Deduplicate on event.id: a redelivery is the same fact, not a new one.
if (seen.has(event.id)) return res.sendStatus(200);
seen.add(event.id);
res.sendStatus(200); // acknowledge first
queue.push(event); // work afterwards
});t — so that our retry is byte-identical to our first attempt. If you enforce a
timestamp window, make it at least as long as the retry ladder (1 min, 5 min, 30 min, 2 h, 12 h, 24 h). Better: ignore the age and
deduplicate on id, which is the identity of the fact itself.
Delivery and retries
- Answer 2xx quickly, then do the work. We time out; a receiver that queues and returns is never the reason a delivery fails.
- Anything else is retried on the ladder 1 min, 5 min, 30 min, 2 h, 12 h, 24 h — 7 attempts in all, then the delivery is dead and only a person can move it.
- After 5 consecutive failures the endpoint is flagged and you are told; after 20 it is switched off. Pause it yourself while you deploy rather than letting it fail.
- One fact, one delivery: the same event announced twice by a retried producer is stored once per endpoint. Your receiver should still be idempotent — the internet is not.
- Test endpoints hear sandbox facts and live endpoints hear live ones. There is no configuration that crosses them.
- Send yourself a signed
webhook.pingfrom the portal orPOST /webhooks/testbefore a real invoice depends on your verification code.
The events
| Event | What it means |
|---|---|
invoice.signed | Invoice signed |
invoice.confirmed | Invoice confirmed by NRS |
invoice.accepted | Invoice accepted by the buyer |
invoice.rejected | Invoice rejected |
invoice.failed | Invoice failed to lodge |
invoice.late_reported | Invoice reported late |
remittance.issued | Remittance issued |
remittance.paid | Remittance paid |
remittance.overdue | Remittance overdue |
payment.settled | Payment settled |
payment.reversed | Payment reversed |
penalty.issued | Penalty issued |
notice.served | Notice served |
Subscribe to a name, to a family (invoice.*) or to everything (*). A name that is not on
this list is refused when you register it, because a typo in a subscription is silence — and silence is the one
failure you cannot debug.
Working offline
A restaurant in a bad coverage area still serves dinner. A gate at a national park sells tickets where there is no signal at all. Neither can wait for us, and neither should try.
The sale is yours; the fiscal document is ours. Take the money, print the customer's copy, and treat lodging
the invoice as a separate, retryable job. The platform is built for this: an invoice carries
client_issued_at, the moment your till issued it, and that — not the moment we received it — is what
the reporting deadline is measured from.
- Write to a durable outbox first. Mint the Idempotency-Key at the point of sale and store it with the payload. If the device reboots, the key survives with the sale.
- Never block the guest. Print, hand over change, move on. The lodgement runs behind.
- Drain in order. One sale at a time, oldest first, retrying each with its own unchanged key until it is answered.
- Catch up in bulk. After a long outage send
POST /invoices/bulk: each item carries its ownreference, so a batch that is sent twice still issues each sale exactly once. - Reconcile at close of day. Anything still unanswered is a support case, not a guess. Send it once
more under its own key: either it is issued, or the
409hands you the uid of the document that already exists.
// The till writes the sale to its own durable queue FIRST, with the key it will
// use for ever, and only then tries the network. Nothing about a sale depends
// on the network being up at the moment of sale.
function recordSale(sale) {
const row = {
idempotency_key: crypto.randomUUID(), // minted ONCE, per sale
issued_at: isoLocal(new Date()), // when the guest actually paid
payload: buildInvoice(sale),
state: 'pending',
};
db.insert('outbox', row); // survives a reboot
printCustomerCopy(sale); // the guest never waits for us
flush(); // fire and forget
}
// The drain: in order, one sale at a time, for ever, until each is answered.
async function flush() {
for (const row of db.select('outbox', { state: 'pending' }, { order: 'id' })) {
const res = await post('/invoices', row.payload, {
'Idempotency-Key': row.idempotency_key, // the SAME key, every attempt
});
if (res.ok || res.status === 422 || res.status === 409) {
// 201/200 → issued (or replayed). 422 → we sent something invalid: park it
// for a human. 409 in_flight → wait for Retry-After and try again.
db.update('outbox', row.id, { state: res.status === 409 ? 'pending' : 'done',
answer: res.body });
continue;
}
break; // still offline; try again later
}
}curl -X POST https://tourismrevenue.co/api/v1/invoices/bulk \
-H "Authorization: Bearer sk_test_your_key_here" \
-H "X-Operator-Code: LAG-HTL-0042" \
-H "Idempotency-Key: till-3-batch-2026-09-04-19" \
-H "Content-Type: application/json" \
-d '{
"rows": [
{ "external_ref": "till-3-2026-09-04-0001",
"issued_at": "2026-09-04 19:41:00",
"kind": "B2C",
"lines": [ { "category": "FOOD_BEVERAGE", "description": "Dinner",
"quantity": 1, "unit_price": 1250000 } ] },
{ "external_ref": "till-3-2026-09-04-0002",
"issued_at": "2026-09-04 20:02:00",
"kind": "B2C",
"lines": [ { "category": "FOOD_BEVERAGE", "description": "Drinks",
"quantity": 3, "unit_price": 150000 } ] }
]
}'
# 202 Accepted, with a batch id. Every row is priced and checked BEFORE any of
# them is issued, and each external_ref is claimed once for ever — so re-sending
# the whole batch issues nothing a second time. Poll GET /batches/{id}.Quickstarts
The API is one shape; what differs is which lines you send and when you send them.
Point of sale
A bar or restaurant till. Issue at the moment of payment with
document_type: customer_receipt, one line per charge category, and print the QR from
verify_url.
Property management
A hotel folio. Issue on checkout, not on booking: the tax point is the supply. Send the whole folio as lines,
and put the folio number in external_ref so your night audit can find the document again.
Online travel agency
You sell somebody else's room. Split the line with domestic_amount and
commission_amount, mark money you merely collect for the host as pass_through, and
carry the listing in sellers_item_id.
Marketplace
Same as an OTA, plus you may hold keys for many hosts at once. One integrator key, one
X-Operator-Code per host, one active grant from each. A host that leaves revokes its own grant and
your calls for it stop that minute.
Ticketing and attractions
High volume, low value, often no signal. Post taps in batches to POST /tickets/bulk and let the
day close into its own day-sheet document; or, for a small gate, issue one receipt at close of day whose
lines carry the quantities.
Accounting and ERP
Read-only is a perfectly good integration: documents:read and invoices:read give you
what a business issued and what it was billed, and the remittance endpoints give you what it owes — without
your software ever issuing anything.
An OTA sale, in full
{
"kind": "B2C",
"external_ref": "booking-8821",
"buyer_reference": "listing-4471",
"customer": { "name": "Chinedu Balogun", "email": "guest@example.ng" },
"lines": [
{ "category": "SHORT_LET",
"description": "2 nights · 1-bed apartment (host: Sunrise Apartments Ltd)",
"quantity": 2, "unit_price": 3800000,
"domestic_amount": 7600000, // the host's share
"commission_amount": 760000, // yours
"sellers_item_id": "listing-4471" },
{ "category": "PASS_THROUGH", "description": "Cleaning fee collected for the host",
"quantity": 1, "unit_price": 500000, "pass_through": true }
]
}A day's gate takings
{
"kind": "B2C",
"document_type": "customer_receipt",
"external_ref": "gate-2-2026-09-04",
"lines": [
{ "category": "ATTRACTION_ENTRY", "description": "Adult entry",
"quantity": 214, "unit_price": 250000 },
{ "category": "ATTRACTION_ENTRY", "description": "Child entry",
"quantity": 96, "unit_price": 100000 }
],
"payment_status": "paid",
"issued_at": "2026-09-04 18:00:00"
}The charge categories a business may use depend on what it does — a hotel and a park do not sell the same things.
Read the list it is allowed at GET /charge-categories rather than hard-coding one.
Going live
Going live is a separate judgement from being approved, and deliberately not made by the same team. Before you ask, work through this — it is what we look at.
| Check | Why |
|---|---|
| Secrets are in configuration, never in source control or a browser | A leaked live key issues real fiscal documents in a real business's name. |
| Every POST sends an Idempotency-Key you minted before the first attempt | It is the difference between a retry and a second charge to a guest. |
| Timeouts and 5xx are retried with the SAME key, not re-sent as new sales | Nothing was issued; a new key would issue it twice. |
You branch on error.code, not on the message | Messages are improved; codes are a contract. |
Your webhook receiver verifies the signature and deduplicates on id | Anything else accepts forged events, or double-books redelivered ones. |
| You read the rate from the API rather than hard-coding it | The tariff is set by an approved version and it changes. |
You check X-Api-Environment somewhere visible to your support team | It is how you find out early that a deployment is on the wrong key. |
| You have issued, credited and cancelled in the sandbox | The unhappy paths are the ones that need a rehearsal. |
Then ask in the developer portal. Two different people at the platform have to agree — the team that onboarded you
cannot also be the team that waves you through — and until they do, live keys are refused
live_not_enabled while your sandbox keys carry on working.
Stuck on something that is not in here? Write to
support@tourismrevenue.co and quote a request_id: with
it we can see the exact call, its refusal and its timing without asking you to reproduce anything.