Onboard Represented Businesses

Verify each NRS-registered issuer, create its Gateway mapping, and optionally store the material used for invoice QR codes.

A represented business is the legal entity whose verified NRS identity appears on an invoice. It sits under the top-level business you manage in Duplo Dashboard: the top-level business owns the approved domain, API key, wallet, billing, and webhooks, while the represented business owns only its NRS identity, invoice routing, and optional QR material. It gets no Duplo Dashboard login, API key, or wallet of its own. Onboarding one is unbilled; only invoice signing carries a charge.

The represented business has an immutable public Gateway identity, gatewaySubBusinessId, used in x-sub-business-id and represented-business path parameters. Gateway separately returns the current versioned NRS mapping as firsBusinessId and nrsIdentity.nrsBusinessId; use that current external value as the Pass-through invoice body's business_id. All three initially contain the selected NRS business's id, but an audited protected migration can advance the NRS mapping without silently changing the Gateway identity.

Prerequisites for each represented business

Confirm all of the following before you send the onboarding request:

  • The NRS entity, and at least one business under it, are active in the NRS environment that your API key resolves to. The key carries its own test or live state, and that state alone selects the environment; no header or body field overrides it.
  • The business has selected Duplo as its Access Point Provider (APP) in the NRS portal. If it has not, onboarding fails with 403.
  • You have that business's NRS portal email and password. The Gateway uses them for this one verification request.
  • The entity contains exactly one active business, or you know the exact TIN of the one to select.
  • That business has an ID, name, TIN, and a non-empty IRN template in NRS.
  • You have chosen an x-idempotency-key for the request and have durable storage for the identifiers it returns. See Design stable idempotency keys for naming patterns.

NRS QR material is optional

Omit nrsCryptoKeys when you do not need invoice QR generation yet. The business is onboarded with cryptoVersion: 0, and the absence of these values does not block validation, signing, or transmission. Duplo skips QR generation until both values have been stored. To send the material now, see where it comes from first.

Send the onboarding request

Send the request to your approved domain, replacing acme with your own domain label from Choose a domain label. The request is scoped to the top-level business that owns the API key, so it carries no x-sub-business-id header, and x-idempotency-key is required.

This minimal request is valid when the authenticated NRS entity has exactly one active business:

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"
    }
  }'

One optional field, tin, selects which business to onboard. Omit it when the authenticated NRS entity has exactly one active business, and send it when the entity has more than one, so that it picks out a single issuer:

{
  "nrsCredentials": {
    "email": "nrs-owner@acme.example",
    "password": "replace-at-runtime"
  }
}

Request-field rules

Duplo verifies the business's identity with NRS and stores what NRS returns. Only nrsCredentials and tin affect which business that is. Every other field below is a local label of your own: it never selects the business and never overrides the NRS-verified values.

FieldRequirement and use
nrsCredentialsRequired while the onboarding intent is new or pending within its completion window. Both email and password are required inside the object. A replay of a completed onboarding can omit the object.
tinOptional selector, 1 to 255 characters. Matched trimmed and case-insensitively, and it must select exactly one active NRS business. When omitted, the entity must contain exactly one active business.
name, legalNameOptional local profile values, each limited to 50 characters.
emailOptional local contact address, validated as an email and limited to 254 characters. This is not the NRS portal login.
phoneNumberOptional local contact number in E.164 format, such as +2348012345678, limited to 20 characters.
invoiceNumberPrefixOptional local value from 1 to 32 characters.
nrsCryptoKeysOptional QR material. When present, both certificate and publicKey must be non-blank strings; the UTF-8 limits are 32 KiB and 16 KiB respectively.

new, pending, and completed are internal idempotency states, not values an integrator reads from a separate status endpoint. A request starts new, remains pending while the same onboarding is incomplete, and becomes completed only after the represented-business mapping commits. A pending intent has a finite completion window configured by Duplo, currently about 30 minutes by default. After that window, the same key can identify an intent that is no longer resumable.

