---
url: /docs/payin-card.md
description: >-
  Accept a credit or debit card payment and deliver the equivalent stablecoins
  to a customer's wallet, with card data never touching your servers or
  BlindPay's.
---

A card payin collects USD from a payer's credit or debit card and delivers USD stablecoins to a [customer](/docs/learn/customers)'s wallet. It is a regular [payin](/docs/payins) with `payment_method: "card"`: you create a payin quote, create the payin, and then the payer enters the card. The card is charged only after 3-D Secure authentication succeeds, and stablecoins are released only after the charge is confirmed.

Three ways for the payer to enter the card, from least to most integration work:

| Option | Who enters the card | Integration |
| --- | --- | --- |
| Dashboard | You, on behalf of the payer, in the payin dialog at [app.blindpay.com](https://app.blindpay.com) | none |
| Hosted checkout link | The payer, on a BlindPay-hosted page you redirect them to | redirect to `checkout_url` |
| Card frame | The payer, inside a BlindPay card frame embedded in your own page | embed the frame, then call Create Card Payment from your backend |

Card data never touches your servers or BlindPay's. In every option the card fields are rendered by the card frame on `pay.blindpay.com`, an isolated origin that hands back a single-use payment token. That keeps your PCI scope at the lowest level (SAQ A).

Card payins are enabled per instance. If a card quote fails with 400 `card_payin_not_supported`, contact BlindPay to enable cards for your instance.

## Rules

* **Currency and tokens.** Cards are USD only. The quote is USD to USD stablecoins at 1:1, so there is no FX exposure. On development instances the token is `USDB`; in production use `USDC` or `USDT`.
* **Amount.** Up to $500.00 per payin (`request_amount` 50000). Larger amounts are rejected at the quote.
* **No OTC.** Card quotes cannot be OTC.
* **3-D Secure.** Every production charge requires an authenticated 3-D Secure result. The frame runs it for you; a charge without it is rejected with 402 `card_three_ds_required`.
* **Billing address.** The cardholder's billing address is required and checked against the card issuer. On production instances a charge whose ZIP or security code does not match is voided and reported as declined.
* **Attempts.** A payin accepts up to 3 declined attempts, then stops accepting charges. Create a new payin to try again.
* **Link lifetime.** `checkout_token` and `checkout_url` are valid for 1 hour after the payin is created. A payin that is never paid moves to `failed` after about a day.

## Prerequisites

You also need a [customer](/docs/learn/customers) with `kyc_status: "approved"` and a destination wallet for that customer, either a [managed wallet](/docs/wallets) (`bl_...`) or a [blockchain wallet](/docs/blockchain-wallets) (`bw_...`).

### Create a payin quote

Same call as every other payin, with `payment_method: "card"`. The sender amount is what the card is charged, in cents.

```bash [cURL]
curl --request POST \
  --url https://api.blindpay.com/v1/instances/in_000000000000/payin-quotes \
  --header 'Authorization: Bearer YOUR_API_KEY' \
  --header 'Content-Type: application/json' \
  --data '{
    "wallet_id": "bl_000000000000",
    "currency_type": "sender",
    "cover_fees": true,
    "request_amount": 1000,
    "payment_method": "card",
    "token": "USDC"
  }'
```

The response carries `card_instruction` with the public keys the frame needs. `checkout_token` and `checkout_url` are null on a quote; they are issued when the payin is created.

```json
{
  "id": "qu_000000000000",
  "expires_at": 1712958191000,
  "commercial_quotation": 10000,
  "blindpay_quotation": 10000,
  "receiver_amount": 970,
  "sender_amount": 1000,
  "partner_fee_amount": 0,
  "flat_fee": 30,
  "billing_fee_amount": 0,
  "is_otc": false,
  "card_instruction": {
    "provider": "nmi",
    "tokenization_key": "KXypee-Vq3y2f-K6c7Dy-Wq92R5",
    "checkout_public_key": "checkout_public_Ab12Cd34Ef56Gh78",
    "checkout_token": null,
    "checkout_url": null
  }
}
```

### Create the payin

```bash [cURL]
curl --request POST \
  --url https://api.blindpay.com/v1/instances/in_000000000000/payins/evm \
  --header 'Authorization: Bearer YOUR_API_KEY' \
  --header 'Content-Type: application/json' \
  --data '{
    "payin_quote_id": "qu_000000000000"
  }'
```

The payin is created in `processing` and `tracking_transaction.card_instruction` now carries everything the payer flow needs:

```json
{
  "id": "pi_000000000000",
  "status": "processing",
  "payment_method": "card",
  "sender_amount": 1000,
  "receiver_amount": 970,
  "tracking_transaction": {
    "step": "processing",
    "status": null,
    "card_instruction": {
      "provider": "nmi",
      "tokenization_key": "KXypee-Vq3y2f-K6c7Dy-Wq92R5",
      "checkout_public_key": "checkout_public_Ab12Cd34Ef56Gh78",
      "checkout_token": "cGlfMDAwMDAwMDAwMDAwLjE3OTM1NzQ0MDAuM2Y1YQ",
      "checkout_url": "https://app.blindpay.com/e/card/pi_000000000000?t=cGlfMDAwMDAwMDAwMDAwLjE3OTM1NzQ0MDAuM2Y1YQ"
    },
    "card_evidence": null,
    "card_failed_attempts": 0
  }
}
```

| Field | Use |
| --- | --- |
| `tokenization_key` | Public key the card frame uses to tokenize the card |
| `checkout_public_key` | Public key the card frame uses for 3-D Secure. Null when 3-D Secure is not configured for the instance's environment |
| `checkout_token` | Signed token for the hosted page, valid 1 hour |
| `checkout_url` | The hosted checkout page for this payin |

### Collect the card

Pick one option.

**Hosted checkout link.** Redirect the payer to `checkout_url`, or send it to them. The page shows the amount, the destination and the card form, runs 3-D Secure, charges the card and shows the result. Nothing else to build. After the link expires the page tells the payer to ask for a new one.

**Card frame in your page.** Embed the frame and drive it with `postMessage`. Your page keeps the amount, the pay button, the email and the billing address form; the frame keeps the card fields.

```html
<iframe
  id="card-frame"
  src="https://pay.blindpay.com/?tokenization_key=KXypee-Vq3y2f-K6c7Dy-Wq92R5&checkout_public_key=checkout_public_Ab12Cd34Ef56Gh78&parent_origin=https://yourapp.com"
  referrerpolicy="origin"
  style="width: 100%; border: 0"
></iframe>
```

`parent_origin` must be the exact https origin of the page embedding the frame, and that origin must be the website registered on your production instance (or a subdomain of it). The frame refuses to render anywhere else.

Messages flow both ways. Every message carries `source: "blindpay-pay"` and `version: 1`.

| Direction | `type` | Payload |
| --- | --- | --- |
| frame to page | `ready` | The card fields are interactive |
| frame to page | `resize` | `height` in px. Apply it to the iframe |
| frame to page | `validation` | `field` (`cardholder_name`, `ccnumber`, `ccexp`, `cvv`), `valid`, `message` |
| frame to page | `challenge` | The issuer is showing a 3-D Secure challenge inside the frame |
| frame to page | `tokenized` | `payment_token`, `card_type`, `cardholder_name`, `three_ds` |
| frame to page | `error` | `code`: `load_failed`, `timeout`, `tokenization_failed`, `invalid_input`, `three_ds_failed`, `three_ds_unavailable` |
| page to frame | `submit` | `amount` (cents), `currency` (`USD`), `email`, `billing` (`address1`, `city`, `state`, `postal_code`, `country`). Starts tokenization and 3-D Secure |

```js
const frame = document.getElementById('card-frame')
const PAY_ORIGIN = 'https://pay.blindpay.com'

window.addEventListener('message', (event) => {
  if (event.origin !== PAY_ORIGIN || event.source !== frame.contentWindow)
    return
  const msg = event.data
  if (msg.source !== 'blindpay-pay' || msg.version !== 1)
    return

  if (msg.type === 'resize')
    frame.style.height = `${msg.height}px`
  if (msg.type === 'tokenized')
    chargeOnYourBackend(msg) // payment_token, cardholder_name, three_ds
  if (msg.type === 'error')
    showError(msg.code)
})

function pay() {
  frame.contentWindow.postMessage({
    source: 'blindpay-pay',
    version: 1,
    type: 'submit',
    amount: 1000,
    currency: 'USD',
    email: 'payer@example.com',
    billing: { address1: '100 Main St', city: 'Chicago', state: 'IL', postal_code: '60601', country: 'US' },
  }, PAY_ORIGIN)
}
```

Always check `event.origin` and `event.source` before trusting a message. After an `error` the frame returns to ready and accepts a new `submit`. If `three_ds_failed` or `three_ds_unavailable` repeats, remount the iframe before retrying.

### Charge the card

Only for the card frame option. The hosted page and the dashboard charge on their own.

Send the `tokenized` payload from your page to your backend, and charge with your API key. The `payment_token` is single use and must be the one that went through 3-D Secure.

```bash [cURL]
curl --request POST \
  --url https://api.blindpay.com/v1/instances/in_000000000000/payins/pi_000000000000/card-payment \
  --header 'Authorization: Bearer YOUR_API_KEY' \
  --header 'Content-Type: application/json' \
  --data '{
    "payment_token": "8kR4TfnC-gU5ZpZ-2jWgJ9-536t4AMnVUzj",
    "cardholder_name": "Jane Doe",
    "billing_address": {
      "address1": "100 Main St",
      "city": "Chicago",
      "state": "IL",
      "zip": "60601",
      "country": "US"
    },
    "three_ds": {
      "cavv": "AAABBWcSNIdjeUZThmNHAAAAAAA=",
      "eci": "05",
      "xid": null,
      "cardholder_auth": "verified",
      "three_ds_version": "2.2.0",
      "directory_server_id": "f38e6948-5388-41a6-bca4-b49723c19437"
    }
  }'
```

A `200` means the charge was approved and the stablecoin delivery was released:

```json
{
  "success": true,
  "provider_transaction_id": "8584364761",
  "status": "confirmed"
}
```

## Errors

| Status | `message` | Meaning |
| --- | --- | --- |
| 400 | `card_payin_not_supported` | Cards are not enabled on this instance, or the quote is OTC |
| 400 | `card_three_ds_not_configured` | 3-D Secure is not available for this instance's environment yet |
| 400 | `payin_not_card` | The payin's `payment_method` is not `card` |
| 402 | `card_three_ds_required` | Production charge without an authenticated 3-D Secure result |
| 402 | `card_payment_declined` | The issuer declined, or the charge was approved but the ZIP, security code or authentication check failed and it was voided. Counts as a failed attempt |
| 409 | `card_already_charged` | The payin already has an approved charge, or a charge is in flight |
| 401 | `card_checkout_token_invalid` | Hosted page only: the link is missing, expired, tampered or for another payin |
| 502 | `card_payment_pending_confirmation` | The charge reached the card network but its result could not be confirmed. Do not retry; wait for the payin to update |

## What happens next

Once the charge is confirmed, BlindPay delivers the stablecoins to the destination wallet. The payin moves from `processing` to `completed`, `tracking_transaction.card_evidence` carries the authentication and authorization results (`eci`, `cavv_result`, `avs_response`, `cvv_response`, `auth_code`), and the usual webhooks fire: `payin.update`, then `payin.complete`, plus `wallet.inbound` for a managed wallet.

A declined charge leaves the payin in `processing` with `card_failed_attempts` incremented, so the payer can try again until the limit. A payin that is never charged moves to `failed` after about a day. A chargeback received later moves the payin to `refunded`.

## Testing

On development instances the whole flow runs end to end against a sandbox card network: the frame tokenizes the test card, the charge is approved by the sandbox, the payin reaches `completed`, `USDB` lands in the wallet and the `payin.complete` webhook fires. The one difference from other payment methods is the trigger: a development card payin does not complete on a timer, it completes when a test card is charged, through the hosted link, the card frame or the dashboard. Use card number `4111 1111 1111 1111`, any future expiry, security code `999` and ZIP `77777`. Real cards are never charged in development.

The sentinel amounts work for cards too: a quote with `request_amount` `66600` ends in `failed` and `77700` in `refunded`, after the test card is charged.

## Related

* [Payins](/docs/payins): the payin reference, statuses and webhooks
* [Payin quotes](/docs/payin-quotes): full request and response fields
* [Managed wallets](/docs/wallets) and [blockchain wallets](/docs/blockchain-wallets): the delivery targets
