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

  1. Bind

    Gateway validates host, state, represented business, IRN, business ID, supplier TIN, and the idempotency key and attempt number identifying this attempt.

  2. Debit

    Duplo resolves the current fee and payer wallet, then records one idempotent billing attempt.

  3. Protect

    Gateway persists recovery context before it is allowed to contact NRS.

  4. Classify

    A valid sign succeeds; a definite rejection reverses; an uncertain outcome enters reconciliation.

OutcomeDid Gateway call NRS?Billing actionClient response
Wallet debit declinedNoNo successful charge402 GW_BILLING_WALLET_DECLINED
Recovery state cannot be persisted after debitNoRecovery or safe reversal is coordinated503 GW_SIGN_RECOVERY_UNAVAILABLE; do not resubmit; contact support
NRS returns valid sign successYesCharge remainsNRS success body, requiring HTTP 2xx, body code: 201, and data.ok: true
NRS definitively rejects signing and the reversal is confirmedYesCharge is reversed422 GW_SIGN_REJECTED; correct the documented cause before a new attempt
NRS rejects signing but reversal is not confirmedYesReversal remains pending or unknown503 GW_SIGN_REVERSAL_UNCONFIRMED; do not resubmit; contact support
Timeout, transport failure, NRS 5xx, malformed body, unexpected 2xx, or NRS 408/409/425/429MaybeCharge is retained for reconciliation502 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.

Definitive sign rejection with confirmed reversal
{
  "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"
}
Unconfirmed sign outcome
{
  "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.

Idempotency key patterns
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-1

Keep the key in the same durable row as your invoice or onboarding command. Never generate it inside a network retry loop.

CommandRequired?Replay behavior
POST /sub-businessesYesnrsCredentials 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=NoThis 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}NoThis 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-materialYesThe 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/signKey recommended; attempt number defaults to 1Keep 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 suppliedThe 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/signYesExact 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}/transmitYesAn 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 resultRetry automatically?Safe action
Represented-business onboarding timed out before completionOnly within a bounded windowResend 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 lostBounded same-key retryReplay 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 outYes, boundedRetry the same GET with backoff. It is read-only, unbilled, and has no idempotency key.
Represented-business list timed outYes, boundedRetry 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 lostNoThe 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 responseUsually, with bounded backoffRetry the same URL; respect rate limits and your total time budget
Unbilled validation returned a definite document errorNoCorrect the invoice, assign a deliberate command/version, then validate again
Unbilled validation had a transport timeoutYes, boundedRe-send the same document; validation does not create a signing debit
Pass-through sign returned normal NRS successNoStore the response and move to lookup/transmission
Pass-through sign returned 402 GW_BILLING_WALLET_DECLINEDNo immediate retryRestore balance or account readiness; once failure is conclusive, use the operation-specific retry guidance
Pass-through sign returned 409 replayNoRecover the original response or verify the IRN; do not create random keys to bypass the guard
Pass-through sign returned 422 GW_SIGN_REJECTEDOnly after correctionReversal is confirmed; correct the cause, then follow the documented new-attempt rules
Pass-through sign returned 503 GW_SIGN_REVERSAL_UNCONFIRMEDNoKeep the original document and identifiers; contact Duplo Support with requestId
Pass-through sign returned 502 GW_SIGN_OUTCOME_UNCONFIRMEDNoKeep the original document and requestId, check the IRN authoritatively, and contact Duplo Support
Pass-through transmit returned 409 GW_TRANSMIT_ATTEMPT_ALREADY_PROCESSEDNoUse operationId to retrieve or reconcile the original transmission; do not create another key
Pass-through transmit returned 502 GW_TRANSMIT_OUTCOME_UNCONFIRMEDNoKeep the original operation and operationId; reconcile the durable claim and authoritative NRS state before any new dispatch
Stored intake timed out before 202 was receivedYesRe-send the exact command with the same idempotency key
Stored intake returned 202No new intakePoll the returned invoice ID
Stored status is still pendingYes, read onlyPoll with bounded exponential backoff and jitter
Transmission failed conclusively before dispatchDo not re-signFollow the returned Problem Details; retry only if it explicitly permits the original transmission identity
Transmission may have reached NRSNoStop 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 statusTypical Gateway meaningFirst action
400Malformed JSON, header, path, query, or identifier; an invalid idempotency key; a server-owned field; or a mismatch between the represented business and the body identityCorrect the request; do not retry unchanged
408Gateway did not receive the complete request body before its read deadlineSend the complete body promptly and preserve the same mutation identity
401The caller's Gateway API key is missing, invalid, expired, or revokedSupply or rotate the Gateway API key; an NRS login error uses 422, not 401
402The signing wallet could not fund or authorize the operationFund or configure the wallet, then follow the code-specific retry guidance
422A semantic prerequisite failed, such as invalid NRS login, inactive NRS identity, billing policy, or a definitive sign rejectionBranch on code, correct the documented prerequisite, and follow retry.strategy
403The 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 NRSStop; repair the tenant mapping or the NRS link
404Domain is unavailable, the represented business or invoice is hidden or absent, or a supplied NRS TIN did not matchVerify host, state, stored IDs, and NRS registration
409State/mode drift, idempotency conflict, an expired or non-resumable onboarding intent, ambiguous active NRS-business selection, Pass-through replay, or operation incompatible with approved modeBranch 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.
413Buffered request exceeds the Gateway limitReduce payload size; do not split one NRS invoice arbitrarily
429Rate limit reachedHonor Retry-After when supplied; otherwise use bounded jittered backoff
502Untrusted dependency response, dependency failure, or an unconfirmed sign outcomeBranch on code; preserve requestId; never blindly retry sign
503Registry, authorization, billing, recovery, or management service unavailableFollow retry.strategy; a sign/reversal state can require polling instead of retrying
508A configured upstream or proxy route sent Gateway's own hop marker back to GatewayDo 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:

  1. Freeze automatic sign retries for that IRN and represented business.
  2. Persist the exact request bytes, IRN, gatewaySubBusinessId, idempotency key, resolved attempt number (1 when omitted), request time, and returned requestId.
  3. Check your own response/archive first. If the original success was stored elsewhere, use it.
  4. 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.
  5. 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:

  1. Freeze transmission retries for that IRN and represented business.
  2. Retain the original requestId, operationId, idempotency key, request time, and response.
  3. Check an authoritative NRS lookup and your own delivery archive, but do not treat an absent optional field as proof of failure.
  4. 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 stable code.
  • A sanitized response body and public operationId whenever 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?

On this page