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

Error response headers
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
Cache-Control: no-store
X-Request-ID: 01991d1c-814d-7a62-8c25-9e0a48bf7322
Invalid API key
{
  "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:

FieldWhat it tells you
codeThe kind of error, as a stable GW_* code. Use it to look up the error in the error code reference.
retry.strategyWhether and how you can retry. See Choose a retry action.
action.descriptionWhat to do next, in plain language.
requestIdGateway's ID for this request. Duplo Support needs it to investigate.
errorsFor 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:

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.

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:

StrategyWhat to do
DO_NOT_RETRYDo 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_CORRECTIONCorrect the indicated input, credential, configuration, or state first, then send the corrected request.
BACKOFFRetry 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_KEYRetry the mutation only with the original idempotency key and the same request content, spacing retries with exponential backoff as for BACKOFF.
POLL_OPERATIONDo 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_SUPPORTDo 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:

Field validation failure
{
  "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:

FieldMeaning
locationWhere the problem is: header, path, query, or body.
fieldName of the header, path parameter, or query parameter. Present for header, path, and query problems.
pointerRFC 6901 JSON Pointer to the body value, such as /invoice_line/0/invoiced_quantity. Present for body problems.
codeOne of REQUIRED, FORMAT, TYPE, RANGE, LENGTH, UNKNOWN_FIELD, MISMATCH, or IMMUTABLE_FIELD.
detailExplanation 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, and code;
  • 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, and gatewaySubBusinessId when relevant, plus any operationId;
  • 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?

On this page