Skip to content
TourismRevenueTourism Development Contribution
Integrator API v1.0.0

Charge the levy from the software the business already runs

Most tourism levy will never be typed into this platform by a human. It arrives from a hotel's till, a property management system, a booking platform or a gate. This API is that door: issue a fiscal invoice, get it lodged with the Nigeria Revenue Service, and let the Tourism Development Contribution account for itself.

Already building? Sign in to the developer portal · OpenAPI 3.1 · Postman collection

Issue an invoice
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"
      }'

Start here

Five steps, and the first four cost nothing and touch no money.

  1. 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.
  2. Prove the key works. GET /ping needs no scope and no business — it is the one call you can make on day one.
  3. 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.
  4. Issue in the sandbox. Same endpoints, same validation, same document, same webhooks — and a sandbox document never accrues to the levy ledger.
  5. 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.
Prove the key works
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
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
<?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']);
JavaScript
// 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;
}
What comes back
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?".

HeaderRequiredWhat it is
AuthorizationAlwaysYour secret key: Bearer sk_live_… for real traffic, Bearer sk_test_… for the sandbox.
X-Operator-CodeIntegrator keysThe 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-KeyEvery POSTA unique key for this attempt, 8–190 printable characters, no spaces. A UUID is ideal.
Content-TypeRequests with a bodyapplication/json. Anything else is refused unsupported_media_type.
We cannot recover your key, and neither can anyone else. The platform stores a SHA-256 hash of it and a short display prefix — never the key. It is shown to you exactly once, when it is created. If you lose it, mint a new one; if you leak it, revoke it. A revoked key is never restored, because "restoring" a credential that has been seen by a stranger is just deciding to trust the stranger.

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.

ScopeWhat it permitsOn a new key by default
invoices:readRead the invoices this business has issued.Yes
invoices:writeIssue, credit and correct fiscal invoices in this business's name.Yes
credit_notes:readRead credit notes.No
credit_notes:writeIssue credit notes against this business's invoices.No
customers:readRead the buyer records this business holds.No
customers:writeCreate and correct buyer records.No
documents:readRead fiscal documents this business has received from its suppliers.Yes
operators:readRead this business's own registered particulars and its reference data.Yes
levy:readRead the levy accrued and what is owed.No
webhooks:manageRegister 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_allowed even 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.

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_…)
DocumentsIssued, validated and signed by a simulatorIssued and lodged with the Nigeria Revenue Service
The levyNever accrues. Not on the ledger, not in a remittance, not in any figure a state seesAccrues, remits and is reconciled
WebhooksDelivered to your test endpoints onlyDelivered to your live endpoints only
Available fromThe minute you registerWhen two people at the platform have approved you
Check the environment, do not assume it. Every response carries 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 from GET /tariff; it is set by an approved tariff version and it can change between one booking and the next.
  • A line marked pass_through is 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 sendYou get
A new keyThe work is done once. The answer is stored for 7 days.
The same key, the same bodyThe first answer, byte for byte, with Idempotent-Replay: true. Nothing is issued again.
The same key, a different body422 idempotency_mismatch. Never obeyed — nothing was issued. A new request needs a new key.
The same key while the first is still running409 request_in_flight with Retry-After. Wait, then retry unchanged. Do not send it as a new request.
No key at all400 idempotency_key_required. Nothing was issued.
The retry that costs nothing
# 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…"}}
A timeout is not a failure. If you never saw our answer, you do not know whether the invoice exists — and the only safe move is to send the same request again with the same key. It is also the cheapest: if the first attempt landed, the second one does no work and hands you the first answer. Answers stop being replayable after 7 days — but you are still covered after that, because 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.

