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. The customer must belong to the same parent business and the same test/live state as the Gateway request, and must include the invoice module.
Intake accepts a request that omits customerId, but the resulting invoice is not NRS-ready and can never reach signed. A referenced customer that is missing either its invoice metadata or its postcode is rejected at intake. NRS processing also requires that customer to supply a name, TIN, email, address, city, two-letter country code, and postcode. The phone number is optional; if you send one, use the +-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 add those routing values to the request body. 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.
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.
curl --request POST \
--url https://acme.invoice.tryduplo.com/api/v1/invoice/sign \
--header 'content-type: application/json' \
--header 'x-api-key: pk_test_replace_with_your_key' \
--header 'x-sub-business-id: 44444444-4444-4444-8444-444444444444' \
--header 'x-idempotency-key: stored-INV000001-2A3A045D-20260818' \
--header 'x-request-id: req-018f0f56-f5f5-7c98-a21a-4a03c11a85b1' \
--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.
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.
{
"statusCode": 200,
"timestamp": "2026-08-18T00:00:00.000Z",
"message": "Invoices retrieved",
"data": {
"items": [
{
"id": "66666666-6666-4666-8666-666666666666",
"gatewaySubBusinessId": "44444444-4444-4444-8444-444444444444",
"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. An unknown invoice ID and another business's invoice ID both return 404.
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 | Signed invoice was delivered; inspect transmissionStatus |
firsStatus also accepts approved as a filter value, which this table does not yet map to a Duplo status.
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 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": "44444444-4444-4444-8444-444444444444"
}'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.
{
"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