Handle Gateway Error Responses
Recognize NRS Invoice Gateway error responses, decide whether to retry, and handle each failure safely.
When a request to your approved Gateway domain fails, Gateway returns an error response that tells you what failed, what to do next, and whether you can retry. This page shows you how to read that response and how to handle it in your integration. For what each GW_* code means, see the Gateway error code reference.
Gateway error responses follow the RFC 9457 Problem Details standard and use the application/problem+json content type.
What an error response looks like
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
Cache-Control: no-store
X-Request-ID: 01991d1c-814d-7a62-8c25-9e0a48bf7322{
"type": "https://docs.tryduplo.com/invoicing/nrs/errors/GW_API_KEY_INVALID",
"title": "API key is invalid or expired",
"status": 401,
"detail": "The supplied API key could not be authenticated.",
"instance": "urn:duplo:request:01991d1c-814d-7a62-8c25-9e0a48bf7322",
"code": "GW_API_KEY_INVALID",
"requestId": "01991d1c-814d-7a62-8c25-9e0a48bf7322",
"timestamp": "2026-09-15T12:34:56.789Z",
"action": {
"code": "ROTATE_API_KEY",
"description": "Create or rotate the API key in Duplo Spend, then retry.",
"documentation": "https://docs.tryduplo.com/invoicing/nrs/errors/GW_API_KEY_INVALID"
},
"retry": {
"strategy": "AFTER_CORRECTION"
},
"message": "The supplied API key could not be authenticated.",
"statusCode": 401,
"path": "/api/v1/invoice/sign"
}To handle an error, you need only these fields:
| Field | What it tells you |
|---|---|
code | The kind of error, as a stable GW_* code. Use it to look up the error in the error code reference. |
retry.strategy | Whether and how you can retry. See Choose a retry action. |
action.description | What to do next, in plain language. |
requestId | Gateway's ID for this request. Duplo Support needs it to investigate. |
errors | For invalid request values, the fields to correct. See Fix validation failures. |
Write your error-handling logic to read code and retry.strategy and decide what to do from those two values. You can show title and detail to users, but never make decisions by matching their English text, because Duplo can reword it at any time.
Handle an error response
Follow these steps whenever a Gateway request fails. In these steps, a mutation is a request that creates or changes something, such as signing, transmitting, onboarding, or QR-material rotation. A read only retrieves data and never changes it.
Confirm the response is a Gateway error
Treat a response as a Gateway error only when its content type is application/problem+json. A timeout, TLS or DNS failure, or a CDN or load-balancer error can happen before the request reaches Gateway, so there is no code to read.
When no Gateway error response arrived:
- Retry a read with backoff.
- For a mutation, the request may already have taken effect. Follow the retry decision table for that operation.
- Never retry QR-material rotation automatically; see Exceptions to the returned strategy.
A 2xx response is not an error. For example, 202 Accepted from Stored intake means the invoice was accepted; poll GET /invoices/{invoiceId} as described in Poll the invoice.
Save the request ID
Read requestId from the body, or the X-Request-ID response header, which has the same value. Store it with your own record of the request, including the idempotency key and attempt number for a mutation, and the request time.
If you send your own x-request-id header, Gateway treats it only as your correlation value. It is not the Gateway request ID, and Duplo Support cannot use it to find the request.
Check for an exception
For two operations, this page overrides what retry.strategy says: QR-material rotation, and GW_IDEMPOTENCY_CONTENT_CONFLICT on onboarding or rotation. Check Exceptions to the returned strategy before you act on the strategy.
Act on the retry strategy
Do what Choose a retry action describes for the returned retry.strategy. When the strategy requires a retry with the same idempotency key, keep both the key and the request content unchanged.
Correct the request when asked
When the strategy is AFTER_CORRECTION, find the code in the error code reference and follow its action. For GW_REQUEST_VALIDATION_FAILED, correct each field listed in errors. If the correction changes a mutation's content, use a new idempotency key, as described in Choose a retry action.
Contact Duplo Support if the action does not resolve it
Send the details listed in Contact Duplo Support.
This function applies steps 1 to 4 and returns the next step for your integration to take:
// Decide the next step after a Gateway request fails.
// `error` is the axios error; `request` describes what you sent.
const decideNextStep = (error, request) => {
const response = error.response;
// Step 1: no response, or a response that is not a Gateway error
if (!response) {
return {
next: request.isQrMaterialRotation
? "contact-support"
: "no-gateway-response",
};
}
const contentType = response.headers["content-type"] ?? "";
if (!contentType.startsWith("application/problem+json")) {
return { next: "no-gateway-response" };
}
const problem = response.data;
// Step 2: keep the request ID with your own record of the request
console.error(
"Gateway error",
problem.code,
"request ID:",
problem.requestId,
);
// Step 3: documented exceptions take precedence over retry.strategy
if (request.isQrMaterialRotation && response.status >= 500) {
return { next: "contact-support", requestId: problem.requestId };
}
if (
(request.isOnboarding || request.isQrMaterialRotation) &&
problem.code === "GW_IDEMPOTENCY_CONTENT_CONFLICT"
) {
return { next: "contact-support", requestId: problem.requestId };
}
// Step 4: act on the returned strategy
const { strategy, sameIdempotencyKey, afterSeconds } = problem.retry;
switch (strategy) {
case "BACKOFF":
return {
next: "retry-with-backoff",
keepIdempotencyKey: sameIdempotencyKey === true,
retryAfter: response.headers["retry-after"] ?? afterSeconds,
};
case "SAME_IDEMPOTENCY_KEY":
return { next: "retry-with-backoff", keepIdempotencyKey: true };
case "AFTER_CORRECTION":
return {
next: "correct-request",
keepIdempotencyKey: sameIdempotencyKey === true,
violations: problem.errors ?? [],
};
case "DO_NOT_RETRY":
return { next: "stop" };
default:
// CONTACT_SUPPORT, POLL_OPERATION, or a strategy your code does not recognize
return { next: "contact-support", requestId: problem.requestId };
}
};Choose a retry action
retry.strategy is always present and has one of these values:
| Strategy | What to do |
|---|---|
DO_NOT_RETRY | Do not send the same request again. A different request that follows the action, such as one to a correct endpoint or with a correct IRN, is allowed. |
AFTER_CORRECTION | Correct the indicated input, credential, configuration, or state first, then send the corrected request. |
BACKOFF | Retry the request with exponential backoff: double the wait after each attempt, cap the wait and the total number of attempts, and add a random delay (jitter). Wait at least the Retry-After header value, or retry.afterSeconds when no header is present. See A practical backoff policy. |
SAME_IDEMPOTENCY_KEY | Retry the mutation only with the original idempotency key and the same request content, spacing retries with exponential backoff as for BACKOFF. |
POLL_OPERATION | Do not resubmit the mutation. Poll only when the response and endpoint documentation provide a public status URL. No GW_* code currently returns this strategy. |
CONTACT_SUPPORT | Do not resubmit a mutation. Contact Duplo Support with requestId. |
A 5xx status does not always mean retry
For signing, transmission, and other mutations, a timeout or 5xx can mean
the outcome is unknown rather than failed, and the request may already have
taken effect. Follow the returned strategy, not the HTTP status. Never
resubmit a mutation when the strategy is POLL_OPERATION or
CONTACT_SUPPORT. Gateway has no public operation-status endpoint, so an
ambiguous sign or transmission is reconciled through Duplo Support, not by
replaying or polling it.
Keep or replace the idempotency key. When retry.sameIdempotencyKey is true, any permitted retry must keep the original idempotency key and send the same request content. When a correction changes a mutation's content, send it as new work with a new key: reusing the original key with different content returns GW_IDEMPOTENCY_CONTENT_CONFLICT. A new Pass-through sign attempt also needs the next x-attempt-number; see Design stable idempotency keys.
Exceptions to the returned strategy
For the following cases, do what this section says, even when retry.strategy says otherwise.
Exception: QR-material rotation
Do not automatically retry POST /sub-businesses/{gatewaySubBusinessId} /nrs-crypto-material after a timeout, lost response, or uncertain 5xx, even
when the returned error says to retain the same idempotency key. The current
rotation path does not deduplicate that retry end to end, so an identical
replay can be recorded as another replacement and increment cryptoVersion
again. Stop and contact Duplo Support with any returned requestId; if no
response arrived, provide the represented-business ID, idempotency key, and
request time. Retry only after Duplo confirms that the earlier replacement was
not saved.
Exception: 409 responses to onboarding and QR-material rotation
For POST /sub-businesses and POST /sub-businesses/{gatewaySubBusinessId} /nrs-crypto-material, the current Gateway can return
GW_IDEMPOTENCY_CONTENT_CONFLICT for several other 409 conditions: an
expired onboarding intent, a consumed proof, an already claimed NRS identity,
or a stale rotation proof. If you receive this code after sending content you
believe is identical, do not start a new operation or loop on the request.
Contact Duplo Support with requestId, the idempotency key, and the
represented-business or onboarding identifiers so Duplo can first confirm
whether the earlier request took effect.
Fix validation failures
When request values are invalid, Gateway returns GW_REQUEST_VALIDATION_FAILED and may include an errors array that lists each problem:
{
"type": "https://docs.tryduplo.com/invoicing/nrs/errors/GW_REQUEST_VALIDATION_FAILED",
"title": "Request validation failed",
"status": 400,
"detail": "One or more request values are invalid.",
"instance": "urn:duplo:request:01991d1c-814d-7a62-8c25-9e0a48bf7322",
"code": "GW_REQUEST_VALIDATION_FAILED",
"requestId": "01991d1c-814d-7a62-8c25-9e0a48bf7322",
"timestamp": "2026-09-15T12:34:56.789Z",
"action": {
"code": "CORRECT_REQUEST",
"description": "Correct the listed request values and submit the request again.",
"documentation": "https://docs.tryduplo.com/invoicing/nrs/errors/GW_REQUEST_VALIDATION_FAILED"
},
"retry": {
"strategy": "AFTER_CORRECTION"
},
"errors": [
{
"location": "header",
"field": "x-sub-business-id",
"code": "FORMAT",
"detail": "Copy the immutable represented-business ID returned as gatewaySubBusinessId without changing it."
}
],
"message": "One or more request values are invalid.",
"statusCode": 400,
"path": "/api/v1/invoice/sign"
}Each item in errors has these fields:
| Field | Meaning |
|---|---|
location | Where the problem is: header, path, query, or body. |
field | Name of the header, path parameter, or query parameter. Present for header, path, and query problems. |
pointer | RFC 6901 JSON Pointer to the body value, such as /invoice_line/0/invoiced_quantity. Present for body problems. |
code | One of REQUIRED, FORMAT, TYPE, RANGE, LENGTH, UNKNOWN_FIELD, MISMATCH, or IMMUTABLE_FIELD. |
detail | Explanation of at most 256 characters. It never repeats the rejected value. |
Correct every listed problem before retrying. Gateway lists at most 20, so a corrected request can still fail with problems that were not listed the first time. Gateway never returns secret values or raw validator exceptions.
Look up an error code
When you need to know what a specific code means and what to do about it, find it in the Gateway error code reference. Each entry gives the meaning, the required action, and the retry strategy Gateway returns.
Contact Duplo Support
When the documented action does not resolve the problem, send Duplo Support:
- the returned
requestId, HTTP status, andcode; - the UTC
timestamp, your approved Gateway hostname, and whether you were in test or live state; - your domain's mode (Pass-through or Stored) and mode version;
- the Duplo
invoiceId, IRN, andgatewaySubBusinessIdwhen relevant, plus anyoperationId; - the idempotency key and attempt number for a mutation;
- for a sign, whether you observed a wallet movement and any authoritative NRS lookup result; and
- a sanitized response body.
Keep secrets out of support requests
Never send passwords, private keys, complete API keys, raw authorization headers, or unredacted personal data. Do not send full invoice line data unless Duplo asks for it through an approved secure channel. Gateway errors intentionally omit internal service names, stack traces, database details, wallet identifiers, and raw NRS responses.
Responses that do not use this format
The GW_* codes cover failures that Gateway creates itself: route validation, authentication, authorization, billing, managed invoice operations, and failures from NRS or internal services that Gateway translates into a code listed in the error code reference.
A supported direct NRS route passes through the NRS status, content type, and body only when NRS succeeds with a 2xx response. When NRS fails, Gateway discards the NRS body and returns a GW_* error instead, so do not rely on NRS error text in your integration.
Failures that happen before a request reaches Gateway, such as TLS, DNS, CDN, or load-balancer errors, never use this format. Handle them as described in Confirm the response is a Gateway error.
How is this guide?