The two shapes
{ "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.

CodeStatusWhat it meansWhat to do
unauthorized401No usable API key was presented. Send it as "Authorization: Bearer sk_…".Do not retry. Fix the credential.
key_revoked401This 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_expired401This API key has expired. Issue a new one in the developer portal.Do not retry. Mint a new key.
forbidden403This key is not authorised to do that.Do not retry.
insufficient_scope403This 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_linked403That 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_mismatch403This 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_allowed403This key may only be used from the addresses on its allowlist.Do not retry from this address.
integrator_suspended403This integrator account is suspended. Please contact platform support.Do not retry. Contact platform support.
live_not_enabled403This 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_only403That is not available to a sandbox key. A sandbox document never touches money.Do not retry with a sandbox key.
idempotency_key_required400Every 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_key400The 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_json400The request body is not valid JSON.Do not retry unchanged. Nothing was issued.
not_found404No such resource.Do not retry.
method_not_allowed405That method is not allowed on this endpoint.Do not retry.
conflict409That 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_flight409The 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_ready409That 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_large413The request body is larger than this endpoint accepts.Do not retry unchanged. Send fewer rows.
unsupported_media_type415Send the body as application/json.Retry with Content-Type: application/json.
invalid_request422Some fields could not be accepted. See error.fields.Do not retry unchanged. Fix the fields named in error.fields. Nothing was issued.
idempotency_mismatch422This 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.
unprocessable422The request was understood but could not be carried out.Do not retry unchanged.
rate_limited429Too many requests. Retry after the interval given.Wait for Retry-After, then retry with the same Idempotency-Key.
server_error500Something 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_error502The 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_unavailable503The service is briefly unavailable. Retry with the SAME Idempotency-Key.Nothing was issued. Retry with the SAME Idempotency-Key after the interval given.
The sentence that matters. Where nothing was issued, the message says so. 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.

LimitValueWhat happens
Calls per key120 per minute429 rate_limited with Retry-After. Your key may carry its own higher limit; X-RateLimit-Limit on every response is the truth.
Calls per address600 per minuteThe same, counted per source address across all keys.
Failed authentications10 per 15 minutes429 too_many_auth_failures. Fix the credential rather than retrying it.
Idempotent answers kept7 daysAfter that a replayed key is treated as a new request.
In-flight lock120 secondsA request that dies mid-flight unblocks its key after this, so a crash can never wedge a till for ever.
API keys per account10 live at onceRevoke one to mint another.
Webhook endpoints10 per accountPer 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

GET /api/v1/ping Check a key works

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.

no scope needed
GET /api/v1/whoami Describe the calling key

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.

no scope needed

Invoices

POST /api/v1/invoices Issue an invoice

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.

invoices:write Idempotency-Key required
POST /api/v1/invoices/preview Price a sale without issuing it

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.

invoices:write Idempotency-Key required
POST /api/v1/invoices/bulk Issue a batch of invoices

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.

invoices:write Idempotency-Key required
GET /api/v1/invoices List invoices

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.

invoices:read ?status ?kind ?type ?from ?to ?search ?page ?per_page
GET /api/v1/invoices/{uid} Retrieve one invoice

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.

invoices:read {uid}
GET /api/v1/invoices/{uid}/pdf Download the fiscal PDF

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.

invoices:read {uid}
GET /api/v1/invoices/{uid}/qr Get the fiscal QR

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.

invoices:read {uid} ?format
GET /api/v1/invoices/{uid}/einvoice The signed e-invoice, as sent

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.

invoices:read {uid}
POST /api/v1/invoices/{uid}/credit-note Credit an invoice

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.

invoices:write Idempotency-Key required {uid}
POST /api/v1/invoices/{uid}/debit-note Charge more against an invoice

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.

invoices:write Idempotency-Key required {uid}
POST /api/v1/invoices/{uid}/correction Reissue a corrected invoice

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.

invoices:write Idempotency-Key required {uid}
GET /api/v1/batches/{id} How a bulk request turned out

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.

invoices:read {id}

Documents

GET /api/v1/documents/received The inbox

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.

documents:read ?response ?from ?to ?page ?per_page
POST /api/v1/documents/{uid}/accept Accept a document

The buyer agrees the document is correct. The answer goes on to the tax service and stops the deadline clock.

documents:write not open yet Idempotency-Key required {uid}
POST /api/v1/documents/{uid}/reject Reject a document

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.

documents:write not open yet Idempotency-Key required {uid}

Operators

GET /api/v1/operators/me The business this key acts for

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.

operators:read
GET /api/v1/operators/me/einvoicing Can this business issue right now?

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

operators:read
POST /api/v1/operators/link Ask a business for consent

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.

operators:write not open yet Idempotency-Key required

Reference

GET /api/v1/tariff The levy schedule in force

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.

operators:read
GET /api/v1/charge-categories What this business may bill for

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.

operators:read
GET /api/v1/resources/{kind} A tax-service code list

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.

operators:read {kind}
GET /api/v1/fx The currency rule

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.

operators:read

Remittances

GET /api/v1/remittances The periods and the position

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.

levy:read ?status ?year ?page ?length
POST /api/v1/remittances/mandate Enrol a direct debit

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.

levy:write not open yet Idempotency-Key required
GET /api/v1/remittances/{ref} One period in full

What the period bills, what has landed against it, and the account it is being collected on — with the evidence that reconciles the two.

levy:read {ref}
POST /api/v1/remittances/{ref}/pay Get the payment instruction

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.

levy:write not open yet Idempotency-Key required {ref}

Tickets

POST /api/v1/tickets/bulk Post a batch of gate taps

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.

invoices:write Idempotency-Key required
GET /api/v1/tickets/daysheets Day-sheets and running totals

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.

invoices:read ?premises_id

Webhooks

GET /api/v1/webhooks Endpoints, events and the signature scheme

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.

webhooks:manage ?mode
POST /api/v1/webhooks Register a receiver

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.

webhooks:manage Idempotency-Key required
POST /api/v1/webhooks/test Send a signed test event

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.

webhooks:manage Idempotency-Key required
DELETE /api/v1/webhooks/{id} Stop sending to a receiver

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.

webhooks:manage {id}

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

What arrives at your endpoint
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
<?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']);
JavaScript
// 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
  });