If an HTTP response is lost, retry the same normalized profile and nrsCredentials with the same idempotency key only within a bounded retry window. Gateway can then resume an active pending intent or replay a committed result. If that retry returns 409, stop automated retries: the public response does not reliably prove whether the intent merely expired, conflicted, or committed before the response was lost. Preserve the idempotency key, request payload, returned requestId, and response, then contact Duplo Support. Use a new idempotency key only after Duplo confirms that the earlier intent did not commit. A successful response exposes only status: "completed".

The Gateway rejects unknown fields with 400. Do not send existingBusinessId, businessId, parentBusinessId, entityId, entityReference, firsBusinessId, nrsBusinessId, businessName, or irnTemplate; those identity and ownership values are server-resolved.

What happens during onboarding

  1. Bind tenant

    Gateway resolves the approved host and re-checks on this request that the API key belongs to the business owning it, in the same test/live state.

  2. Create intent

    Spend normalizes and records the optional profile fields and the idempotency key before Gateway contacts NRS.

  3. Verify NRS

    NRS authentication returns an entity_id. Gateway loads that entity and selects one active business using the optional TIN, or the only active business; the selected business.id becomes the public Gateway identifier.

  4. Create mapping

    Gateway sends Spend a short-lived signed identity proof plus optional QR material; Spend creates the represented-business record in one step.

The Gateway uses the NRS password only for that verification call. It is not logged, echoed back, stored, or forwarded to Spend. Spend receives only the signed NRS identity proof and, when supplied, the optional QR values.

Selection fails with:

  • 422 when no active business is available, the selected business is inactive, or its IRN template is missing.
  • 404 when a supplied TIN matches no NRS business.
  • 409 when the entity has several active businesses and no TIN narrows them to one.
  • 502 when NRS returns incomplete or malformed identity data.

Save the response

A first-time onboarding that sends no QR material returns 201:

Onboarding response
{
  "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}}-2A3A045D-{{YYYYMMDD}}"
  },
  "status": "completed",
  "replayed": false,
  "cryptoVersion": 0
}

onboardingIntentId identifies the intent that created this mapping. You do not send it on later invoice calls. On initial onboarding, the immutable Gateway identity is allocated from the selected NRS value, so these fields match:

gatewaySubBusinessId == firsBusinessId == nrsIdentity.nrsBusinessId

Gateway obtains the initial value from the selected active business's id after NRS authentication has returned data.entity_id and Gateway has loaded the associated entity. gatewaySubBusinessId then remains stable for the life of the Gateway mapping. If an audited protected migration later changes the NRS business ID, firsBusinessId and nrsIdentity.nrsBusinessId advance together while gatewaySubBusinessId and its accepted historical aliases remain unchanged.

nrsIdentity is the complete durable NRS identity Gateway verified and stored for this represented business. The NRS authentication response contributes entityId from data.entity_id. Gateway then reads that entity and selects one active business to obtain entityReference, nrsBusinessId, businessName, tin, and irnTemplate. entityReference is null when NRS supplied neither a usable entity reference nor a selected-business reference. It is diagnostic metadata, not a routing value.

Gateway does not copy the raw NRS authentication envelope into this response. Provider transaction IDs, status text, messages, and receipt timestamps are transient transport metadata and are not persisted. The password, certificate, public key, and internal ownership identifiers are never returned.

status is always completed on a 2xx response, so a successful call never leaves an intent for you to poll. replayed is true when the idempotency key matched an earlier completed onboarding and these identifiers came from that first attempt. The public Gateway currently returns HTTP 201 for both the first completed onboarding and a completed replay; use replayed, not the HTTP status, to distinguish them.

Store at least this mapping in your integration database:

