Managing Issued Invoices
Read invoices back, filter them, edit before signing, record payment, and handle invoices sent to you.
Issuing an E-Invoice takes one invoice from nothing to delivered. This guide covers everything after that: reading invoices back, correcting them while they are still editable, recording payment, and acting on invoices other businesses send you.
States of an E-Invoice
There are four fields that tell you the state of an invoice and each of them answers a different question. The table below provides an overview.
| Field | Question it answers | Values |
|---|---|---|
status | Where is this invoice in Duplo? | draft, created, validated, submitted, signed, approved, rejected, cancelled, pending_feedback, accepted, disputed, sent, paid |
firsStatus | What does NRS think? | pending, submitted, validated, signed, approved, rejected. Null until the invoice is submitted |
transmissionStatus | Has the signed invoice been delivered? | not_sent, transmitted, rejected |
paymentStatus | Have you been paid? | pending, paid, partially_paid, overdue |
A signed invoice that you have not transmitted is status: signed, firsStatus: signed, transmissionStatus: not_sent, paymentStatus: pending. All four move independently.
Two more fields matter on invoices sent to you: direction separates invoices you issued (outbound) from invoices you received (inbound), and incomingAction records what you did about an inbound one.
Reading one invoice
The Get invoice endpoint returns the full record for one IRN.
curl --request GET \
--url https://dashboard.tryduplo.com/api/e-invoicing/invoices/DUP00000001-2A3A045D-20240123 \
--header 'Authorization: Bearer <your-api-key>'Alongside the invoice document and its calculated totals, the response carries an auditLog: a most-recent-first timeline of everything that has happened to the invoice.
{
"invoiceReferenceNumber": "DUP00000001-2A3A045D-20240123",
"status": "signed",
"firsStatus": "signed",
"transmissionStatus": "not_sent",
"paymentStatus": "pending",
"direction": "outbound",
"totalAmount": 435375,
"amountDue": 435375,
"qrCodeImageUrl": "https://bucket.s3.region.amazonaws.com/business-1/invoice-qr-codes/key.png",
"auditLog": [
{
"action": "INVOICE_SIGNED",
"createdAt": "2024-01-23T10:38:44Z"
},
{
"action": "INVOICE_CREATED",
"createdAt": "2024-01-23T10:20:11Z"
}
]
}The audit log is populated only on this endpoint, not in list results. It is the first place to look when a submit did not do what you expected.
Listing and filtering
The List invoices endpoint returns one page of your business's invoices, newest first by default.
curl --request GET \
--url 'https://dashboard.tryduplo.com/api/e-invoicing/invoices?page=1&limit=20&status=signed&direction=outbound' \
--header 'Authorization: Bearer <your-api-key>'Every filter is optional. Send none and you get the first page of everything.
| Parameter | Accepts | Notes |
|---|---|---|
page | A number, for example 1 | Which page to return |
limit | A number, for example 20 | How many invoices per page |
status | Any value from the status list above | Comma-separate several, as in status=draft,validated |
firsStatus | Any value from the firsStatus list above | Useful for finding invoices still pending at NRS |
transmissionStatus | not_sent, transmitted, rejected | not_sent finds signed invoices you have not delivered |
paymentStatus | pending, paid, partially_paid, overdue | What you have recorded, not what NRS holds |
invoiceType | 380, 381, 384, 385, 388, 389 | 381 is a commercial invoice, 380 and 384 are notes |
direction | outbound, inbound | Invoices you issued, or invoices sent to you |
incomingAction | pending, acknowledged, rejected | Only meaningful with direction=inbound |
sort | A field name, -field for descending, as in -createdAt | Comma-separate several |
The response carries a meta object next to data for paging:
{
"total": 137,
"page": 1,
"limit": 20,
"count": 20,
"previousPage": null,
"nextPage": 2,
"pageCount": 7,
"totalRecords": 137
}Two filters answer the questions that come up most often. status=signed&transmissionStatus=not_sent finds invoices that are valid but still sitting with you undelivered. firsStatus=pending finds invoices a handed-off submit is still working through.
Editing before you sign
An invoice is editable while it is draft or created, and locked from validation onwards. The Update invoice endpoint takes a partial payload, so send only the fields you are changing.
curl --request POST \
--url https://dashboard.tryduplo.com/api/e-invoicing/invoices/DUP00000001-2A3A045D-20240123/update \
--header 'Authorization: Bearer <your-api-key>' \
--header 'Content-Type: application/json' \
--data '{
"invoiceNumber": "INV-2024-004",
"paymentTerms": "Net 30 days"
}'On a successful request, the updated invoice comes back in full, with only the fields you sent changed:
{
"invoiceReferenceNumber": "DUP00000001-2A3A045D-20240123",
"invoiceNumber": "INV-2024-004",
"paymentTerms": "Net 30 days",
"status": "created",
"firsStatus": null,
"totalAmount": 322500,
"amountDue": 322500,
"updatedAt": "2024-02-20T09:02:18.640Z"
}Update accepts the same shapes the create call does: items, discount, supplierData, customerId or buyerData, payeeData, taxRepresentativeData, paymentDetails, invoiceNumber, invoiceType, issueDate, taxPointDate, documentCurrency, taxCurrency, notes, and paymentTerms. The field rules from creating the invoice apply unchanged, and totals are recalculated from whatever you send.
Sending items replaces the whole array. There is no per-line patch, so send every line you want on the finished invoice, not just the one you changed.
saveAsDraft toggles between the two editable states. Setting it to true moves the invoice to draft and false moves it to created, and it only applies while the invoice is still pre-validation.
Changing invoiceType to 380 or 384 requires billingReferences pointing at the original invoice, the same as it does at creation.
An empty body is rejected. With no fields to apply the call returns 400, and so does any update to an invoice past created.
Correcting a signed invoice
Once an invoice is signed it cannot be edited, because NRS holds the signed
document. To correct one, raise a credit note (380) or debit note (384)
against it, carrying a billingReferences entry with the original IRN and
issue date.
Duplicating an invoice
Recurring invoices differ only in dates and amounts, so rather than rebuilding the payload, copy the last one. The duplicate endpoint reserves a fresh IRN and copies the document into a new draft invoice, leaving the original untouched.
curl --request POST \
--url https://dashboard.tryduplo.com/api/e-invoicing/invoices/DUP00000001-2A3A045D-20240123/duplicate \
--header 'Authorization: Bearer <your-api-key>'On a successful request, you should receive a response similar to the one below, abbreviated to the fields that distinguish the copy from its source:
{
"id": "4d2b8f1e-90a7-4c33-8f21-6b0d7e5a1c94",
"invoiceReferenceNumber": "DUP00000002-7C4F912B-20240220",
"systemInvoiceNumber": "DUP00000002",
"status": "draft",
"firsStatus": null,
"transmissionStatus": "not_sent",
"issueDate": "2024-01-23",
"totalAmount": 322500,
"amountDue": 322500
}The response is the new invoice, with its own invoiceReferenceNumber and systemInvoiceNumber. Any invoice can be the source, including a signed one, and the source is never modified.
What the copy carries over is the whole document: line items, customer, supplier data, discounts, payment details, notes, and terms. What it deliberately does not carry is the invoice's history. The new invoice starts at status: draft with firsStatus null, no signature, no QR code, and transmissionStatus: not_sent, so it is fully editable and NRS has never seen it.
Check the issue date before you submit a copy
issueDate is copied verbatim, so a duplicate of last month's invoice carries
last month's date. Validation will not catch this: past dates are perfectly
legal, and only future dates are rejected. The result is a signed,
irreversible invoice dated to the wrong period, correctable only by a credit
note. Set issueDate, and taxPointDate if you use it, on the update call
before you submit.
That makes the recurring-billing loop four calls: duplicate last cycle's IRN, update the dates and any changed quantities, submit, transmit. Store the latest IRN per customer in your own system and you never rebuild the payload. Duplicating costs nothing, because only signing is billable, so a duplicate that you abandon as a draft is free.
Reach for duplicate when the original is locked. If the invoice you want to change is still draft or created, update it in place instead rather than accumulating abandoned copies.
Resubmitting a rejected invoice
An invoice NRS rejected is terminal under its own IRN. The resubmit endpoint copies it to a new IRN and sends it through validate and sign again. Only an invoice with status: rejected is accepted, and anything else returns 400.
curl --request POST \
--url https://dashboard.tryduplo.com/api/e-invoicing/invoices/DUP00000001-2A3A045D-20240123/resubmit \
--header 'Authorization: Bearer <your-api-key>'On a successful request, you should receive the new invoice, carrying the IRN you now need to track:
{
"id": "7f5c2a9d-31b8-4e60-9d47-8c1a3f2e5b06",
"invoiceReferenceNumber": "DUP00000003-B18E44A0-20240220",
"systemInvoiceNumber": "DUP00000003",
"status": "signed",
"firsStatus": "signed",
"transmissionStatus": "not_sent"
}Before you call it, read firsRejectionReason on the rejected invoice, and its auditLog for the fuller story. That reason decides which of two paths you are on, because resubmit takes no request body and therefore sends the same document that was just rejected.
If the document was fine and the rejection was environmental, for instance NRS was unavailable or your credentials were momentarily wrong, resubmit is the right call and the same document should pass this time.
If NRS objected to the content, for instance a customer TIN it does not recognise or a tax figure it disagrees with, resubmit alone cannot help you, because it will send the identical document and collect the identical rejection. A rejected invoice is also past created, so the update call refuses it. The path there is to duplicate the invoice, update the copy, then submit it.
Resubmit signs, so it bills. It draws on your wallet or bundle exactly as submit does, and the quota refunded when the original was rejected is spent again here.
Resubmit also takes no idempotency key, unlike submit and sign, so a call that times out cannot safely be fired again. Check the original invoice's audit log, or list your invoices for a recent one carrying the same document, and confirm whether a new IRN was already created before you retry.
The rejected original stays in your account as a record, and keeps appearing in list results, so map the old IRN to the new one in your own ledger and filter rejected invoices out of any reporting query.
Recording payment
Payment tracking is yours to maintain, and nothing in the invoice flow sets it for you. There are two ways to do it: the mark as paid endpoint, which closes an invoice out in full, and the update call, which is the only route to anything less than that.
Mark as paid is the terminal one. It requires an invoice that has been sent, moves status to paid and paymentStatus to paid, and stamps paymentDate with the current time if you have not set one.
curl --request POST \
--url https://dashboard.tryduplo.com/api/e-invoicing/invoices/DUP00000001-2A3A045D-20240123/payment-status \
--header 'Authorization: Bearer <your-api-key>'On a successful request, the invoice comes back with both fields moved and paymentDate stamped:
{
"invoiceReferenceNumber": "DUP00000001-2A3A045D-20240123",
"status": "paid",
"paymentStatus": "paid",
"paymentDate": "2024-02-20T09:14:33.221Z"
}An invoice that is not sent, or that is already paid, returns 400. The call takes no body, so it is all or nothing: there is no amount parameter, and it does not settle part of amountDue.
It is also terminal in both fields at once, moving status to paid as well as paymentStatus. Nothing walks that back, because update refuses any invoice past created, so call it when the money has actually arrived rather than when you expect it to.
The other route is the update call, which accepts paymentStatus as pending, paid, partially_paid, or overdue, plus a paymentDate of your own, or null to clear it. It is the only way to record a part payment or an overdue invoice, and it comes with a real constraint worth planning around:
Part payment cannot be recorded after sending
Update only accepts a draft or created invoice, so partially_paid and
overdue can only be set before the invoice is validated, which is well
before anyone could have paid it. For an invoice you have already sent, the
API offers exactly one payment transition: mark as paid, in full. Track
anything more granular in your own ledger and use mark as paid to close the
invoice out when the balance clears.
If you need the payment state Duplo holds to match what NRS holds, read what NRS itself holds, which reports its own view of the payment state.
Invoices sent to you
When another NRS-connected business invoices your TIN, the invoice lands in your account on its own, with direction: inbound and incomingAction: pending. You issued no call to make that happen, and nothing calls you when it does, so poll direction=inbound&incomingAction=pending on the list endpoint at whatever cadence your accounts payable process runs.
Inbound invoices are read-only records of what a supplier says you owe. Everything on this page that changes an invoice applies to your outbound ones, and the two calls below are the only actions an inbound invoice accepts.
The acknowledge endpoint accepts an inbound invoice:
curl --request POST \
--url https://dashboard.tryduplo.com/api/e-invoicing/invoices/DUP00000001-2A3A045D-20240123/acknowledge \
--header 'Authorization: Bearer <your-api-key>'{
"invoiceId": "9c1f3b2a-77e4-4c1d-9b6a-2f5d8e0a4c73",
"incomingAction": "acknowledged",
"incomingActionAt": "2026-06-10T11:32:00Z"
}The reject endpoint refuses one, and requires a reason of 3 to 500 characters:
curl --request POST \
--url https://dashboard.tryduplo.com/api/e-invoicing/invoices/DUP00000001-2A3A045D-20240123/reject \
--header 'Authorization: Bearer <your-api-key>' \
--header 'Content-Type: application/json' \
--data '{ "reason": "Incorrect line item amounts; please re-issue." }'{
"invoiceId": "9c1f3b2a-77e4-4c1d-9b6a-2f5d8e0a4c73",
"incomingAction": "rejected",
"incomingActionAt": "2026-06-10T11:32:00Z",
"incomingRejectionReason": "Incorrect line item amounts; please re-issue."
}Both return the invoice id, the resulting incomingAction, and incomingActionAt. Rejection also echoes incomingRejectionReason.
Rejection is local
Rejecting an inbound invoice records your decision in Duplo and surfaces the reason in the audit log. NRS is not notified, and the supplier's copy of the invoice is unaffected, so tell the supplier through your usual channel and ask them to raise a credit note.
The two actions are mutually exclusive and each is one-way: acknowledging an already-rejected invoice returns 400, as does rejecting an acknowledged one, and so does either action on an outbound invoice. There is no undo, so treat the call as the point where your internal approval has already happened, not as the approval itself.
incomingActionAt is the field to keep. It stamps when your business took a position on the invoice, which is the timestamp an audit will ask about, and it is the only record of that decision outside the invoice's own audit log.
Checking state at NRS
Everything above reads Duplo's copy of the invoice. Three endpoints go further and ask NRS directly, which is what you want when the two copies might have drifted. In practice that is three situations: a submit that was handed off to the background processor and you want the authoritative answer, a recipient who says an invoice never arrived, and a periodic reconciliation job.
Each of these is a live network call to NRS, so they are slower than reading Duplo and they are subject to NRS being slow or down. Call them for a specific invoice you have a question about, never in a loop over a page of list results.
The confirm endpoint returns what NRS currently holds for an IRN:
curl --request GET \
--url https://dashboard.tryduplo.com/api/e-invoicing/invoices/DUP00000001-2A3A045D-20240123/confirm \
--header 'Authorization: Bearer <your-api-key>'{
"reference": "DUP00000001-2A3A045D-20240123",
"issueDate": "2026-08-07",
"dueDate": null,
"syncDate": "2026-08-07",
"paymentStatus": "PENDING",
"entryStatus": "NEW_ENTRY",
"transmitted": true,
"delivered": false
}transmitted and delivered are the useful pair, and the reason to call this at all. Duplo's own transmissionStatus tells you that NRS accepted the transmit call. delivered tells you it actually reached the recipient, which is the question behind most "we never got it" conversations. A transmitted: true with delivered: false puts the problem on the recipient's side of NRS, not yours.
Note that NRS answers in its own vocabulary, in upper case: PENDING, NEW_ENTRY. Do not compare these strings against Duplo's lower-case status and paymentStatus values, and treat syncDate as how fresh NRS's own record is.
The lookup endpoint reports whether the invoice's transmit recipient is reachable on the NRS network, and whether they have a webhook configured there:
curl --request GET \
--url https://dashboard.tryduplo.com/api/e-invoicing/invoices/DUP00000001-2A3A045D-20240123/lookup \
--header 'Authorization: Bearer <your-api-key>'{
"reference": "DUP00000001-2A3A045D-20240123",
"receiverConnected": true,
"hasWebhookSetup": false
}Recipient lookup answers the narrower question of whether the recipient's TIN is registered with NRS, and is the one to call before transmitting. It returns a safe default when NRS is slow or unavailable, so a failed lookup never blocks you, and it returns 400 if the invoice recipient has no TIN on file.
The two overlap, and the practical split is that recipient lookup answers "can I transmit this through NRS at all", while lookup adds hasWebhookSetup, which tells you whether the recipient will be notified by NRS or will only see the invoice when they next look. Neither changes the invoice, and both are free to call.
If either says the recipient is not connected, transmit with firsTransmit: false and an email address instead. Doing the lookup before the transmit is cheaper than discovering it afterwards, because a transmit to a disconnected recipient comes back as transmissionStatus: rejected and you have to work out why.
Regenerating a QR code
The QR code is what makes a printed or emailed invoice verifiable: scanning it resolves to the signed record at NRS. It arrives on its own, without you asking, once an invoice is signed, or once an inbound invoice is materialised in your account, and it lands in two fields:
| Field | What it is | Use it for |
|---|---|---|
qrCodeData | Base64 data URL of the PNG | Embedding directly in an HTML or PDF template, with no second fetch |
qrCodeImageUrl | Hosted PNG on S3 | Emails and anywhere a URL is easier to pass around than a blob |
Generate QR code rebuilds both from the signed invoice, which you need only when something on your side lost them, for instance a rendering pipeline that stored the data URL and dropped it.
curl --request POST \
--url https://dashboard.tryduplo.com/api/e-invoicing/invoices/DUP00000001-2A3A045D-20240123/qr-code \
--header 'Authorization: Bearer <your-api-key>'The invoice comes back with both fields repopulated:
{
"invoiceReferenceNumber": "DUP00000001-2A3A045D-20240123",
"status": "signed",
"qrCodeData": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...",
"qrCodeImageUrl": "https://bucket.s3.region.amazonaws.com/business-1/invoice-qr-codes/key.png"
}It is safe to call more than once. Regenerating does not re-sign the invoice, does not touch NRS, and is not billable, so it cannot cost you a second signature the way a careless resubmit can.
Both 400s it can return are setup problems rather than payload problems. An invoice that is not signed yet has no signature to encode, so sign it first. A business with no NRS crypto keys stored cannot produce one at all, which is the same setup this whole API depends on, so check your NRS configuration in Duplo Dashboard.
Reading a customer back
Invoices reference customers by customerId. To read one back, call Get customer with the id you stored at creation.
curl --request GET \
--url https://dashboard.tryduplo.com/api/e-invoicing/customers/123e4567-e89b-12d3-a456-426614174000 \
--header 'Authorization: Bearer <your-api-key>'{
"id": "123e4567-e89b-12d3-a456-426614174000",
"businessId": "f3f3c6a1-45e3-44aa-b05a-1fbe98d52d09",
"reference": "cust_invoice_A1B2C3D4E5F6",
"modules": ["invoice"],
"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",
"createdAt": "2024-01-23T09:58:11.187Z",
"updatedAt": "2024-01-23T09:58:11.187Z"
}The response carries the stored customer details, including the tin and postcode that invoice validation depends on. That makes this the first call to reach for when an invoice fails validation on customer data: the invoice payload does not carry the customer inline, so a missing TIN surfaces as an invoice error whose actual cause is sitting in the customer record.
The customer API is deliberately small, and the two things it does not have shape how you should use it:
- There is no list endpoint. You cannot search your customers by email or name through the API, so the
idreturned at creation is the only handle you get. Store it against your own record of the customer at the moment you create them. - There is no update endpoint. Create customer doubles as the update: re-posting the same email with a different TIN updates the existing record rather than creating a second one, while the same email and the same TIN returns
409.
Customers are scoped to your business and to the mode you created them in, so a test id will not resolve in live. Create your customers again when you go live rather than carrying ids across.
How is this guide?