Issuing an E-Invoice

Create a customer, reserve an invoice reference number, then create and sign an invoice.

This guide walks one invoice from nothing to signed, ready for Transmitting an E-Invoice to deliver it. It assumes you already have an API key and can authenticate; if not, start with Getting Started.

Your business also needs e-invoicing on its plan and its NRS setup completed in Duplo Dashboard. The e-invoicing endpoints check both, whatever the request looks like: on a plan without e-invoicing they return 403 with the error FEATURE_NOT_SUBSCRIBED, and until NRS setup is complete they return 403 with NRS/e-invoicing is not set up for this business.

Building a gateway for other businesses?

This guide is for a business invoicing under its own TIN. If you invoice on behalf of businesses you represent, you want the NRS Invoice Gateway instead, which gives you a dedicated domain and a system integrator relationship.

The invoice lifecycle

An invoice moves through four stages. Each stage is its own endpoint, so you can inspect the invoice between steps.

Validate and sign are separately callable, but most integrations use the submit endpoint, which runs both in one request. The walkthrough below uses submit, then shows the two-call alternative.

Creating a customer

An invoice references a customer by customerId rather than carrying their details inline, so create the customer first and reuse the returned ID. Customers are stored per business and per mode. Thus, customers created while your business is in test mode will not work in live mode.

Looking up country and state codes

Every customer needs a country, and can optionally carry a state. Only use a country and state that these endpoints return, since those are the ones supported. Each comes with a shortCode, and for country that is the ISO 3166-1 alpha-2 code the field expects.

Start with the list countries endpoint, which returns every supported country:

curl --request GET \
  --url https://spend-api.tryduplo.com/api/v1/countries \
  --header 'Authorization: Bearer <your-api-key>'

Each country comes back with a name and a shortCode. Only Nigeria and South Africa are supported:

JSON
{
  "statusCode": 200,
  "timestamp": "2024-01-23T09:57:38.104Z",
  "path": "/api/v1/countries",
  "message": "Countries retrieved successfully",
  "data": [
    { "name": "Nigeria", "shortCode": "NG" },
    { "name": "South Africa", "shortCode": "ZA" }
  ]
}

The shortCode is the value the customer's country takes, so Nigeria is NG. Pass it in the path to the list states endpoint to get that country's states:

curl --request GET \
  --url https://spend-api.tryduplo.com/api/v1/countries/NG/states \
  --header 'Authorization: Bearer <your-api-key>'

Each state also comes back with a name and a shortCode. The response below is shortened to the first three:

JSON
{
  "statusCode": 200,
  "timestamp": "2024-01-23T09:57:40.512Z",
  "path": "/api/v1/countries/NG/states",
  "message": "States retrieved successfully",
  "data": [
    { "name": "Abia", "shortCode": "AB" },
    { "name": "Abuja Federal Capital Territory", "shortCode": "FC" },
    { "name": "Adamawa", "shortCode": "AD" }
  ]
}

Any other country code returns 400 Unsupported country code. Supported: NG, ZA.

Storing the customer

To store a customer, make a request to the Create a customer endpoint. Every field is required unless the samples mark it otherwise:

const axios = require("axios");

const createCustomer = async () => {
  const response = await axios.post(
    "https://spend-api.tryduplo.com/api/v1/customers",
    {
      firstName: "Example Buyer",
      lastName: "Limited",
      email: "ap@buyer.example",
      phone: "+2348098765432",
      tin: "87654321-0001", // optional, but NRS needs it
      country: "NG",
      state: "FCT", // optional
      city: "Abuja", // optional
      address: "2 Buyer Avenue",
      postcode: "900001",
      module: "invoice",
    },
    {
      headers: {
        Authorization: "Bearer <your-api-key>",
        "Content-Type": "application/json",
      },
    },
  );

  console.log(response.data);
};

createCustomer();

In your payload, you have to set module to invoice so the customer is usable for e-invoicing. firstName and lastName are joined into the customer name that appears on the invoice. country is the shortCode from the countries list, NG for Nigeria.

