Send invoices to FIRS through Nvono
Nvono is a FIRS-accredited Access Point Provider. Post your invoice data to this API and Nvono validates it, signs it with FIRS, and transmits it on your behalf — returning the IRN, cryptographic stamp, and QR code, with live status delivered to your webhook.
There are two ways to integrate, depending on where invoices get signed:
- Nvono signs & transmits — you send raw invoice data; Nvono handles validation, signing, and transmission. See the guide →
- You sign, Nvono transmits — you already hold FIRS-signed IRNs and only need the Access Point to transmit them. See the guide →
Authentication
Every request is authenticated with an API key sent in the x-api-key header. Generate your key in the Nvono platform under Settings → API Keys. The full key is shown only once at creation, so store it securely.
GET /api/v1/documents HTTP/1.1 Host: api.nvono.com x-api-key: nvo_live_9f2c7b1a8e64d0f3c5a1b7e2
Keys carry scopes that limit what they can do. For the full send-and-transmit flow, your key needs: documents:write, documents:read, firs:sign, and firs:transmit.
An API key acts on your account. Never embed it in browser or mobile code — call this API from your server. Rotate a key immediately if exposed (Settings → API Keys → Revoke).
Requests & responses
Successful responses wrap the result in a consistent envelope:
{
"success": true,
"data": { /* the resource */ },
"meta": { "timestamp": "2026-07-09T09:14:36.818Z", "requestId": "1801a93b-…" }
}
Errors use the same shape with success: false. The error.code is stable and machine-readable; error.details lists field-level validation messages.
{
"success": false,
"error": {
"code": "BAD_REQUEST",
"message": "telephone must start with the character +",
"details": [ { "message": "..." } ]
},
"meta": { "timestamp": "…", "path": "/api/v1/documents", "requestId": "…" }
}
Quote the requestId when contacting support. Validation is strict — unknown fields are rejected with 400, so send only the documented fields.
Idempotency
Safely retry a create after a network timeout by sending an Idempotency-Key header — any unique string you generate per logical request (a UUID works well). If Nvono has already processed that key, it returns the original response instead of creating a second document.
POST /api/v1/documents x-api-key: nvo_live_… Idempotency-Key: a1f3c9e2-7b64-4d0f-9c5a-1b7e2c8d4f60
- Reuse the same key on a retry → you get the first response back, no duplicate.
- Same key with a different body →
422(the key is bound to its first payload). - Retry while the first request is still running →
409; wait and retry.
Keys are remembered for 24 hours and scoped to your account. Currently honoured on POST /documents.
The invoice lifecycle
An invoice moves through these stages. Nvono tracks the first four on firsStatus and the last two on transmissionStatus.
Guide: submit, sign & transmit
Use this when you want Nvono to do everything from raw invoice data.
Step 1 — Create the document
Post the invoice. See the field reference for every option.
curl -X POST https://api.nvono.com/api/v1/documents \ -H "x-api-key: nvo_live_…" \ -H "Content-Type: application/json" \ -d '{ "documentType": "invoice", "documentNumber": "INV-2026-00042", "documentDate": "2026-07-09", "transactionType": "B2B", "currency": "NGN", "firsEnabled": true, "counterparty": { "name": "Dean & Davis Ltd", "tin": "01281054-0001", "email": "accounts@deandavis.ng", "phone": "+2348021234567", "address": "20 Rumuibekwe Road", "city": "Port Harcourt", "stateCode": "RI", "countryCode": "NG" }, "lines": [{ "description": "Magnesium Oxide", "quantity": 147.163, "unitPrice": 53000, "unit": "KGM", "itemType": "goods", "hsCode": "2519.90", "taxes": [{ "taxTypeCode": "VAT-STD" }] }] }'
The response returns the new document with "documentStatus": "draft" and an id (UUID). Use that id in the next steps. documentStatus is the document's own lifecycle — draft → final (after finalize) → void — distinct from firsStatus (signing) and transmissionStatus (delivery). When listing, the query param that filters it is named status (see listing & filtering).
Phone → E.164: any phone must start with + (e.g. +2348021234567). Classification: goods lines need an hsCode (format 0000.00); service lines need itemType:"service" and a serviceCode. Currency: must be one Nvono has enabled for your account.
Taxes on a line
Each line needs its tax treatment. There are two ways to declare it.
1 — By tax type (recommended, used in Step 1 above). Pass a taxes array where each entry references a configured tax type by taxTypeCode (or taxTypeId). Nvono resolves the rate, whether it is inclusive or exclusive, whether it is withholding (WHT), and the FIRS tax category from that record — so you only send the reference. You can send more than one (e.g. VAT + WHT):
"lines": [{
"description": "Consulting",
"quantity": 1,
"unitPrice": 100000,
"itemType": "service",
"serviceCode": "83121500",
"taxes": [
{ "taxTypeCode": "VAT-STD" },
{ "taxTypeCode": "WHT-5" }
]
}]
Discover the tax types available to you (codes, rates, labels) with GET /firs/resources/tax-types.
2 — Simple (VAT only, tax-exclusive). If you don't use tax types, set taxCategory and taxRate instead. The unit price is treated as net and VAT is added on top — e.g. "taxCategory": "S", "taxRate": 7.5.
The stored tax type is authoritative. A rate, isInclusive or isDeductible you send alongside a referenced tax type is ignored — the configured record wins. An unknown taxTypeCode/taxTypeId is rejected with 400. Inclusive means the unit price already includes that tax (Nvono back-calculates the taxable base); otherwise tax is added on top. Withholding tax types are subtracted from the amount due, not added.
Send discountRate — a percentage of the unit price. Nvono folds it into the unit price (e.g. unitPrice 1000 with discountRate 10 → net 900), so the FIRS invoice shows a single net price. Totals (taxable base, VAT, line total) reflect the discount.
Step 2 — Finalize & sign
Finalizing with submitToFirs: true runs the full signing workflow automatically: validate IRN → validate invoice → sign → generate QR.
curl -X POST https://api.nvono.com/api/v1/documents/{id}/finalize \ -H "x-api-key: nvo_live_…" \ -H "Content-Type: application/json" \ -d '{ "submitToFirs": true }'
On success the document carries signed firsStatus, plus irn, csid (cryptographic stamp), and qrCode.
Step 3 — Transmit
Signing does not transmit. Send the signed invoice through the Access Point:
curl -X POST https://api.nvono.com/api/v1/firs/transmit/{id}/transmit \ -H "x-api-key: nvo_live_…"
The repeated transmit is intentional — the resource group is /firs/transmit and /{id}/transmit is the action on it.
transmissionStatus becomes transmitting, then transmitted and finally acknowledged as FIRS confirms. To transmit many at once, use POST /firs/transmit/bulk with { "documentIds": ["…"] }.
Step 4 — Track status
Either fetch the document, or (recommended) let Nvono call your webhook on every change.
curl https://api.nvono.com/api/v1/documents/{id} \ -H "x-api-key: nvo_live_…"
Guide: transmit an already-signed IRN
If your invoices are signed with FIRS elsewhere and you only need Nvono as the transmitting Access Point, skip creation and signing entirely. Submit the IRNs directly.
curl -X POST https://api.nvono.com/api/v1/firs/transmit/by-irn \ -H "x-api-key: nvo_live_…" \ -H "Content-Type: application/json" \ -d '{ "items": [{ "irn": "INV36N5D00002-08106EF2-20260708", "documentType": "invoice", "transactionType": "B2B", "documentNumber": "INV-36N5D-00002", "supplierName": "Aluminium Smelter Co.", "supplierTin": "01056765-0001", "buyerName": "Dean & Davis Ltd", "buyerTin": "01281054-0001" }] }'
Each IRN must already be signed in FIRS. Only irn is required per item; the rest help Nvono record the transmission. Then poll status for up to 100 IRNs at once:
curl "https://api.nvono.com/api/v1/firs/transmit/by-irn/status?irns=INV36N5D00002-08106EF2-20260708,INV…" \ -H "x-api-key: nvo_live_…"
The response returns a results array with each IRN's current transmission status.
Status webhooks
Rather than polling, register a URL and Nvono will POST to it on two kinds of event — when an invoice is signed or rejected (document.firs_status_changed) and when Access Point delivery progresses (transmission.status_changed). Configure it once:
curl -X PATCH https://api.nvono.com/api/v1/tenants/me/webhook \ -H "x-api-key: nvo_live_…" \ -H "Content-Type: application/json" \ -d '{ "webhookUrl": "https://yourapp.example/hooks/nvono", "webhookSecret": "whsec_your_random_secret", "webhookEnabled": true }'
Event: document.firs_status_changed
Fires when a document's signing state reaches validated, signed, rejected, or cancelled — so you can stop polling for the signing result.
{
"event": "document.firs_status_changed",
"documentId": "4d7fcac6-f8d3-4897-aa05-6b4392df2fe2",
"irn": "INV36N5D00002-08106EF2-20260708",
"firsStatus": "signed",
"timestamp": "2026-07-09T09:18:00.000Z",
"data": {
"documentNumber": "INV-2026-00042",
"previousStatus": "validated",
"newStatus": "signed",
"reason": null
}
}
Event: transmission.status_changed
Fires as the Access Point delivers a signed invoice (transmitting → transmitted → acknowledged, or failed).
{
"event": "transmission.status_changed",
"irn": "INV36N5D00002-08106EF2-20260708",
"documentId": "4d7fcac6-f8d3-4897-aa05-6b4392df2fe2",
"transmissionStatus": "acknowledged",
"timestamp": "2026-07-09T09:20:00.000Z",
"data": {
"documentNumber": "INV-2026-00042",
"previousStatus": "transmitted",
"newStatus": "acknowledged",
"reason": null
}
}
When a webhookSecret is set, each delivery includes an X-Webhook-Signature: sha256=<hex> header — an HMAC-SHA256 of the raw request body using your secret. Recompute it and compare before trusting the payload. Respond 2xx quickly; non-2xx responses are retried.
Invoice fields
Body for POST /documents. Unknown fields are rejected.
Top level
| Field | Type | Req | Notes |
|---|---|---|---|
| documentType | enum | yes | invoice · credit_note · debit_note · bill |
| documentDate | date | yes | YYYY-MM-DD |
| counterparty | object | yes | Customer (or vendor for a bill) — see below |
| lines | array | yes | One or more line items — see below |
| documentNumber | string | opt | Auto-generated if omitted |
| transactionType | enum | opt | B2B (default) · B2G · B2C |
| currency | string | opt | ISO 4217, default NGN; must be enabled for your account |
| dueDate | date | opt | YYYY-MM-DD |
| referenceDocumentNumber | string | opt | Original invoice for credit/debit notes |
| firsEnabled | boolean | opt | Enable FIRS processing for this document |
| notes | string | opt | Free text |
counterparty
| Field | Req | Notes |
|---|---|---|
| name | yes | Legal name |
| tin | opt | Format NNNNNNNN-NNNN |
| opt | Valid email | |
| phone | opt | E.164, must start with + |
| address · city | opt | Street and city |
| stateCode · lgaCode | opt | FIRS codes (see code lists) |
| countryCode | opt | ISO 3166-1 alpha-2, default NG |
lines[]
| Field | Type | Req | Notes |
|---|---|---|---|
| description | string | yes | Item description |
| quantity | number | yes | > 0 |
| unitPrice | number | yes | Per-unit, excl. tax |
| itemType | enum | opt | goods (default) · service |
| hsCode | string | goods | HS code, format 0000.00 |
| serviceCode | string | service | ISIC/service code |
| unit | string | opt | UN/ECE unit code, e.g. KGM, EA, LTR |
| taxCategory | enum | opt | S standard (default) · Z zero · E exempt · O out-of-scope |
| taxRate | number | opt | Percent, e.g. 7.5 |
| whtRate | number | opt | Withholding percent, e.g. 5 |
| discount · discountRate | number | opt | Line discount |
Listing & filtering
GET /documents is paginated and filterable. All parameters are optional query-string values; combine them freely.
| Param | Type | Notes |
|---|---|---|
| page | integer | 1-based page number. Default 1. |
| limit | integer | Items per page. Default 20. |
| documentType | enum | invoice · credit_note · debit_note · bill |
| status | enum | Filters the response's documentStatus field (same value): draft · final · void |
| firsStatus | string | Signing state, e.g. signed, rejected (see status values) |
| search | string | Matches document number or counterparty name |
| dateFrom | date | Inclusive lower bound, YYYY-MM-DD |
| dateTo | date | Inclusive upper bound, YYYY-MM-DD |
curl "https://api.nvono.com/api/v1/documents?documentType=invoice&firsStatus=signed&dateFrom=2026-07-01&dateTo=2026-07-31&page=1&limit=50" \ -H "x-api-key: nvo_live_…"
The response data is an array of documents and meta carries total, page, and limit for paging.
Recording payments
POST /documents/{id}/payment-status records a payment against a document and supports partial payments — call it multiple times as instalments arrive. Requires documents:write.
| Field | Type | Req | Notes |
|---|---|---|---|
| amountPaid | number | yes | Amount paid in this event (document currency) |
| paymentMeans | string | yes | NRS payment-means code — see below. Unknown values return 400 |
| paymentReference | string | opt | Bank/POS/transfer reference |
| notes | string | opt | Free text |
paymentMeans is an NRS payment-means code — the UN/CEFACT code NRS records on the invoice. Common values:
10 · Cash
20 · Cheque
30 · Credit Transfer
48 · Bank Card
42 · ACH Credit
97 · Other
ZZZ · Mutually Defined.
Fetch the full, current catalogue from GET /firs/resources/firs-payment-means.
An unrecognised code is rejected with 400 (it is not silently coerced to “Other”), and no payment is recorded.
curl -X POST https://api.nvono.com/api/v1/documents/{id}/payment-status \ -H "x-api-key: nvo_live_…" \ -H "Content-Type: application/json" \ -d '{ "amountPaid": 250000, "paymentMeans": "30", "paymentReference": "FT26190XYZ", "notes": "First instalment" }'
Nvono updates paymentStatus to PARTIAL or PAID based on the cumulative total versus the invoice amount, and reports the paid amount to FIRS. Read the ledger with GET /documents/{id}/payments.
Endpoint reference
All paths are relative to https://api.nvono.com/api/v1.
Documents
{submitToFirs:true} validates & signs automatically.application/pdf).{"reason":"…"}.FIRS signing — granular steps
The signing flow (validate IRN → validate invoice → sign → QR) runs automatically via finalize / firs-workflow. These endpoints expose the individual steps if you need to run them separately — the two validate calls are what the firs:validate scope grants.
firsStatus to validated/rejected).Transmission — Access Point
{documentIds:[…]} — transmit many; skips unsigned/B2C/already-sent.?irns= comma-separated (max 100) — poll status.Account
Status values
A document carries three independent status fields. Don't conflate them: documentStatus is the record's own lifecycle, firsStatus is signing, transmissionStatus is Access Point delivery.
documentStatus — record lifecycle (filter param: status)
draft final void
firsStatus — signing progress
draft pending processing validated signed rejected
transmissionStatus — Access Point delivery
not_transmitted transmitting transmitted acknowledged failed
paymentStatus
PENDING PARTIAL PAID REJECTED
Recording a payment via POST /documents/{id}/payment-status yields PENDING, PARTIAL, or PAID (based on the cumulative amount). REJECTED is an NRS-side outcome — it appears when NRS rejects a reported payment — not something the payment-status call produces directly.
Key fields on a signed document: irn (invoice reference), csid (cryptographic stamp ID), and qrCode.
Code lists & lookups
Fetch FIRS reference data to populate your invoices. All of these accept any valid API key — no special scope required (except currencies). Paths are relative to https://api.nvono.com/api/v1.
?countryCode=NG. Provides stateCode.?stateCode=LA. Provides lgaCode.?search=&page=1&limit=100.?search=&page=1&limit=100.unit (e.g. EA, KGM, MTR, C62).?taxTypeCode=VAT|WHT|EXCISE|OTHER.paymentMeans.Rate limits
Requests are rate-limited per API key. Exceeding a limit returns 429 with error.code: RATE_LIMIT_EXCEEDED — back off and retry. A general default applies to most endpoints; a few tiers are tighter or looser by nature of the operation.
| Tier | Limit | Applies to |
|---|---|---|
| Default | 60 / min | Most endpoints, incl. document create/read |
| Read-heavy | 120 / min | Listing and lookup endpoints |
| FIRS calls | 10 / min | Sign / transmit operations that hit FIRS |
| Bulk | 5 / min | Bulk transmit and batch operations |
Windows are fixed 60-second buckets. Bulk transmission additionally paces its internal FIRS calls (default 10/min per batch) so a single bulk request will not breach the FIRS tier.
On a 429, wait to the next minute boundary before retrying, and prefer the bulk/by-IRN endpoints over tight loops of single calls. Standard RateLimit-* response headers are on the roadmap — until then, treat the limits above as the contract.
Error codes
| HTTP | error.code | Meaning |
|---|---|---|
| 400 | BAD_REQUEST | Malformed body or a FIRS validation failure (message says which field) |
| 401 | UNAUTHORIZED | Missing/invalid API key, or bad key format (must start nvo_) |
| 403 | FORBIDDEN | Key lacks the required scope |
| 404 | NOT_FOUND | No such document/resource for your account |
| 409 | CONFLICT | Invalid state transition (e.g. transmitting an unsigned invoice) |
| 422 | VALIDATION_ERROR | Field validation failed — see details |
| 429 | RATE_LIMIT_EXCEEDED | Too many requests — back off and retry |
| 500 | INTERNAL_ERROR | Unexpected error — retry; quote requestId to support |
Scopes
Assign your key the least it needs. For the send-and-transmit flow: documents:write, documents:read, firs:sign, firs:transmit.
| Scope | Grants |
|---|---|
| documents:read | List/get documents, PDFs, payments, currencies |
| documents:write | Create, finalize, update, void, record payments |
| documents:delete | Delete drafts |
| firs:validate | Validate IRN / invoice with FIRS |
| firs:sign | Sign invoices, generate QR, run the FIRS workflow |
| firs:transmit | All Access Point transmission endpoints |
| * | Full access |
Nvono API — FIRS Access Point Integration Guide. Confirm your assigned base URL, sandbox host, and enabled currencies with your Nvono contact before going live. Interactive API explorer available on request.