Hash the raw body. The most common integration bug on any platform is verifying against a re-encoded body: a JSON round trip changes whitespace and key order, and the signature then never matches. Capture the bytes before your framework parses them.
Do not judge a delivery by its age. A redelivery replays the original bytes and the original signature — including the original 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.ping from the portal or POST /webhooks/test before a real invoice depends on your verification code.

The events

EventWhat it means
invoice.signedInvoice signed
invoice.confirmedInvoice confirmed by NRS
invoice.acceptedInvoice accepted by the buyer
invoice.rejectedInvoice rejected
invoice.failedInvoice failed to lodge
invoice.late_reportedInvoice reported late
remittance.issuedRemittance issued
remittance.paidRemittance paid
remittance.overdueRemittance overdue
payment.settledPayment settled
payment.reversedPayment reversed
penalty.issuedPenalty issued
notice.servedNotice 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.

  1. 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.
  2. Never block the guest. Print, hand over change, move on. The lodgement runs behind.
  3. Drain in order. One sale at a time, oldest first, retrying each with its own unchanged key until it is answered.
  4. Catch up in bulk. After a long outage send POST /invoices/bulk: each item carries its own reference, so a batch that is sent twice still issues each sale exactly once.
  5. 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 409 hands you the uid of the document that already exists.
The outbox
// 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
  }
}
Catching up in bulk
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}.
If the tax service itself is unavailable, the platform accepts your document, tells you so, and lodges it when the service returns; where a formal outage has been declared, the deadline clock is adjusted for everyone rather than for whoever complained. You do not need to build for that case beyond the retry you already have.

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

POST /invoices — a booking sold on behalf of a host
{
  "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

POST /invoices — one receipt for the day
{
  "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.

CheckWhy
Secrets are in configuration, never in source control or a browserA leaked live key issues real fiscal documents in a real business's name.
Every POST sends an Idempotency-Key you minted before the first attemptIt 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 salesNothing was issued; a new key would issue it twice.
You branch on error.code, not on the messageMessages are improved; codes are a contract.
Your webhook receiver verifies the signature and deduplicates on idAnything else accepts forged events, or double-books redelivered ones.
You read the rate from the API rather than hard-coding itThe tariff is set by an approved version and it changes.
You check X-Api-Environment somewhere visible to your support teamIt is how you find out early that a deployment is on the wrong key.
You have issued, credited and cancelled in the sandboxThe 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.