The address and postcode are mandatory, and address is limited to 50 characters. A customer without them is rejected at creation.

When you are creating a customer, the tin is optional. However, NRS requires a tin for a customer during the validation stage, and a customer created without one will cause the invoice to fail.

On a successful request, you should receive a response similar to the one below:

JSON
{
  "statusCode": 201,
  "timestamp": "2024-01-23T09:58:11.204Z",
  "path": "/api/v1/customers",
  "message": "Customer added successfully",
  "data": {
    "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"
  }
}

Keep the id from the response: that is the customerId you send on every invoice for this customer. Posting the same customer again does not create a second one, and Managing Customers covers what it does instead, along with finding and reading customers back.

Reserving an Invoice Reference Number (IRN)

With the customer stored, the next thing the invoice needs is its reference number. Every invoice needs an IRN, and you get one from Duplo rather than composing it yourself. To get your IRN, make a request to the Generate an IRN endpoint as shown below:

curl --request GET \
  --url https://spend-api.tryduplo.com/api/v1/e-invoicing/invoices/generate-irn \
  --header 'Authorization: Bearer <your-api-key>'

On a successful request, you should receive a response similar to the one below:

JSON
{
  "statusCode": 200,
  "timestamp": "2024-01-23T10:15:04.512Z",
  "path": "/api/v1/e-invoicing/invoices/generate-irn",
  "message": "Invoice Reference Number generated successfully",
  "data": {
    "invoiceReferenceNumber": "DUP00000001-2A3A045D-20240123",
    "systemInvoiceNumber": "DUP00000001"
  }
}

Use invoiceReferenceNumber from the response above as the invoiceReferenceNumber on the invoice you create next. It is also the reference in the path of every later call for that invoice.

Reserve a new IRN for every invoice. The systemInvoiceNumber inside it can only be used once, so reusing an IRN returns 400 System invoice number DUP00000001 already exists.

Creating the invoice

You now have the two values the invoice document depends on: the customerId you stored, and the IRN you just reserved. Everything else describes the sale itself.

This is the largest payload in the flow, so the sample below stays at the minimum NRS accepts: one line item, one tax, and the supplier's own details. Post it to the Create an invoice endpoint. As before, anything without a comment is required:

const axios = require("axios");

const createInvoice = async () => {
  const response = await axios.post(
    "https://spend-api.tryduplo.com/api/v1/e-invoicing/invoices",
    {
      invoiceNumber: "INV-2024-001", // optional
      invoiceReferenceNumber: "DUP00000001-2A3A045D-20240123",
      invoiceType: "381",
      issueDate: "2024-01-23",
      documentCurrency: "NGN",
      taxCurrency: "NGN",
      customerId: "123e4567-e89b-12d3-a456-426614174000",
      supplierData: {
        name: "Acme Limited",
        email: "billing@acme.example",
        phoneNumber: "+2348012345678",
        tin: "12345678-0001",
        country: "NG",
        state: "Lagos", // optional
        city: "Ikeja", // optional
        address: "1 Seller Street",
        postcode: "100001",
      },
      items: [
        {
          description: "Annual platform subscription",
          unit: "Months",
          quantity: 12, // optional, defaults to 1
          unitPrice: 25000,
          serviceCategory: "Information technology services",
          isicCode: "M-6201",
          taxes: [{ name: "STANDARD_VAT", rate: 7.5 }], // optional
        },
      ],
    },
    {
      headers: {
        Authorization: "Bearer <your-api-key>",
        "Content-Type": "application/json",
      },
    },
  );

  console.log(response.data);
};

createInvoice();

The tax names, rates, and item codes in this payload come from the reference data endpoints, which list the values each field takes.

supplierData describes your own business as it appears on the invoice, and its country takes the same ISO 3166-1 alpha-2 code the customer does, so Nigeria is NG here too.

invoiceType is a numeric document code rather than a word. 381 is a commercial invoice, which is what a normal sale needs. 380 and 384 are credit and debit notes, and both require billingReferences pointing at the original invoice. The invoice type reference lists all six.

