Billing and Safe Retries
Understand the signing charge, definitive reversals, ambiguous outcomes, idempotency, and production retry rules.
Invoice signing is the only billable Gateway operation. Your top-level business in Duplo Dashboard pays the current NGN signing fee for every represented business on its approved domain. The caller cannot choose the payer wallet, fee amount, fee destination, state, or mode.
Validation, NRS resource reads, entity and recipient lookups, health checks, transmission, represented-business onboarding and information reads, and Stored invoice reads do not create a Gateway signing charge.
The precise charging promise
Only invoice signing is billable. A definitive NRS rejection starts an automatic compensating reversal. If NRS may have committed the sign but Duplo cannot prove the outcome, the charge is retained temporarily for authoritative reconciliation. This avoids reversing a real sign and then charging again for a duplicate.
Pass-through billing sequence
Bind
Gateway validates host, state, represented business, IRN, business ID, supplier TIN, and the idempotency key and attempt number identifying this attempt.
Debit
Duplo resolves the current fee and payer wallet, then records one idempotent billing attempt.
Protect
Gateway persists recovery context before it is allowed to contact NRS.
Classify
A valid sign succeeds; a definite rejection reverses; an uncertain outcome enters reconciliation.
| Outcome | Did Gateway call NRS? | Billing action | Client response |
|---|---|---|---|
| Wallet debit declined | No | No successful charge | 402 GW_BILLING_WALLET_DECLINED |
| Recovery state cannot be persisted after debit | No | Recovery or safe reversal is coordinated | 503 GW_SIGN_RECOVERY_UNAVAILABLE; do not resubmit; contact support |
| NRS returns valid sign success | Yes | Charge remains | NRS success body, requiring HTTP 2xx, body code: 201, and data.ok: true |
| NRS definitively rejects signing and the reversal is confirmed | Yes | Charge is reversed | 422 GW_SIGN_REJECTED; correct the documented cause before a new attempt |
| NRS rejects signing but reversal is not confirmed | Yes | Reversal remains pending or unknown | 503 GW_SIGN_REVERSAL_UNCONFIRMED; do not resubmit; contact support |
| Timeout, transport failure, NRS 5xx, malformed body, unexpected 2xx, or NRS 408/409/425/429 | Maybe | Charge is retained for reconciliation | 502 GW_SIGN_OUTCOME_UNCONFIRMED; do not resubmit; contact support |
The public error is intentionally bounded so dependency details and internal billing identifiers do not leak.
{
"type": "https://docs.tryduplo.com/invoicing/nrs/errors/GW_SIGN_REJECTED",
"title": "Invoice signing was rejected",
"status": 422,
"detail": "The regulator rejected signing and compensation was confirmed.",
"instance": "urn:duplo:request:01991d1c-814d-7a62-8c25-9e0a48bf7322",
"code": "GW_SIGN_REJECTED",
"requestId": "01991d1c-814d-7a62-8c25-9e0a48bf7322",
"timestamp": "2026-09-15T12:34:56.789Z",
"action": {
"code": "CORRECT_SIGNING_REQUEST",
"description": "Correct the documented validation issue before starting a new attempt.",
"documentation": "https://docs.tryduplo.com/invoicing/nrs/errors/GW_SIGN_REJECTED"
},
"retry": {
"strategy": "AFTER_CORRECTION"
},
"message": "The regulator rejected signing and compensation was confirmed.",
"statusCode": 422,
"path": "/api/v1/invoice/sign"
}{
"type": "https://docs.tryduplo.com/invoicing/nrs/errors/GW_SIGN_OUTCOME_UNCONFIRMED",
"title": "Signing outcome is unconfirmed",
"status": 502,
"detail": "The regulator result could not be confirmed and signing may have occurred.",
"instance": "urn:duplo:request:01991d1c-814d-7a62-8c25-9e0a48bf7322",
"code": "GW_SIGN_OUTCOME_UNCONFIRMED",
"requestId": "01991d1c-814d-7a62-8c25-9e0a48bf7322",
"timestamp": "2026-09-15T12:34:56.789Z",
"action": {
"code": "CHECK_OPERATION_STATUS",
"description": "Do not resubmit or use a new key; contact support with the request ID.",
"documentation": "https://docs.tryduplo.com/invoicing/nrs/errors/GW_SIGN_OUTCOME_UNCONFIRMED"
},
"retry": {
"strategy": "CONTACT_SUPPORT"
},
"message": "The regulator result could not be confirmed and signing may have occurred.",
"statusCode": 502,
"path": "/api/v1/invoice/sign"
}These are Gateway-generated failures and use application/problem+json. An eligible direct NRS call preserves an NRS-owned response only when NRS succeeds with 2xx; Gateway discards every NRS non-2xx body and maps it to a safe GW_* failure. See Handle Gateway error responses for handling rules and the Gateway error code reference for every stable code.
Pass-through transmission safety
Transmission is not billed, but it is still an external mutation. Before POST /api/v1/invoice/transmit/{irn} can reach NRS, Gateway obtains a durable Spend claim bound to the tenant, state, domain, represented business, IRN, idempotency identity, request hash, and a public operationId. Only a newly claimed operation may be dispatched to NRS.
An existing claim can resolve as in_progress, succeeded, rejected, or unknown. Gateway must not call NRS again for any of those dispositions. An in-progress or recorded outcome returns 409 GW_TRANSMIT_ATTEMPT_ALREADY_PROCESSED with the original operationId. A timeout, transport break, provider 5xx, malformed result, or expired in-flight claim that cannot prove the NRS outcome returns 502 GW_TRANSMIT_OUTCOME_UNCONFIRMED with that operation ID.
Do not replay an unconfirmed transmission
Preserve the IRN, represented-business ID, original idempotency key,
requestId, and operationId. Do not retry transmission with the same key,
a new key, or another Gateway replica until Duplo has reconciled the durable
claim against NRS.
Stored-mode billing
202 Accepted in Stored mode is not a billing event. It means the command and invoice record were accepted for asynchronous processing.
The processor validates the invoice first, then creates the signing debit immediately before NRS signing. It records the resulting NRS and billing state on the Stored invoice. A validation failure before sign is not billed. Poll GET /invoices/{invoiceId} until the record is terminal; do not infer sign success from the intake response or elapsed time.
Inspect firsRejectionReason whenever Stored processing ends at created / null after validation or rejected / rejected after signing/billing work. Keep the invoice ID and request trace. Do not create a replacement until you understand whether the original IRN may already exist; the current Gateway does not expose an edit/resubmit command for that record.
Design stable idempotency keys
Use one key for one logical command. Keys on Gateway management commands must be 8 to 128 characters long and use only letters, digits, dots, underscores, colons, and hyphens.
onboard:<customer-id>:v1
qr-material:<gateway-sub-business-id>:2026-08
stored-sign:<erp-invoice-id>:v1
stored-transmit:<duplo-invoice-id>:v1
pass-sign:<erp-invoice-id>:attempt-1Keep the key in the same durable row as your invoice or onboarding command. Never generate it inside a network retry loop.
| Command | Required? | Replay behavior |
|---|---|---|
POST /sub-businesses | Yes | nrsCredentials is required while the intent is new or pending within its completion window, currently about 30 minutes by default. A completed replay returns the original gatewaySubBusinessId, firsBusinessId, and durable nrsIdentity snapshot with replayed: true; the public route currently returns HTTP 201 for that replay. A pending intent can become non-resumable, and changing profile content also returns 409. After an uncertain result followed by 409, stop and contact support; use a new key only after confirmation that the old intent did not commit. |
GET /sub-businesses?page=&limit= | No | This repeatable, paginated, unbilled read returns active stored mappings in the authenticated parent/domain/state/current-mode scope. It makes no NRS request. |
GET /sub-businesses/{gatewaySubBusinessId} | No | This repeatable, unbilled read returns the stored active mapping and onboarding-time NRS identity. It makes no NRS request and does not refresh changed regulator data. |
POST /sub-businesses/{id}/nrs-crypto-material | Yes | The key is required, but rotation is not currently deduplicated end to end. Repeating the same request can write again and increment cryptoVersion; never automatically retry an uncertain or lost response. Contact support before another attempt. |
Pass-through POST /api/v1/invoice/sign | Key recommended; attempt number defaults to 1 | Keep x-idempotency-key and positive x-attempt-number together; a successful billing replay is blocked with 409 because the original NRS body is not retained |
Pass-through POST /api/v1/invoice/transmit/{irn} | Standard Idempotency-Key or legacy x-idempotency-key is optional in the current version; retain one whenever supplied | The durable claim binds the tenant/domain/represented business/IRN and request hash. Only claimed dispatches to NRS; unknown is never replayed automatically. If both header names are sent they must match. A future API version can require the standard header. |
Stored POST /api/v1/invoice/sign | Yes | Exact logical document reuses the accepted command; changed content or changing the represented-business identifier under the key conflicts. A pre-migration retry keeps its old UUID alias; new commands use the immutable gatewaySubBusinessId. |
POST /invoices/{id}/transmit | Yes | An identical Stored transmission command is deduplicated; a changed represented-business or invoice binding conflicts |
For Stored intake, idempotency also binds the 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; changing a value or reordering an array does.
Persist the onboarding idempotency key, immutable gatewaySubBusinessId, and complete current nrsIdentity together. Use gatewaySubBusinessId in Gateway headers and represented-business paths. Use firsBusinessId / nrsIdentity.nrsBusinessId as the current NRS business_id in future Pass-through invoice bodies. These values initially match, but a reviewed migration can advance the NRS mapping without changing the Gateway ID. A completed replay, the list endpoint, and GET /sub-businesses/{gatewaySubBusinessId} must return the same active identity version. If identity fields change without a corresponding reviewed version, stop and contact support instead of signing.
For Pass-through signing, Duplo also identifies the billing attempt by business, domain, IRN, and x-attempt-number. Re-sending the same logical attempt keeps both headers unchanged. After a conclusively failed or reversed attempt, a separately authorized attempt must use the next positive attempt number and a new stable idempotency key. Changing only the key can still return 409. Never advance either value for an unconfirmed outcome.
Retry decision table
| Operation and result | Retry automatically? | Safe action |
|---|---|---|
| Represented-business onboarding timed out before completion | Only within a bounded window | Resend the same optional profile fields with the same idempotency key and include nrsCredentials. If a retry returns 409, stop and contact support; do not create a new key until Duplo confirms the old intent did not commit. |
| Completed onboarding response was lost | Bounded same-key retry | Replay the same optional profile fields with the same idempotency key. A successful completed replay currently returns HTTP 201 with replayed: true. If the result is 409, treat it as ambiguous and contact support. |
| Represented-business information read timed out | Yes, bounded | Retry the same GET with backoff. It is read-only, unbilled, and has no idempotency key. |
| Represented-business list timed out | Yes, bounded | Retry the same page and limit with backoff. Do not advance the page until that response succeeds. |
| QR-material write timed out or its response was lost | No | The current rotation path is not end-to-end deduplicated. Preserve the key, represented-business ID, submitted material, and requestId; contact support before retrying because another request can increment cryptoVersion again. |
NRS GET read failed before a response | Usually, with bounded backoff | Retry the same URL; respect rate limits and your total time budget |
| Unbilled validation returned a definite document error | No | Correct the invoice, assign a deliberate command/version, then validate again |
| Unbilled validation had a transport timeout | Yes, bounded | Re-send the same document; validation does not create a signing debit |
| Pass-through sign returned normal NRS success | No | Store the response and move to lookup/transmission |
Pass-through sign returned 402 GW_BILLING_WALLET_DECLINED | No immediate retry | Restore balance or account readiness; once failure is conclusive, use the operation-specific retry guidance |
Pass-through sign returned 409 replay | No | Recover the original response or verify the IRN; do not create random keys to bypass the guard |
Pass-through sign returned 422 GW_SIGN_REJECTED | Only after correction | Reversal is confirmed; correct the cause, then follow the documented new-attempt rules |
Pass-through sign returned 503 GW_SIGN_REVERSAL_UNCONFIRMED | No | Keep the original document and identifiers; contact Duplo Support with requestId |
Pass-through sign returned 502 GW_SIGN_OUTCOME_UNCONFIRMED | No | Keep the original document and requestId, check the IRN authoritatively, and contact Duplo Support |
Pass-through transmit returned 409 GW_TRANSMIT_ATTEMPT_ALREADY_PROCESSED | No | Use operationId to retrieve or reconcile the original transmission; do not create another key |
Pass-through transmit returned 502 GW_TRANSMIT_OUTCOME_UNCONFIRMED | No | Keep the original operation and operationId; reconcile the durable claim and authoritative NRS state before any new dispatch |
Stored intake timed out before 202 was received | Yes | Re-send the exact command with the same idempotency key |
Stored intake returned 202 | No new intake | Poll the returned invoice ID |
| Stored status is still pending | Yes, read only | Poll with bounded exponential backoff and jitter |
| Transmission failed conclusively before dispatch | Do not re-sign | Follow the returned Problem Details; retry only if it explicitly permits the original transmission identity |
| Transmission may have reached NRS | No | Stop on GW_TRANSMIT_OUTCOME_UNCONFIRMED; never infer failure from a timeout or missing response |
A practical backoff policy
For read-only polling, start around one second, double up to a reasonable ceiling such as 30 seconds, add jitter, and enforce a total workflow deadline. Retry-After, when supplied, takes precedence. Stop on a terminal invoice state or a customer-visible 4xx that requires input.
Do not apply this generic policy to a Pass-through sign. A transport error after a debit is not evidence that NRS did nothing.
Status guide
| HTTP status | Typical Gateway meaning | First action |
|---|---|---|
400 | Malformed JSON, header, path, query, or identifier; an invalid idempotency key; a server-owned field; or a mismatch between the represented business and the body identity | Correct the request; do not retry unchanged |
408 | Gateway did not receive the complete request body before its read deadline | Send the complete body promptly and preserve the same mutation identity |
401 | The caller's Gateway API key is missing, invalid, expired, or revoked | Supply or rotate the Gateway API key; an NRS login error uses 422, not 401 |
402 | The signing wallet could not fund or authorize the operation | Fund or configure the wallet, then follow the code-specific retry guidance |
422 | A semantic prerequisite failed, such as invalid NRS login, inactive NRS identity, billing policy, or a definitive sign rejection | Branch on code, correct the documented prerequisite, and follow retry.strategy |
403 | The API key does not own the represented business, an IRN is not bound to it, or Duplo is not that business's Access Point Provider in NRS | Stop; repair the tenant mapping or the NRS link |
404 | Domain is unavailable, the represented business or invoice is hidden or absent, or a supplied NRS TIN did not match | Verify host, state, stored IDs, and NRS registration |
409 | State/mode drift, idempotency conflict, an expired or non-resumable onboarding intent, ambiguous active NRS-business selection, Pass-through replay, or operation incompatible with approved mode | Branch on code. After an uncertain onboarding result, stop mutation retries and contact support; use a new key only after confirmation that the old intent did not commit. |
413 | Buffered request exceeds the Gateway limit | Reduce payload size; do not split one NRS invoice arbitrarily |
429 | Rate limit reached | Honor Retry-After when supplied; otherwise use bounded jittered backoff |
502 | Untrusted dependency response, dependency failure, or an unconfirmed sign outcome | Branch on code; preserve requestId; never blindly retry sign |
503 | Registry, authorization, billing, recovery, or management service unavailable | Follow retry.strategy; a sign/reversal state can require polling instead of retrying |
508 | A configured upstream or proxy route sent Gateway's own hop marker back to Gateway | Do not retry; contact support so routing can be corrected |
An ordinary proxied NRS route can return an NRS status and body that is not in this table. Protected and management routes can return a Gateway error instead. See NRS API response ownership.
Reconcile an unconfirmed sign
When a Pass-through sign returns 502 GW_SIGN_OUTCOME_UNCONFIRMED:
- Freeze automatic sign retries for that IRN and represented business.
- Persist the exact request bytes, IRN,
gatewaySubBusinessId, idempotency key, resolved attempt number (1when omitted), request time, and returnedrequestId. - Check your own response/archive first. If the original success was stored elsewhere, use it.
- Use an authoritative NRS lookup appropriate to the signed IRN and preserve that response. NRS owns the lookup schema, so do not treat a missing optional field as proof that signing failed.
- Contact Duplo with the support packet below. Wait for the billing/NRS outcome before starting a new sign attempt.
Do not send internal billing identifiers; the public requestId is sufficient for Duplo to correlate the attempt.
Reconcile an unconfirmed transmission
When a Pass-through transmission returns 502 GW_TRANSMIT_OUTCOME_UNCONFIRMED:
- Freeze transmission retries for that IRN and represented business.
- Retain the original
requestId,operationId, idempotency key, request time, and response. - Check an authoritative NRS lookup and your own delivery archive, but do not treat an absent optional field as proof of failure.
- Contact Duplo so the durable transmission claim can be reconciled. Do not create a new key while its disposition is
unknown.
Support packet
Provide:
- Gateway hostname and test/live state, but never the complete API key.
- Returned UTC
timestamp,requestId, HTTP status, and stablecode. - A sanitized response body and public
operationIdwhenever the response contains one. - IRN and
gatewaySubBusinessId. - Idempotency key and attempt number.
- Mode and mode version shown in your approved domain configuration.
- Whether you observed a wallet movement and any authoritative NRS lookup result.
Remove passwords, private keys, Duplo Dashboard secret keys, full API keys, customer personal data, and raw invoice line data unless Duplo asks for it through an approved secure channel.
How is this guide?