Integrate in Stored Mode
Submit an invoice for durable asynchronous validation and signing, poll its status, and transmit it by Duplo invoice ID.
Stored mode persists the invoice under your business in Duplo Dashboard before it returns a response. Duplo then validates, bills, and signs it asynchronously. Use this mode when you want a durable invoice record under your parent business that you can query for status.
How the sign URL behaves in Stored mode
POST https://<label>.invoice.tryduplo.com/api/v1/invoice/sign<label> is your approved domain label from Domain and API key.
In Stored mode, this URL only accepts the invoice and queues it for processing. It does not call NRS or debit your wallet during the HTTP request. It accepts the Stored invoice document, not the NRS document that Pass-through mode uses.
Resolve customer
Create or look up the parent-owned invoice customer and persist its customerId before submission.
Accept
Duplo binds the request to your parent business, approved domain, test/live state, approved mode, and represented business.
Process
Duplo validates the invoice against NRS and prepares the signing attempt.
Bill and sign
Duplo creates the signing debit immediately before it signs, then records the resulting NRS and billing status on the invoice.
Read and transmit
Poll by Duplo invoice ID, then transmit the invoice only after it reaches signed.
1. Create or resolve the customer
Customer create/list/lookup is part of the Stored invoice flow. These operations appear under Stored Invoices in the API reference: create, list, and lookup. PASSTHROUGH uses the customer party in the NRS document directly and does not need this step. The customer endpoints themselves currently have no invoice-handling-mode restriction.
The Stored invoice references an existing parent-owned invoice customer through customerId. Create the customer through your approved Gateway domain before intake, and persist the returned UUID. Customers created through Gateway belong to the authenticated parent business and can be reused across that parent's represented businesses in the same test/live state; they are not created as per-represented-business records.
When customerId is present, intake checks that the customer belongs to the same parent business and test/live state as the Gateway request. The customer must include the invoice module, and customer.metadata.invoice must contain postcode (legacy records using postalZone are also accepted). A failure in any of those intake checks is exposed by Gateway as 400 GW_REQUEST_VALIDATION_FAILED; correct the customer record or identifier instead of retrying the unchanged request.
Some legacy customer records can still carry a non-null represented-business binding. Such a record is accepted only when that existing binding matches the invoice's gatewaySubBusinessId. The Gateway customer API does not create or manage this legacy child scope; do not send gatewaySubBusinessId when creating a customer.
Intake currently accepts a request that omits customerId, but the accepted invoice is not NRS-ready and cannot reach signed. Gateway exposes no operation for attaching a customer to that accepted invoice later. Treat customerId as required in your own application, persist it before invoice creation, and reject an outbound request that does not have one. During asynchronous validation, NRS readiness also requires the customer to supply a name, TIN, email, address, city, two-letter country code, and postcode. Those later requirements are reported in firsRejectionReason if processing returns the invoice to created / null. The customer-creation API requires phone; send it in +-prefixed international format. Use the Gateway customer endpoints below with the same x-api-key as invoice requests.
Create the reusable invoice customer on your approved Gateway domain. Gateway authenticates the domain and API key, then forwards the request to Spend:
curl --request POST \
--url https://acme.invoice.tryduplo.com/customers \
--header 'x-api-key: pk_test_replace_with_your_key' \
--header 'Content-Type: application/json' \
--data '{
"firstName": "Example Buyer",
"lastName": "Limited",
"email": "ap@buyer.example",
"phone": "+2348098765432",
"tin": "87654321-0001",
"country": "NG",
"state": "FCT",
"city": "Abuja",
"address": "2 Buyer Avenue",
"postcode": "900001",
"module": "invoice"
}'The data.id in the Gateway's 201 response is the Stored request's customerId. You can resolve existing customers with GET https://acme.invoice.tryduplo.com/customers?module=invoice or GET https://acme.invoice.tryduplo.com/customers/{customerId}. Duplo derives the parent business and business state (test or live) from the authenticated API key; do not send gatewaySubBusinessId, a parent ID, or test/live state in the customer-create body. Gateway creates a parent-scoped customer, so the same customerId can be used for invoices belonging to any represented business under that parent in that state. The customer's geographic state, such as FCT, is ordinary invoice metadata and is unrelated to the test/live state. If you switch to a different parent business, or move between test and live, resolve or create a customer in that new context instead of reusing an ID from the old one.
Use these routes on the same approved host, with x-api-key on every call:
| Operation | Route | Result |
|---|---|---|
| Create | POST /customers | 201, with the customer in data |
| List or resolve by email | GET /customers?page=1&limit=50&email=ap%40buyer.example | 200, with customers in data.data and pagination in data.meta |
| Look up by ID | GET /customers/{customerId} | 200, with the customer in data |
Listing accepts page (1–1,000,000), limit (1–100), and an optional exact email filter. Defaults are page 1 and limit 50. The optional module must be invoice; creation also defaults to the invoice module when omitted. Unknown query fields and caller-supplied ownership fields are rejected. Creation requires firstName, lastName, email, phone, country, address, and postcode; include tin and city so the customer is ready for NRS validation. country is normalized to two uppercase letters. No x-sub-business-id is needed for these parent-scoped customer calls.
An existing invoice customer with the same email and TIN produces 409 GW_CUSTOMER_ALREADY_EXISTS. List by email, compare the returned customer's TIN, and reuse its id. This also resolves an uncertain create response before you submit again; customer creation does not provide invoice-style idempotency replay. An ID outside the authenticated parent or test/live state, or without the invoice module, returns 404 GW_CUSTOMER_NOT_FOUND. Gateway returns its standard Problem Details envelope for failures and does not relay internal Spend error text.
Customer creation does not allocate or return a represented-business identity. Obtain gatewaySubBusinessId from the completed onboarding response, or recover it with GET /sub-businesses or GET /sub-businesses/{gatewaySubBusinessId}. Send that value in the Gateway invoice request's x-sub-business-id header; do not add it to the customer request.
2. Submit the invoice
Send a stable x-idempotency-key on intake. It must be 8 to 128 characters long and use only letters, digits, dots, underscores, colons, and hyphens. See Design stable idempotency keys for naming patterns.
Keep the Gateway request ID
You may send x-request-id as optional correlation metadata for your own
system, but Gateway does not trust, echo, or use that value as its support
identifier. Gateway creates a new request ID for every call. Always capture
the response X-Request-ID; on a Gateway Problem Details response, it is the
same value as body requestId. Store that Gateway-generated value with the
invoice and use it when contacting Duplo Support.
curl --request POST \
--url https://acme.invoice.tryduplo.com/api/v1/invoice/sign \
--include \
--header 'content-type: application/json' \
--header 'x-api-key: pk_test_replace_with_your_key' \
--header 'x-sub-business-id: nrs-business-demo' \
--header 'x-idempotency-key: stored-INV000001-2A3A045D-20260818' \
--data @stored-invoice.jsonExpected acceptance:
HTTP/1.1 202 Accepted
content-type: application/json{
"statusCode": 202,
"message": "Invoice accepted for asynchronous signing",
"data": {
"accepted": true,
"invoiceId": "66666666-6666-4666-8666-666666666666"
}
}Store invoiceId before returning success to your own caller. A 202 means the invoice was durably accepted; it does not mean NRS has validated or signed it.
A processing delay does not cancel acceptance
If the asynchronous Spend processor is delayed after this 202, the
accepted invoice can remain pending or validated until processing resumes.
A processing delay never rejects an otherwise valid invoice. Keep polling
with backoff, and do not submit a replacement invoice.
Idempotency includes the routing snapshot
Duplo deduplicates intake on a fingerprint that binds your approved domain
configuration, its canonicalized domain label, the current test/live state,
the mode version, the represented business, and the recursively canonicalized
invoice body. Reordering JSON object keys does not change the fingerprint, so
an exact resend is safe. Reusing the same x-idempotency-key with any changed
value, or with a reordered array, returns 409. After a timeout, resend the
identical body with the same idempotency key.
3. Poll the invoice
List the Gateway invoices for your parent business in the current test/live state:
curl \
--url 'https://acme.invoice.tryduplo.com/invoices?page=1&limit=20&firsStatus=signed' \
--header 'x-api-key: pk_test_replace_with_your_key'page defaults to 1, limit defaults to 20 and cannot exceed 100, and firsStatus is optional. firsStatus is the only status filter. There is no status query parameter, so you cannot narrow the list by the Duplo status field and must filter that one yourself from the returned items. firsStatus is a legacy field name rather than a misspelling of firstStatus; in this Gateway it carries the NRS processing status. Its filter values are submitted, pending, validated, signed, approved, and rejected.
submitted and approved remain accepted enum/filter values for compatibility with historical or externally managed rows. The current Gateway Stored processor does not emit either one: its active sequence is pending to validated to signed, or a terminal null/rejected outcome. Do not infer a new Gateway lifecycle transition merely because the compatibility filter accepts those values.
{
"statusCode": 200,
"timestamp": "2026-08-18T00:00:00.000Z",
"message": "Invoices retrieved",
"data": {
"items": [
{
"id": "66666666-6666-4666-8666-666666666666",
"gatewaySubBusinessId": "nrs-business-demo",
"invoiceReferenceNumber": "INV000001-2A3A045D-20260818",
"status": "signed",
"firsStatus": "signed",
"transmissionStatus": "not_sent"
}
],
"meta": {
"page": 1,
"limit": 20,
"total": 1,
"count": 1,
"previousPage": false,
"nextPage": false,
"pageCount": 1,
"totalRecords": 1
}
}
}Read a single invoice for its authoritative current status:
curl \
--url https://acme.invoice.tryduplo.com/invoices/66666666-6666-4666-8666-666666666666 \
--header 'x-api-key: pk_test_replace_with_your_key'Duplo scopes these reads to your parent business server-side. Supply invoiceId as the canonical UUID version 4 returned by intake. An unknown invoice ID and another business's invoice ID both return 404.
Nullable invoice fields can be present with JSON null rather than being omitted. These include invoiceNumber, systemInvoiceNumber, billingReferences, customerId, payeeData, taxRepresentativeData, discountData, rejection and QR fields, notes, payment terms, and payment details. Check for both a missing property and null before using an optional value.
Status model
Every invoice carries both a Duplo status and an NRS-facing firsStatus.
status | firsStatus | Meaning |
|---|---|---|
submitted | pending | Accepted; the processor has not finished with it yet |
validated | validated | NRS validation checkpoint completed |
signed | signed | NRS signing completed and the signing charge succeeded |
created | null | Validation ended without a sign; inspect firsRejectionReason |
rejected | rejected | A terminal signing, billing, or reconciliation condition occurred; inspect firsRejectionReason |
sent | signed | NRS confirmed transmission of this signed Gateway Stored invoice; transmissionStatus is transmitted |
If recipient lookup reports that the recipient is offline, the transmit command returns HTTP 200 with transmissionStatus: "not_sent" and leaves both lifecycle fields at status: "signed" and firsStatus: "signed". It does not move the invoice to sent. Treat transmissionStatus as the authoritative delivery result and do not wait for sent after a not_sent response without first resolving the recipient condition.
Stored processing ends at one of three statuses: signed, created / null, or rejected / rejected. Stop polling when you reach one of them; an invoice reaches sent only through your own transmit call.
Do not assume a fixed processing delay. Poll with bounded exponential backoff: start around one second, double to a ceiling of about 30 seconds, add jitter, and enforce a total deadline. Retain the Gateway-generated response X-Request-ID, your idempotency key, the invoice ID, and the IRN for support. The current Gateway has no edit or resubmit command for a failed Stored invoice. Before you create a corrected replacement, confirm whether the original IRN was already signed.
4. Transmit after signing
The Stored processor stops after sign. Transmission is an explicit, idempotent command:
curl --request POST \
--url https://acme.invoice.tryduplo.com/invoices/66666666-6666-4666-8666-666666666666/transmit \
--header 'content-type: application/json' \
--header 'x-api-key: pk_test_replace_with_your_key' \
--header 'x-idempotency-key: transmit-66666666-0001' \
--data '{
"gatewaySubBusinessId": "nrs-business-demo"
}'The body carries gatewaySubBusinessId only to prove that the invoice belongs to that represented business. Duplo derives the IRN and the NRS business ID from the stored record.
If you stored a Duplo UUID returned by an older onboarding response, this body accepts it temporarily as a deprecated lookup alias. The response and subsequent invoice reads use the immutable Gateway identity. Resolve and store that value through GET /sub-businesses and use it for new commands. For a retry, keep the original body value and idempotency key stable as normal idempotency discipline; do not rely on the compatibility alias remaining available.
{
"statusCode": 200,
"message": "Invoice transmitted",
"data": {
"invoiceId": "66666666-6666-4666-8666-666666666666",
"transmissionStatus": "transmitted",
"emailedTo": []
}
}If the recipient business is not connected to the NRS network, the command can still return HTTP 200 with transmissionStatus: "not_sent" and a message such as recipient not on NRS network - try again later. Do not represent that as delivered in your product. A transmit timeout or a dependency 502 leaves the outcome unknown: the invoice may or may not have been delivered. Poll the invoice and contact support instead of blindly resending.
What not to call in Stored mode
Direct NRS validation, signed-IRN lookup, and transmission by IRN are available only in Pass-through mode. Calling them on a Stored domain returns 409. Use the invoice intake, read, and invoice-ID transmit routes on this page.
Read Billing and safe retries for async failure and reconciliation behavior.
How is this guide?
Last updated on