issueDate cannot be in the future. Today and any past date are accepted, and tomorrow is rejected with an Issue date cannot be in the future error.

Every item needs a category, and there are two ways to give it one: productCategory with an hsnCode, or serviceCategory with an isicCode. They are mutually exclusive, and an item carrying neither is rejected. An hsnCode is exactly four digits, a dot, then two digits, as in 2201.10, while an isicCode carries its sector letter, as in M-6201. The product codes endpoint and the service codes endpoint list the codes NRS publishes for each.

unit comes from a fixed list: Hour, Day, Weeks, Months, Pieces, Litres, Kilograms, Grams, Pounds, and Ounces. Duplo converts it to the matching NRS unit code for you, so you never send a code from the quantity codes endpoint directly. quantity defaults to 1 if you leave it out, and taxes is optional for a line that carries none.

Each tax name is passed to NRS as its tax category, so use an NRS category code such as STANDARD_VAT rather than a description. The list taxes endpoint returns the NRS categories with their current rates, alongside any taxes set up for your business in Duplo Dashboard.

To fetch them, make a request to the list taxes endpoint:

curl --request GET \
  --url https://spend-api.tryduplo.com/api/v1/e-invoicing/taxes \
  --header 'Authorization: Bearer <your-api-key>'

Each tax comes back with a name and a rate. Taxes set up for your business come first, followed by the NRS categories, whose name is the category code to send. NRS categories without a published rate are left out. The response below is shortened to one entry:

JSON
{
  "statusCode": 200,
  "timestamp": "2024-01-23T09:59:02.318Z",
  "path": "/api/v1/e-invoicing/taxes",
  "message": "Available taxes retrieved successfully",
  "data": [
    { "name": "STANDARD_VAT", "rate": 7.5 }
  ]
}

Copy an NRS category's name and rate into the item's taxes array as they are. The rate is already in the percentage-point form an invoice line expects.

saveAsDraft decides the status the invoice starts in. Omit it and the invoice is created, set it to true and the invoice is draft, and either way it stays fully editable until you validate.

Two rules that reject invoices most often

Item description accepts a narrow character set: letters, digits, spaces, &, ", [, ], and -, with no double spaces. Commas and full stops are rejected, so write Annual platform subscription rather than Subscription, annual.

On a successful request, you should receive a response similar to the one below, abbreviated here because the full record echoes every field you sent along with the customer:

JSON
{
  "statusCode": 201,
  "timestamp": "2024-01-23T10:22:47.980Z",
  "path": "/api/v1/e-invoicing/invoices",
  "message": "Invoice created successfully",
  "data": {
    "id": "9c1f3b2a-77e4-4c1d-9b6a-2f5d8e0a4c73",
    "businessId": "f3f3c6a1-45e3-44aa-b05a-1fbe98d52d09",
    "businessState": "TEST",
    "invoiceNumber": "INV-2024-001",
    "systemInvoiceNumber": "DUP00000001",
    "invoiceReferenceNumber": "DUP00000001-2A3A045D-20240123",
    "invoiceType": "381",
    "issueDate": "2024-01-23",
    "documentCurrency": "NGN",
    "taxCurrency": "NGN",
    "customerId": "123e4567-e89b-12d3-a456-426614174000",
    "subtotalAmount": 300000,
    "taxTotal": 22500,
    "allowanceTotal": 0,
    "chargeTotal": 0,
    "totalAmount": 322500,
    "amountDue": 322500,
    "status": "created",
    "firsStatus": null,
    "direction": "outbound",
    "transmissionStatus": "not_sent",
    "paymentStatus": "pending",
    "createdAt": "2024-01-23T10:22:47.902Z",
    "updatedAt": "2024-01-23T10:22:47.902Z"
  }
}

You do not compute totals. Duplo derives subtotalAmount, taxTotal, totalAmount, and amountDue from the line items, per-line allowances and charges, and any invoice-level discount.

Submitting for validation and signing

The invoice exists in Duplo, but NRS has not seen it yet, and it carries no signature or QR code until it does. The next step is to validate and sign the invoice, which are two separate stages of the lifecycle. However, the submit endpoint does both in a single call, and that is what this guide uses. To make the two calls yourself instead, see Validating and signing separately.

