> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cbpayapp.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Destination rejection protection and safe payout retries

> Handle recent bank rejections without creating duplicate payouts

export const EnvUrls = ({lang = "en"}) => {
  const T = {
    en: {
      test: "Test",
      live: "Live",
      hint: "Same API on both environments — build against test first, then go live by swapping the base URL and the key.",
      guide: "Environments and testing",
      href: "/en/environment-testing"
    },
    es: {
      test: "Test",
      live: "Live",
      hint: "La misma API en ambos ambientes — construye primero contra test y pasa a live cambiando la URL base y la key.",
      guide: "Entorno y pruebas",
      href: "/es/environment-testing"
    },
    zh: {
      test: "Test",
      live: "Live",
      hint: "两个环境的 API 完全一致——先在 test 环境构建，再通过切换基础 URL 和密钥上线。",
      guide: "环境与测试",
      href: "/zh/environment-testing"
    }
  };
  const t = T[lang] || T.en;
  const row = (label, url, keyPattern, badgeCls) => <div className="flex flex-wrap items-center gap-2 px-3 py-2">
      <span className={"rounded px-1.5 py-0.5 text-xs font-semibold uppercase tracking-wide " + badgeCls}>
        {label}
      </span>
      <code className="text-xs">{url}</code>
      <span className="text-xs text-gray-400 dark:text-zinc-500">·</span>
      <code className="text-xs">{keyPattern}</code>
    </div>;
  return <div className="my-4 rounded-xl border border-gray-200 dark:border-zinc-700 divide-y divide-gray-200 dark:divide-zinc-700 text-sm not-prose">
      {row(t.test, "https://cryptobank.qbank.cl/platform", "pk_test_...", "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300")}
      {row(t.live, "https://api.qbank.cl/platform", "pk_...", "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300")}
      <p className="px-3 py-2 text-xs text-gray-500 dark:text-zinc-400 m-0">
        {t.hint} <a href={t.href}>{t.guide} →</a>
      </p>
    </div>;
};

<EnvUrls lang="en" />

See the [public error catalog](/en/errors) for the complete error contract.

## What this protection does

Some bank corridors protect a destination after a recent bank rejection. When
the same normalized destination was rejected recently, CBPay stops the next
create before it creates a payout, debits the balance, or consumes the
idempotency key.

The protection is scoped to the account and the normalized combination of
`bank_code`, `account_number`, and `tax_id`. Changing capitalization or
formatting does not bypass it.

```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}}
sequenceDiagram
  autonumber
  participant App as Your app
  participant CB as CBPay
  participant Bank as Bank rail
  App->>CB: POST payout (key K1)
  CB->>CB: Check normalized destination
  alt Recent rejection found
    CB-->>App: 422 destination_recently_rejected
    Note over CB: No payout, debit, or key consumption
  else No recent rejection
    CB->>Bank: Dispatch payout
    Bank-->>CB: Final or ambiguous result
    CB-->>App: Payout state
  end
```

## Safe sequence

1. If the first response is ambiguous, replay the exact request with the same
   `idempotency_key`. This returns the original resource with
   `idempotency_hit: true`; it never creates a second payout.
2. If the response is `destination_recently_rejected`, the original key was
   not consumed. Review the bank rejection and decide whether the destination
   should be retried.
3. A deliberate retry uses a **new** idempotency key and
   `options.destination_retry_ack` set to the most recent rejected payout ID.
4. Reusing the old key with changed options returns
   `409 idempotency_conflict`; it never changes the original operation.
5. A destination with no recent rejection, or a payout on another corridor,
   follows its normal validation and dispatch path.

The acknowledgement is an explicit operator decision, not an automatic second
attempt.

## Deliberate retry request

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "country": "CL",
  "currency": "CLP",
  "method": "bank_transfer",
  "amount": "1000.00",
  "beneficiary": {
    "name": "Example Recipient",
    "bank_code": "012",
    "account_number": "123456789",
    "tax_id": "11111111-1"
  },
  "options": {
    "destination_retry_ack": "<prior_case_id>"
  },
  "idempotency_key": "payout-retry-0001"
}
```

The acknowledgement must identify the most recent rejected case for the same
account and normalized destination. Do not copy a case ID from another account
or destination.

For the complete error catalog, see [Errors](/en/errors).

## Response examples

### Recent rejection

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "error": "destination_recently_rejected",
  "message": "destination was rejected by the bank on 2026-01-15 (prior case <prior_case_id>, code <prior_code>); retry with a new idempotency_key and options.destination_retry_ack=<prior_case_id> to confirm"
}
```

The message includes the UTC rejection date, the prior case ID, and the prior
code. The public response is provider-agnostic.

### Changed options with the old key

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "error": "idempotency_conflict",
  "message": "the idempotency key was already used with a different request"
}
```

## States and actions

| Response or state | Meaning | Action |
| - | - | - |
| `422 destination_recently_rejected` | A recent rejection matches the same normalized destination. No payout was created and the key remains available. | Review the rejection; replay unchanged or deliberately retry with a new key plus acknowledgement. |
| `409 idempotency_conflict` | The same key was reused with a different request or options. | Keep the original operation; choose a fresh key only for a genuinely new request. |
| `processing` | The outcome is not final yet. | Read the payout and wait for the final webhook/status; do not create a new payout. |
| `completed` | The payout reached its final successful state. | No retry is needed. |
| `failed` | The payout failed and the debit was handled by the normal failure path. | Read the failure details and correct the request before starting a new operation. |

## FAQ

<AccordionGroup>
  <Accordion title="Does the 422 create a payout?">
    No. The guard runs before payout insertion and before the debit. It also does
    not consume the submitted idempotency key.
  </Accordion>

  <Accordion title="Can I retry with the same key after the 422?">
    Yes, if you are replaying the exact original request. A deliberate retry after
    reviewing the rejection must use a new key and the acknowledgement option.
  </Accordion>

  <Accordion title="Why does changing only options return 409?">
    Request options are part of the idempotency payload. Reusing a key with changed
    options is a different request, so CBPay rejects it instead of silently
    changing the original operation.
  </Accordion>

  <Accordion title="Does this apply to every payout corridor?">
    No. The protection is applied only where the corridor enables it. Other rails
    and destinations without a recent matching rejection follow their existing
    validation and dispatch flow.
  </Accordion>
</AccordionGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.