> ## 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.

# Stored cards & subscriptions

> Save cards with the payer's consent, charge them one-click or without the payer present (MIT) and schedule recurring subscriptions

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/entorno-y-pruebas"
    },
    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" />

The `card` method supports **stored credentials** (the card brands' COF
mandate): your payer saves their card with explicit consent on the first
payment, and afterwards you can offer one-click payment without re-typing
the number — or charge subscriptions and unscheduled amounts yourself
without the payer present. The card number **never exists** in your
integration or on the platform: only an opaque processor reference plus
display data (brand, last 4 digits, expiry) is stored.

<Steps>
  <Step title="Seed: offer to save the card on the first payment">
    Create the `card` payin with `save_card: true` and your payer reference:

    ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
    curl -X POST https://api.qbank.cl/platform/v1/payins \
      -H "Authorization: Bearer <token>" \
      -H "Content-Type: application/json" \
      -d '{
        "country": "BO",
        "currency": "BOB",
        "method": "card",
        "amount": "700.00",
        "save_card": true,
        "payer_reference": "customer-1042",
        "idempotency_key": "topup-7720"
      }'
    ```

    The hosted page shows a **"Save this card for future payments"** checkbox.
    The credential is stored ONLY if the payer ticks it and the 3-D Secure
    payment is approved. On credit you receive the `card_stored` webhook and
    the card appears in your list.
  </Step>

  <Step title="List the payer's cards">
    ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
    curl "https://api.qbank.cl/platform/v1/stored-cards?from=2026-07-01&to=2026-07-20&payer_reference=customer-1042" \
      -H "Authorization: Bearer <token>"
    ```

    ```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
    {
      "page": 1,
      "page_size": 50,
      "stored_cards": [{
        "stored_card_id": "5f0f2c9e-…",
        "payer_reference": "customer-1042",
        "country": "BO",
        "currency": "BOB",
        "brand": "visa",
        "last4": "2701",
        "expiry_month": "12",
        "expiry_year": "2028",
        "status": "active",
        "created_at": "2026-07-20T18:00:00Z"
      }]
    }
    ```
  </Step>

  <Step title="Pay with a saved card (payer present)">
    Create the `card` payin with `stored_card_id`: the page skips card entry,
    shows the saved card (`VISA •••• 2701`) and 3-D Secure still runs — the
    payer only confirms with their bank. The **billing details** the payer
    entered when saving the card are also kept on file: the page applies them
    automatically and shows only a masked summary (name, partial email, city)
    with a "use different details" link in case they want to change them —
    nothing is retyped. On the public checkout page, paying with a saved card
    additionally requires entering the **same cardholder email** it was saved
    with (a mismatch responds `404`).

    ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
    curl -X POST https://api.qbank.cl/platform/v1/payins \
      -H "Authorization: Bearer <token>" \
      -H "Content-Type: application/json" \
      -d '{
        "country": "BO",
        "currency": "BOB",
        "method": "card",
        "amount": "350.00",
        "stored_card_id": "5f0f2c9e-…",
        "idempotency_key": "topup-7721"
      }'
    ```
  </Step>

  <Step title="Recurring / unscheduled charge (payer not present)">
    Charge the card directly — subscriptions (`recurring: true`) or
    unscheduled amounts your customer agreed to:

    ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
    curl -X POST https://api.qbank.cl/platform/v1/stored-cards/5f0f2c9e-…/charges \
      -H "Authorization: Bearer <token>" \
      -H "Content-Type: application/json" \
      -d '{
        "amount": "45.00",
        "description": "Monthly subscription",
        "recurring": true,
        "idempotency_key": "sub-2026-07-cust1042"
      }'
    ```

    Response `201` — an approved charge credits your balance automatically
    (`payin_credited` webhook, same path as any card payin):

    ```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
    {
      "payin_id": "3c5b002c-…",
      "status": "pending",
      "reference": "3c5b002c-…",
      "transaction_id": "7846012604…",
      "note": "charge approved; the balance is credited automatically (payin_credited webhook)"
    }
    ```

    An issuer decline responds `422` with the payin in `failed` and a
    `failure_reason`. A retry with the same `idempotency_key` returns the
    original payin and **never charges twice**.
  </Step>
</Steps>

To revoke a saved card (at the payer's request or on suspicion):
`DELETE /v1/stored-cards/{stored_card_id}` — charges stop working
immediately and you receive `stored_card_revoked`
(`422 stored_card_revoked` if you try to charge it afterwards).

<Warning>
  Charges without the payer present travel **without 3-D Secure** by
  definition of the mandate: the chargeback risk is yours. Charge only what
  your customer explicitly agreed to — the platform persists the seed's
  consent evidence (checkbox, IP and timestamp) for disputes.
</Warning>

## Subscriptions (scheduled recurring charges)

Instead of charging manually every month, let **the platform run the
schedule**: create a subscription on the saved card — the first period is
charged on creation (unless `start_at` is in the future) and the rest fire
automatically per `interval`.

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl -X POST https://api.qbank.cl/platform/v1/subscriptions \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "stored_card_id": "5f0f2c9e-…",
    "amount": "45.00",
    "interval": "monthly",
    "description": "Monthly plan",
    "idempotency_key": "plan-cust1042-monthly"
  }'