Value to storeWhere it comes fromWhy you need it
Your own customer or legal-entity IDYour systemResolve the represented business from your application
gatewaySubBusinessIdThis responseImmutable Gateway identity. Send it as x-sub-business-id, represented-business path segments, and the Stored transmission body's represented-business selector.
firsBusinessId / nrsIdentity.nrsBusinessIdThe active identity versionCurrent NRS business ID. These two fields are equal; send this value as body business_id in Pass-through validation and signing requests.
nrsIdentity.entityId and entityReferenceThis responseIdentify the NRS entity for diagnostics; do not use them as Gateway routing values
nrsIdentity.businessName and tinThis responseMust match the represented issuer identity in invoice payloads
nrsIdentity.irnTemplateThe active identity versionRequired to build a valid IRN; treat it as a persisted snapshot until reverification records a newer reviewed version
cryptoVersionThis response0 means no QR material, 1 is the first stored pair, and later replacements increment it

Store both the immutable Gateway ID and the current NRS business ID in your integration. Gateway verifies that the header- or path-selected mapping owns the current versioned NRS identity. Treat both values as opaque, case-sensitive strings: do not parse them as UUIDs, trim them, change their case, or generate replacements.

List represented businesses

Use the collection read to recover or reconcile every active represented business available to the current parent, approved domain, test/live state, and mode version:

curl \
  --url 'https://acme.invoice.tryduplo.com/sub-businesses?page=1&limit=50' \
  --header 'x-api-key: pk_test_replace_with_your_key'

page defaults to 1 and can be from 1 through 1,000,000. limit defaults to 50 and can be from 1 through 100. Unknown or repeated query parameters are rejected. Do not send x-sub-business-id, an idempotency key, a body, or NRS credentials. The read is unbilled, uses stored mappings, and makes no NRS request.

Represented-business list response
{
  "items": [
    {
      "gatewaySubBusinessId": "nrs-business-demo",
      "onboardingIntentId": "33333333-3333-4333-8333-333333333333",
      "domain": "acme",
      "businessState": "test",
      "modeVersion": 1,
      "status": "active",
      "profile": {
        "name": "Acme Retail",
        "legalName": "Acme Retail Limited",
        "email": "finance@acmeretail.example",
        "phoneNumber": "+2348000000000",
        "invoiceNumberPrefix": "ACME"
      },
      "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}}-2A3A045D-{{YYYYMMDD}}"
      },
      "crypto": {
        "configured": false,
        "version": 0,
        "updatedAt": null
      },
      "createdAt": "2026-09-15T12:34:56.789Z",
      "updatedAt": "2026-09-15T12:34:56.789Z"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 50,
    "total": 1,
    "totalPages": 1
  }
}

An empty page returns items: []; it is not a 404. Paginate until page reaches totalPages. Every item uses the same safe snapshot as the detail endpoint below. firsBusinessId always equals nrsIdentity.nrsBusinessId; gatewaySubBusinessId can differ only when a reviewed migration has advanced the external NRS mapping.

Read a represented business back

Use the immutable gatewaySubBusinessId to read one stored mapping without authenticating at NRS again:

curl \
  --url https://acme.invoice.tryduplo.com/sub-businesses/nrs-business-demo \
  --header 'x-api-key: pk_test_replace_with_your_key'

Do not send x-sub-business-id, x-idempotency-key, a request body, or NRS credentials. The path already selects the represented business, while the approved host and x-api-key establish its parent tenant and current test/live state. This read is unbilled and makes no NRS request.

Represented-business information response
{
  "gatewaySubBusinessId": "nrs-business-demo",
  "onboardingIntentId": "33333333-3333-4333-8333-333333333333",
  "domain": "acme",
  "businessState": "test",
  "modeVersion": 1,
  "status": "active",
  "profile": {
    "name": "Acme Retail",
    "legalName": "Acme Retail Limited",
    "email": "finance@acmeretail.example",
    "phoneNumber": "+2348000000000",
    "invoiceNumberPrefix": "ACME"
  },
  "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}}-2A3A045D-{{YYYYMMDD}}"
  },
  "crypto": {
    "configured": false,
    "version": 0,
    "updatedAt": null
  },
  "createdAt": "2026-09-15T12:34:56.789Z",
  "updatedAt": "2026-09-15T12:34:56.789Z"
}

