Sign and Transmit Invoices in Stored Mode
Step-by-step guide to signing your first invoice in Stored mode, from checking your connection and onboarding a represented business to submitting, polling, and transmitting the invoice.
This guide walks you through signing and transmitting your first invoice in test state on a domain approved in STORED mode. In Stored mode, Duplo stores the invoice you submit, then validates, bills, and signs it in the background.
Note
If your domain is approved in PASSTHROUGH mode, follow the guide on
signing and transmitting invoices in Pass-through
mode instead.
Prerequisites
To follow this guide, you need the following:
- A top-level business in Duplo Dashboard that is active and activated for e-invoicing. If you don't have one, follow this guide to set up your business in Duplo Dashboard.
- A test API key. You can generate your API key in Duplo Dashboard under Settings > Developer API while your business is in test state.
- A Gateway domain approved in
STOREDmode. See our guide on submitting a domain request. - Enough test funds in your test-state NGN wallet for one signing fee. Signing is billed in test, but it draws on test funds. See Billing and safe retries.
- A business to invoice for that is active in NRS and has selected Duplo as its Access Point Provider, plus its NRS portal email and password. See the represented-business prerequisites.
The examples below use acme as the domain label and pk_test_replace_with_your_key as the API key. Replace both with your own.
If a request fails
The Gateway returns an error with a code and a retry.strategy. Decide
what to do from those two fields, never from the English text, and keep the
requestId for Duplo Support. See Handle Gateway error
responses for what each strategy means.
Checking your connection
Start by confirming that your API key, your approved domain, and the Gateway's NRS connection work together. If a later step fails, you then know the problem is in that request and not in your setup.
To check the connection, make a GET request to the Get NRS Sender Health endpoint. This call is unbilled.
For example:
curl \
--url https://acme.invoice.tryduplo.com/api/v1/invoice/transmit/self-health-check \
--header 'x-api-key: pk_test_replace_with_your_key'When you send the request, you will receive a response similar to the one below:
{
"code": 200,
"data": {
"ok": true
}
}If you receive a 401 instead, the API key is wrong for this domain or state. See Handle Gateway error responses for other failures.
Onboarding a represented business
A represented business is the business whose NRS identity appears on the invoice. The Gateway only accepts invoices for a business it has verified with NRS, so you onboard each business once before its first invoice.
To onboard a business, make a POST request to the Onboard Represented Business endpoint with the business's NRS portal credentials. Onboarding is unbilled.
The request needs the following headers:
x-api-key(required): your test API key.x-idempotency-key(required): a key that identifies this onboarding, so a retry after a dropped connection returns the original result instead of starting again. Duplo doesn't issue it; you create it yourself. See designing stable idempotency keys to learn how to create one.
For example:
curl --request POST \
--url https://acme.invoice.tryduplo.com/sub-businesses \
--header 'content-type: application/json' \
--header 'x-api-key: pk_test_replace_with_your_key' \
--header 'x-idempotency-key: onboard-acme-retail-0001' \
--data '{
"nrsCredentials": {
"email": "nrs-owner@acme.example",
"password": "replace-at-runtime"
}
}'This request works when the NRS entity has exactly one active business. If it has more than one, add a tin field to select one, as described in Send the onboarding request.
On a successful request, you will receive a response similar to the one below:
{
"onboardingIntentId": "33333333-3333-4333-8333-333333333333",
"gatewaySubBusinessId": "nrs-business-demo",
"firsBusinessId": "nrs-business-demo",
"nrsIdentity": {
"entityId": "entity-demo",
"entityReference": "entity-reference-demo",
"nrsBusinessId": "nrs-business-demo",
"businessName": "Acme Retail Limited",
"tin": "12345678-0001",
"irnTemplate": "{{invoice_id(e.g:INV00XXX)}}-2A3A045D-{{YYYYMMDD(e.g:20261005)}}"
},
"status": "completed",
"replayed": false,
"cryptoVersion": 0
}Store the following values from the response, because the rest of this guide uses them:
gatewaySubBusinessId: the value you send in thex-sub-business-idheader when you submit the invoice, and in the body when you transmit it.nrsIdentity.businessName: the value you send as the invoice'ssupplierData.name.nrsIdentity.tin: the value you send as the invoice'ssupplierData.tin.nrsIdentity.irnTemplate: the template you build the invoice'sinvoiceReferenceNumberfrom.
Creating the buyer as a customer
A Stored invoice names its buyer by customerId instead of carrying the buyer's details. Duplo uses the customer record to fill in the buyer's details for NRS, so you create the buyer once and reuse its ID on later invoices.
To create a customer, make a POST request to the Create Invoice Customer endpoint with the buyer's details. Customers belong to your top-level business, so this request carries no x-sub-business-id.
For example:
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"
}'On a successful request, you will receive a 201 response with the customer in data. Store data.id, because it is the invoice's customerId. Include the buyer's name, TIN, email, address, city, country, and postcode, because NRS validation later needs all of them.
If a customer with the same email and TIN already exists, you will receive 409 GW_CUSTOMER_ALREADY_EXISTS. List customers with GET /customers?email=ap%40buyer.example and reuse the matching id. See Create or resolve the customer for details.
Building the invoice
Every invoice needs an Invoice Reference Number (IRN) built from the represented business's irnTemplate. NRS and the Gateway use the IRN to identify the invoice through signing, transmission, and support. To build one, replace {{invoice_id(...)}} with your own invoice reference and {{YYYYMMDD(...)}} with the issue date. The text in parentheses only shows the expected format:
{{invoice_id(e.g:INV00XXX)}}-2A3A045D-{{YYYYMMDD(e.g:20261005)}} → INV000001-2A3A045D-20260818Then save the invoice below as stored-invoice.json. It is the service invoice from Build invoice payloads. Set customerId to the ID you stored when you created the customer:
{
"invoiceNumber": "INV-0001",
"invoiceReferenceNumber": "INV000001-2A3A045D-20260818",
"invoiceType": "381",
"issueDate": "2026-08-18",
"documentCurrency": "NGN",
"taxCurrency": "NGN",
"paymentStatus": "pending",
"supplierData": {
"name": "Acme Retail Limited",
"email": "invoice@acme.example",
"phoneNumber": "+2348012345678",
"tin": "12345678-0001",
"country": "NG",
"state": "Lagos",
"city": "Lagos",
"address": "1 Integration Road",
"postcode": "100001"
},
"customerId": "55555555-5555-4555-8555-555555555555",
"items": [
{
"description": "Integration service",
"unit": "Hour",
"quantity": 1,
"unitPrice": 1000,
"taxes": [
{
"name": "STANDARD_VAT",
"rate": 7.5
}
],
"serviceCategory": "Computer programming activities",
"isicCode": "6201"
}
],
"notes": "Payment due in 30 days"
}Replace the supplier details with your represented business's own. supplierData.name and supplierData.tin must match the nrsIdentity from the onboarding response, because the Gateway rejects an invoice whose supplier doesn't match the business in x-sub-business-id. Duplo calculates the totals from the items, so you don't send them. The resource codes, such as STANDARD_VAT and 6201, are illustrative. In production, fetch them from the unbilled resource endpoints.
Submitting the invoice
Submitting hands the invoice to Duplo, which stores it and then validates, bills, and signs it in the background. Your request returns as soon as the invoice is stored, so it doesn't wait on NRS.
To submit the invoice, make a POST request to the Sign or Store NRS Invoice endpoint with stored-invoice.json. Along with x-api-key and x-sub-business-id, send a new x-idempotency-key you create for this invoice, such as stored- plus the IRN, so a resent request returns the original invoice instead of creating a second one.
Signing is billed after acceptance
Duplo debits the signing fee when it signs the invoice, not when the submit
request returns 202. Store the idempotency key with the invoice before you
submit. If the request times out, resend the identical body with the same
key. Never submit a replacement for an invoice that is still processing,
because each accepted invoice can be signed and billed. See Billing and safe
retries.
For example:
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.jsonOn a successful request, you will receive a 202 Accepted response similar to the one below:
{
"statusCode": 202,
"message": "Invoice accepted for asynchronous signing",
"data": {
"accepted": true,
"invoiceId": "66666666-6666-4666-8666-666666666666"
}
}Store invoiceId, because you use it to read and transmit the invoice. A 202 means Duplo accepted the invoice, not that NRS has validated or signed it.
Polling until processing ends
Because Duplo processes the invoice in the background, the only way to learn the result is to read the invoice. Keep reading it until status reaches one of these end states:
signed: NRS signed the invoice and the signing charge succeeded. You can now transmit it.created, withfirsStatusnull: validation failed and the invoice wasn't signed. ReadfirsRejectionReasonfor the cause.rejected: signing or billing failed. ReadfirsRejectionReasonfor the cause.
To read the invoice, make a GET request to the Get Stored Invoice endpoint with the invoiceId. Processing time varies, so wait longer between each read rather than polling at a fixed interval.
For example:
curl \
--url https://acme.invoice.tryduplo.com/invoices/66666666-6666-4666-8666-666666666666 \
--header 'x-api-key: pk_test_replace_with_your_key'When the invoice is signed, you will receive a response containing fields similar to the ones below:
{
"statusCode": 200,
"data": {
"id": "66666666-6666-4666-8666-666666666666",
"gatewaySubBusinessId": "nrs-business-demo",
"invoiceReferenceNumber": "INV000001-2A3A045D-20260818",
"status": "signed",
"firsStatus": "signed",
"firsRejectionReason": null,
"transmissionStatus": "not_sent"
}
}If processing ends at created or rejected, don't submit a corrected copy until you know whether the original IRN was signed. See the Stored status model.
Transmitting the signed invoice
Duplo stops after signing, so the invoice doesn't reach the buyer until you transmit it. Transmission sends the signed invoice to the buyer through NRS.
To transmit the invoice, make a POST request to the Transmit Stored Invoice endpoint with the invoiceId in the path. The body carries gatewaySubBusinessId to prove that the invoice belongs to that represented business. Transmission is unbilled.
For example:
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"
}'On a successful request, you will receive a response similar to the one below:
{
"statusCode": 200,
"message": "Invoice transmitted",
"data": {
"invoiceId": "66666666-6666-4666-8666-666666666666",
"transmissionStatus": "transmitted",
"emailedTo": []
}
}Note
Check transmissionStatus, not the HTTP status. If the buyer isn't connected
to the NRS network, the request still returns 200, but with
transmissionStatus: "not_sent", which means the invoice wasn't delivered.
A timeout or 502 leaves the outcome unknown, so read the invoice and
contact Duplo Support instead of transmitting again.
Related guides
How is this guide?
Last updated on
Sign and Transmit Invoices in Pass-Through Mode
Step-by-step guide to signing your first invoice in Pass-through mode, from checking your connection and onboarding a represented business to validating, signing, and transmitting the invoice.
Build Invoice Payloads
Prepare identity-bound Pass-through and Stored invoice documents with current NRS resource values.