```

Response `201` (`first_charge` is present when the first period was charged
on creation):

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "subscription_id": "7a1c9e2d-…",
  "stored_card_id": "5f0f2c9e-…",
  "amount": "45.00",
  "currency": "BOB",
  "interval": "monthly",
  "status": "active",
  "period": 1,
  "next_charge_at": "2026-08-20T18:00:00Z",
  "first_charge": { "outcome": "approved", "payin_id": "3c5b002c-…" }
}
```

* `interval`: `daily`, `weekly`, `monthly` or `yearly`. The day of month is
  kept and clamped to the last day in short months (a plan on the 31st
  charges on Feb 28/29 and returns to the 31st in March).
* `start_at` (optional, future RFC3339): defers the first charge (trial /
  start date); without it, it charges on creation.
* **Dunning**: on an issuer decline the platform retries every 24h up to 3
  times; exhausted, the subscription becomes `past_due` and you get the
  `subscription_status_changed` webhook. `resume` reactivates it with a
  fresh attempt.
* Each successful charge credits your balance like any card payin
  (`payin_credited` webhook, carrying `subscription_id` to link it to the
  plan).

Lifecycle management:

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
# Pause (stops charging; resuming does NOT catch up missed periods)
curl -X POST https://api.qbank.cl/platform/v1/subscriptions/7a1c9e2d-…/pause -H "Authorization: Bearer <token>"
# Resume
curl -X POST https://api.qbank.cl/platform/v1/subscriptions/7a1c9e2d-…/resume -H "Authorization: Bearer <token>"
# Cancel (terminal)
curl -X POST https://api.qbank.cl/platform/v1/subscriptions/7a1c9e2d-…/cancel -H "Authorization: Bearer <token>"
# List / read
curl "https://api.qbank.cl/platform/v1/subscriptions?from=2026-07-01&to=2026-07-31&status=active" -H "Authorization: Bearer <token>"
```

Revoking the saved card (`DELETE /v1/stored-cards/{id}`) automatically
cancels its subscriptions (`cancel_reason: card_revoked`).

## Subscription states

| Status     | Meaning                                                       | What to do                                      |
| ---------- | ------------------------------------------------------------- | ----------------------------------------------- |
| `active`   | Charges every period on `next_charge_at`                      | Nothing — the scheduler runs it                 |
| `paused`   | Frozen; missed periods are **not** charged retroactively      | `POST .../resume` when ready                    |
| `past_due` | 3 dunning retries (24 h apart) failed                         | Fix the card/balance and `resume` to reactivate |
| `canceled` | Terminal — by `cancel` or because the stored card was revoked | Create a new subscription if needed             |

## Errors

| HTTP | Code                       | What to do                                                                   |
| ---- | -------------------------- | ---------------------------------------------------------------------------- |
| 400  | `idempotency_key_required` | Send `idempotency_key` (body or `Idempotency-Key` header)                    |
| 400  | `invalid_amount`           | `amount` must be a positive decimal string                                   |
| 400  | `invalid_interval`         | Use `daily`, `weekly`, `monthly` or `yearly`                                 |
| 400  | `invalid_request`          | The currency must match the stored card corridor                             |
| 404  | `not_found`                | The stored card / subscription does not exist or is not yours                |
| 409  | `idempotency_conflict`     | Same key with a different payload — use a new key                            |
| 409  | `subscription_state`       | The current state does not allow that action (e.g. resuming a canceled plan) |
| 422  | `stored_card_revoked`      | The card credential was revoked; ask the payer to save it again              |
| 422  | `core_rejected`            | The charge was declined by the rail — the message carries the reason         |

The general error catalog lives in [Errors](/en/errors).

## FAQ

<AccordionGroup>
  <Accordion title="Do you store the card number (PAN)?">
    Never. Saving a card stores an opaque network token — the PAN never touches
    the platform. Revoking the credential invalidates the token.
  </Accordion>

  <Accordion title="Why don't recurring charges ask for 3-D Secure?">
    Merchant-initiated transactions (MIT) run without 3DS by card-network
    mandate: the payer authenticated with 3DS on the initial consented payment,
    and every MIT references that transaction.
  </Accordion>

  <Accordion title="What happens to subscriptions if the card is revoked?">
    They are canceled automatically (`card_revoked`). The payer must save the
    card again and you create a new subscription.
  </Accordion>

  <Accordion title="Does pausing accumulate charges?">
    No — there is no catch-up: periods elapsed while paused advance the counter
    without charging. Resuming charges from the next due period only.
  </Accordion>

  <Accordion title="How does dunning work when a charge declines?">
    The scheduler retries up to 3 times, 24 h apart. If all fail the plan moves
    to `past_due` and you receive `subscription_status_changed` — no more
    charges until you `resume`.
  </Accordion>

  <Accordion title="When is the first charge collected?">
    Synchronously at creation, unless you pass a future `start_at` (trial):
    then the first charge waits for that date.
  </Accordion>
</AccordionGroup>
