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.
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
The Stored invoice references an existing parent-owned invoice customer through customerId. Create the customer with the Duplo Dashboard API before intake, and persist the returned UUID. Customers created by the current Dashboard API 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 current Dashboard 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 current Dashboard customer-creation API requires phone; send it in +-prefixed international format. The Gateway does not expose a customer-creation endpoint on your custom domain, so use the Duplo Dashboard API below.
From an authenticated Duplo Dashboard API client, create the reusable invoice customer with a Dashboard bearer token. This is a different credential from the Gateway x-api-key used everywhere else on this page:
curl --request POST \
--url https://dashboard.tryduplo.com/spend/customers \
--header 'Authorization: Bearer <duplo-dashboard-access-token>' \
--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 Duplo Dashboard API's 201 response is the Stored request's customerId. You can resolve existing customers with GET https://dashboard.tryduplo.com/spend/customers?module=invoice or GET https://dashboard.tryduplo.com/spend/customers/{customerId}. Duplo derives the parent business and business state (test or live) from the authenticated session; do not send gatewaySubBusinessId, a parent ID, or test/live state in the customer-create body. The current API 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.
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 Dashboard 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 processor outage does not cancel acceptance
If the asynchronous Spend processor or administrator dependency becomes
unavailable after this 202, the accepted invoice can remain pending or
validated until processing resumes. Infrastructure failures use dependency
backoff and must not consume the invoice's business-rejection retry budget or
terminally reject an otherwise valid invoice. Continue bounded read-only
polling; do not submit a replacement invoice. Spend owns processor-heartbeat,
queue-age, and post-acceptance failure alerts.
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?