profile contains the optional local values you supplied during onboarding; a member is null when it was omitted from the original request. It is not NRS-verified identity. crypto reports only whether optional QR material exists and its storage version—the certificate and public key are never returned. createdAt and updatedAt describe the Duplo mapping, not the age of the NRS account.

The nrsIdentity object is the currently active persisted identity version. It begins with the onboarding observation and advances only through the reviewed reverification workflow. This endpoint does not itself prove that NRS has not changed again since the last observation. Use identity reverification when you need a fresh regulator view; do not silently overwrite IDs, TIN, active state, name, reference, or IRN template in your own integration.

An unknown identifier, another tenant's identifier, an inactive mapping, or a mapping outside the approved domain's current state and mode version all return the same 404. This prevents the endpoint from revealing whether another tenant holds an identifier.

Migrate a previously returned Duplo UUID

Older onboarding responses returned a Duplo UUID as gatewaySubBusinessId. During the compatibility window, you can still supply that UUID to the detail path, x-sub-business-id, the QR-material path, or the Stored transmission body's gatewaySubBusinessId. It is a deprecated lookup alias only: current onboarding, detail, list, invoice, and QR-material responses return the immutable Gateway identity allocated for the mapping. Newly created webhook events also contain that stable ID; a delivery already queued before this migration can still contain the historical UUID.

Use GET /sub-businesses/{oldUuid} or GET /sub-businesses to obtain the immutable gatewaySubBusinessId, replace the UUID in your durable mapping and future requests, and stop validating this field as a UUID. Do not onboard the business again merely to migrate the identifier. Duplo will announce the end of the compatibility window separately.

Finish or abandon an in-flight command before changing the identifier stored with it. Retry a pre-migration Stored intake with the same old UUID and idempotency key; use the canonical ID for new commands and new idempotency keys.

Mode changes and test/live changes are different

A completed mode-only change carries active represented-business mappings forward to the new mode version, so keep their canonical gatewaySubBusinessId values and do not onboard them again. After reapproval, however, every invoice must follow the newly approved mode's body, response, persistence, and transmission process; Pass-through and Stored cannot be selected per request.

A test/live state change is different: the old-state mappings are deactivated and their NRS identity claims are released. Create the target-state API key, complete domain approval there, and onboard each represented business again. NRS may return the same canonical business ID in both states, but equality of that string does not prove that a target-state mapping exists; confirm it through the target hostname and API key's list endpoint.

Safe retry

If the request times out while the intent is likely still within its completion window, make only bounded retries with the same optional profile fields and x-idempotency-key, and include nrsCredentials again. Once onboarding has completed, the same profile and key can return the original gatewaySubBusinessId, firsBusinessId, and nrsIdentity with replayed: true; that successful replay returns HTTP 201 and may omit both nrsCredentials and nrsCryptoKeys.

Changing any profile value under the same key returns 409. An expired or otherwise non-resumable intent can also leave the same key returning 409. Because a 409 after an uncertain response is not safe evidence that onboarding did not commit, stop, preserve the requestId, and contact Duplo Support. Do not create a new key until Duplo confirms the earlier intent did not commit; a new key starts a separate onboarding attempt.

Reverify and review NRS identity changes

Use POST /sub-businesses/{gatewaySubBusinessId}/identity-reverification when you need a fresh NRS observation. Send the parent x-api-key, a stable x-idempotency-key, and transient nrsCredentials in the same shape used for onboarding. Gateway authenticates to NRS, compares the observation with the active stored version, and returns either an unchanged result or a review record. The password is never persisted or forwarded to Spend.