Make a request to the Submit an invoice endpoint with the IRN in the path, and an idempotency key so a network retry cannot bill you twice:

curl --request POST \
  --url https://spend-api.tryduplo.com/api/v1/e-invoicing/invoices/DUP00000001-2A3A045D-20240123/submit \
  --header 'Authorization: Bearer <your-api-key>' \
  --header 'x-idempotency-key: submit-DUP00000001-2A3A045D-20240123'

On a successful request, you should receive a response similar to the one below, shown here as the data object only:

JSON
{
  "invoiceId": "9c1f3b2a-77e4-4c1d-9b6a-2f5d8e0a4c73",
  "firsStatus": "signed",
  "status": "signed",
  "processedSynchronously": true
}

Submit tries to complete both calls synchronously inside a 30 second window. A 200 carries processedSynchronously, which tells you whether it managed it:

  • processedSynchronously: true means validate and sign both completed inside the window. The invoice is signed, its QR code is attached, and there is nothing further to do.
  • processedSynchronously: false means NRS was slow or briefly unavailable, so the invoice moved to submitted with firsStatus: pending and Duplo's background processor took it over. The invoice is not signed yet, and what you do next is covered in When a submit is handed off.

A 422 is the third outcome, and it carries no such field. NRS rejected the invoice outright and the message carries the reason it gave. That is terminal for this attempt: the invoice stays at created so you can correct the document and submit again, and retrying the same document unchanged will fail the same way.

When a submit is handed off (processedSynchronously: false)

There is no completion callback, so poll Get invoice until firsStatus leaves pending.

The processor retries on its own schedule, waiting 30 seconds, then 2 minutes, 10 minutes, 30 minutes, and an hour between attempts. Poll on roughly that shape rather than in a tight loop: most invoices settle on the first or second attempt, and a checking interval of a minute or so is enough.

After five failed attempts it gives up. A failure during validation returns the invoice to unsubmitted, a failure during signing leaves it rejected, any bundle quota consumed is refunded, and the supplier address on the invoice gets an email explaining what happened. Worst case, that is about 45 minutes from submit to a settled answer.

Validating and signing separately

Submit is the shortest path, but you can make the two calls yourself instead. Validating first has NRS check the document without charging you, so you can validate an invoice as soon as it is drafted and sign it later, when you are ready to issue it. Neither call is handed off to the background processor, so each one returns its result, or its error, in the response.

Make a request to the Validate an invoice endpoint with the IRN in the path. It takes no body and no idempotency key, because nothing is billed:

curl --request POST \
  --url https://spend-api.tryduplo.com/api/v1/e-invoicing/invoices/DUP00000001-2A3A045D-20240123/validate \
  --header 'Authorization: Bearer <your-api-key>'

On a successful request, you should receive a response similar to the one below, abbreviated to the fields validation changes:

JSON
{
  "statusCode": 200,
  "timestamp": "2024-01-23T10:24:05.413Z",
  "path": "/api/v1/e-invoicing/invoices/DUP00000001-2A3A045D-20240123/validate",
  "message": "Invoice validated successfully",
  "data": {
    "id": "9c1f3b2a-77e4-4c1d-9b6a-2f5d8e0a4c73",
    "invoiceReferenceNumber": "DUP00000001-2A3A045D-20240123",
    "status": "validated",
    "firsStatus": "validated",
    "firsRejectionReason": null,
    "qrCodeData": null,
    "qrCodeImageUrl": null,
    "transmissionStatus": "not_sent",
    "updatedAt": "2024-01-23T10:24:05.398Z"
  }
}

The invoice is now validated in Duplo (status) and at NRS (firsStatus). Validation fails in one of two ways, and neither changes the invoice's status, so you can correct it with the update endpoint and validate again:

  • 400 comes from Duplo before NRS is called. Either the invoice is not draft or created, or required fields are missing, and the message lists them after Invoice is not ready for NRS submission. Missing or invalid fields:.
  • 422 means NRS rejected the document, and the message carries the reason it gave.