The result classifies changes before anything is applied:

  • additive covers reviewed fields such as registered name, IRN template, or active state. The tenant can apply or reject that review with POST /sub-businesses/{gatewaySubBusinessId}/identity-reviews/{reviewId}/decision, a new idempotency key, and body { "action": "apply" | "reject", "reason": "..." }.
  • protected covers entity ID, NRS business ID, or TIN changes. The public decision route cannot approve these changes and returns 409 GW_IDENTITY_PROTECTED_MIGRATION_REQUIRED if protectedMigration: true is attempted. An authorized Duplo operator must review the evidence and use the separately protected audit boundary.
  • The immutable gatewaySubBusinessId never changes. Applying a protected migration advances only the current NRS mapping, keeps historical external IDs as accepted aliases, and records who approved the version and why.

Read GET /sub-businesses/{gatewaySubBusinessId}/identity-versions before deciding a review or investigating drift. It returns the observation time, changed fields, state (active, superseded, pending_review, or rejected), review request ID/reason, and whether protected migration approval was recorded. This read uses persisted audit history and does not call NRS.

Do not submit invoices while material identity drift is pending review. Never work around a protected review by onboarding a duplicate represented business or by changing only your locally stored NRS ID.

Add or replace optional NRS QR material

You can supply nrsCryptoKeys during onboarding or store it later against the represented-business ID. When the object is present, both values are required.

NRS issues this material, so you do not generate the key pair yourself. In the NRS taxpayer portal, open API Integration, then Manage Cryptographic keys, and choose Generate cryptographic key. Name the key so you can recognize it later, then download the keys file. Despite its .txt extension the file is JSON, and it holds a base64-encoded public_key and certificate. Send certificate exactly as the file gives it. For publicKey, Duplo accepts either that same base64 value or the PEM it decodes to, which NRS extracts with:

Decode the NRS public key to PEM
jq -r '.public_key' crypto_keys.txt | openssl base64 -d -out public-key.pem
curl --request POST \
  --url https://acme.invoice.tryduplo.com/sub-businesses/nrs-business-demo/nrs-crypto-material \
  --header 'content-type: application/json' \
  --header 'x-api-key: pk_test_replace_with_your_key' \
  --header 'x-idempotency-key: qr-material-acme-retail-0001' \
  --data '{
    "nrsCryptoKeys": {
      "certificate": "nrs-issued-certificate-value",
      "publicKey": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n"
    }
  }'

A successful call returns 200, whether this is the first pair stored or a replacement. rotatedAt carries that name in both cases:

First QR-material response
{
  "gatewaySubBusinessId": "nrs-business-demo",
  "cryptoVersion": 1,
  "rotatedAt": "2026-08-31T15:20:36.000Z"
}

At onboarding and replacement time, the Gateway treats both strings as opaque and stores them exactly as supplied. It does not parse an X.509 certificate, check certificate dates, normalize the values, measure RSA key size, or verify a certificate/key match. The QR generator interprets them only when a signed or received invoice needs a QR code: it embeds certificate in the payload and uses publicKey as plain PEM or base64-encoded PEM for RSA encryption. Unusable material can make QR generation fail, but QR generation is best-effort, and it never turns this material into an onboarding, validation, signing, or transmission requirement.

The endpoint re-checks the top-level business, the approved domain's test/live state, and that the represented business belongs to that top-level business. It sends the opaque values to Spend for storage and makes no NRS call. A represented-business ID that belongs to another account and one that does not exist at all both return the same 404, so the response never reveals whether another account holds that ID. Each successful write increments cryptoVersion.

Do not automatically retry QR-material writes

Although this endpoint requires x-idempotency-key, QR-material rotation is not currently deduplicated end to end. If a response is lost or uncertain, sending the same request again can perform another write and increment cryptoVersion again. Do not automatically retry with either the same key or a new key. Preserve the represented-business ID, idempotency key, submitted material, response details, and returned requestId, then contact Duplo Support. Retry only after Duplo confirms that the previous write did not commit.

Continue with Build invoice payloads.

How is this guide?

On this page