Once the invoice is validated, make a request to the Sign an invoice endpoint. Signing is the billable step, so send an idempotency key as you would on submit, with a key of its own:

curl --request POST \
  --url https://spend-api.tryduplo.com/api/v1/e-invoicing/invoices/DUP00000001-2A3A045D-20240123/sign \
  --header 'Authorization: Bearer <your-api-key>' \
  --header 'x-idempotency-key: sign-DUP00000001-2A3A045D-20240123'

On a successful request, you should receive a response similar to the one below, abbreviated the same way:

JSON
{
  "statusCode": 200,
  "timestamp": "2024-01-23T10:25:41.087Z",
  "path": "/api/v1/e-invoicing/invoices/DUP00000001-2A3A045D-20240123/sign",
  "message": "Invoice signed and submitted successfully",
  "data": {
    "id": "9c1f3b2a-77e4-4c1d-9b6a-2f5d8e0a4c73",
    "invoiceReferenceNumber": "DUP00000001-2A3A045D-20240123",
    "status": "signed",
    "firsStatus": "signed",
    "firsRejectionReason": null,
    "qrCodeData": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...",
    "qrCodeImageUrl": "https://bucket.s3.region.amazonaws.com/business-1/invoice-qr-codes/key.png",
    "transmissionStatus": "not_sent",
    "updatedAt": "2024-01-23T10:25:41.064Z"
  }
}

The invoice is now signed, with its QR code in qrCodeData and qrCodeImageUrl, and it is ready to transmit. Sign only accepts a validated invoice, so calling it on anything else returns 400 Only validated invoices can be signed and submitted, followed by the current status. A bundle with no invoices left returns 400 Your e-invoicing balance is exhausted. Please purchase a new bundle. before NRS is called.

If NRS does not sign the invoice, the call returns 500 with the reason, and the invoice ends up in one of two states:

  • NRS rejected it. firsStatus becomes rejected and the charge is reversed, or the bundle invoice refunded. The resubmit endpoint takes it from there under a new IRN.
  • The outcome could not be confirmed, for example because NRS timed out. The invoice is held at status: submitted with firsRejectionReason: nrs_sign_outcome_unconfirmed, because NRS may have signed it. Do not try to sign it again, which returns 400 now that it is no longer validated, and contact Duplo support with the IRN.

Signing is billable and irreversible

Signing is charged per invoice, and how depends on your plan. On pay-as-you-go it debits your NGN business wallet before the NRS call, so an underfunded wallet fails the submit or sign with 400 PAYG Wallet debit failed, followed by the reason. On a bundle it draws down your invoice quota, and on an annual plan it is already covered. Send an idempotency key on every submit and sign so a network retry cannot bill you twice.

A signed invoice can no longer be edited: the update endpoint only accepts a draft or created invoice. To correct a signed invoice, raise a credit note against it. An invoice that NRS moved to rejected is a different case, handled by the resubmit endpoint under a new IRN.

Retrying safely

Submit and sign both accept an idempotency key because they are the only billable endpoints. Transmit accepts one too, under different rules covered in retrying a transmit. On submit and sign, send it in the x-idempotency-key header: your own key, 8 to 128 characters of letters, digits, ., _, :, or -.

Repeat a call with the same key and you get the first call's result replayed, with no second charge. Reusing the key on a different invoice is rejected rather than replayed, with 409 Idempotency key was already used for a different invoice, because keys are unique across your business, not per invoice.

A key is claimed only once the call gets as far as billing. If the wallet debit itself fails, the key is released, so you can top up and retry with the same key. Deriving the key from your own invoice identifier, rather than a fresh random value per attempt, is what makes a retry after a timeout safe.

Next steps

That is one invoice from nothing to signed. Transmitting an E-Invoice delivers it to the recipient. What happens after that, reading the invoice back, filtering your invoices, correcting one before it is signed, recording payment, and acting on invoices other businesses send you, is covered in Manage Issued Invoices.

How is this guide?

Last updated on

On this page