# Locus — Real-Card Payments (Visa Intelligent Commerce)

> **You are on:** `https://paywithlocus.com/` · `https://api.paywithlocus.com/api` | [llms.txt](https://paywithlocus.com/llms.txt)
>
> Locus has three environments. Make sure every URL you call matches your expected environment:
> - **Production**: paywithlocus.com (landing) / {api}.paywithlocus.com (API)
> - **Beta**: {beta}.paywithlocus.com (landing) / {beta-api}.paywithlocus.com (API)
> - **Stage**: {stage}.paywithlocus.com (landing) / {stage-api}.paywithlocus.com (API)
>
> If the URLs above don't match your expected environment, re-fetch this file from the correct domain.

Charge your human's **real** Visa credit/debit card via Visa Intelligent Commerce (VIC). Each charge requires a one-tap cardholder approval (Visa Payment Passkey). Once approved, you get **single-use virtual-card credentials** (number + CVC + expiry) that you can present at any merchant checkout to complete the purchase.

**Base URL:** `https://api.paywithlocus.com/api`
**Auth:** `Authorization: Bearer YOUR_LOCUS_API_KEY`

---

## Real Cards vs. Other Payment Methods

| Need | Use |
|------|-----|
| **Charge the human's existing Visa card** at any merchant | **This file (CARDS.md)** |
| **Send USDC** on Base | [SKILL.md → Send USDC](SKILL.md#send-usdc) |
| **Order a one-time prepaid card** (USDC-funded, US only, $5–$1000) | [LASO.md](LASO.md) |
| **Pay a merchant checkout session** (Locus Checkout SDK) | [CHECKOUT.md](CHECKOUT.md) |
| **Call a paid API** (per-call USDC) | [SKILL.md → x402](SKILL.md#x402-endpoints-pay-per-call-apis) |

Real-card flow is the only path that produces **virtual card credentials usable at any merchant on the open web**. Use this when the user wants you to "buy this thing on Amazon / book this flight / pay this invoice with my Visa."

---

## Lifecycle in One Picture

```
1. agent: GET   /api/pay/cards                        # list user's cards, pick one
2. agent: POST  /api/pay/charge-card                  # initiate charge
   ← 202 { charge_id, approval_url, status: "pending_approval" }
3. agent: send approval_url to the user (or they get an email)
4. user:  taps Visa Payment Passkey on the approval page
5. agent: GET   /api/pay/charge-status/:charge_id     # poll
   ← { status: "pending_approval" }   (keep polling)
   ← { status: "completed", credentials: { number, cvc, ... } }
6. agent: use credentials at the merchant checkout
   credentials are SINGLE-USE and expire ~15 minutes after release
```

**Important:** the `charge_id` you receive is the value to pass to `/charge-status/:id`. It is the same id as `data.charge_id` in the create response.

---

## Step 1: List Available Cards

Find a card the user has enrolled. Cards must be `enrollment_status: "active"` to be charged.

```bash
curl https://api.paywithlocus.com/api/pay/cards \
  -H "Authorization: Bearer YOUR_LOCUS_API_KEY"
```

**Response (200):**
```json
{
  "success": true,
  "data": {
    "cards": [
      {
        "id": "58a4d838-142c-406d-acd7-2dd68132170c",
        "brand": "visa",
        "last4": "4242",
        "exp_month": 12,
        "exp_year": 2027,
        "enrollment_status": "active",
        "ready_for_charges": true,
        "issuer_name": "Chase"
      }
    ]
  }
}
```

| Field | Meaning |
|-------|---------|
| `id` | The `card_id` to pass to `/charge-card`. |
| `brand` | `visa`, `mastercard`, etc. (lowercase) |
| `last4` | Last four digits. Display as `Visa 4242` for Visa cards (Visa Card Art best practices); other brands as `BRAND 4242`. |
| `enrollment_status` | `none` / `pending_verification` / `active` / `abandoned`. **Only `active` cards can be charged.** |
| `ready_for_charges` | Convenience boolean. Filter on this before attempting a charge. |
| `issuer_name` | The bank that issued the card (display only). |

If the list is empty or no cards are `ready_for_charges`, tell the user to add a card at `https://app.paywithlocus.com/cards` (the build system rewrites this to the corresponding stage/beta PWL app URL).

---

## Step 2: Initiate the Charge

```bash
curl -X POST https://api.paywithlocus.com/api/pay/charge-card \
  -H "Authorization: Bearer YOUR_LOCUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "card_id": "58a4d838-142c-406d-acd7-2dd68132170c",
    "amount": 12.99,
    "currency": "USD",
    "description": "AA flight DFW→SFO, 2026-05-12",
    "merchant": {
      "name": "American Airlines",
      "category_code": "4511",
      "url": "https://www.aa.com",
      "country_code": "US"
    },
    "expires_at": "2026-05-01T20:00:00Z",
    "idempotency_key": "trip-dfw-sfo-2026-05-12-flight-001"
  }'
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `card_id` | string | Yes | A card id from `/api/pay/cards`. Must be `ready_for_charges`. |
| `amount` | number | Yes | Dollar amount, e.g. `12.99`. Must be > 0. |
| `currency` | string | No | Default `USD`. Currently USD-only. |
| `description` | string | Yes | Short description shown to the cardholder in the passkey prompt and recorded in the audit log. |
| `merchant.name` | string | Recommended | Merchant name. Shown to the cardholder verbatim in the passkey prompt — accuracy matters. |
| `merchant.category_code` | string | Recommended | 4-digit MCC. **Without this, the wallet's restricted-category guardrails (gambling, tobacco, etc.) cannot enforce.** Look up the merchant's MCC if you can. |
| `merchant.url` | string | No | Merchant URL. |
| `merchant.country_code` | string | No | ISO 3166-1 alpha-2, e.g. `US`. |
| `expires_at` | string | No | RFC3339. Default +24h. Max +30 days from now. After this, the charge auto-expires. |
| `idempotency_key` | string | No (but strongly recommended) | Re-using the same key for a logical attempt makes retries safe — you'll get the same `charge_id` back instead of a duplicate charge. Mint one per logical attempt (e.g. one per flight you're booking, NOT one per HTTP retry). |

**Response (202 — pending cardholder approval):**
```json
{
  "success": true,
  "data": {
    "status": "pending_approval",
    "charge_id": "0af9f7c6-1234-5678-9abc-...",
    "approval_url": "https://paywithlocus.com/fiat-approve/<token>"
  }
}
```

The cardholder receives an email with `approval_url`. They can also find pending approvals at `https://paywithlocus.com/approvals` if email is missed.

**If you receive a 4xx response, surface the error to the user.** Common ones:

| HTTP | error | What it means |
|------|-------|---------------|
| 400 | `Card is not eligible for enrollment: Prepaid cards are not eligible for enrollment` | Cardholder's card is prepaid. VIC requires credit/debit. |
| 400 | `consent_required` | Cardholder hasn't granted (or has revoked) data-sharing consent. They need to grant it at `/cards` first. |
| 400 | `merchant category XXXX is restricted (...)` | The MCC you supplied is on the Visa-mandated blocklist (gambling, adult, tobacco, etc.). |
| 400 | `merchant category XXXX is blocked by cardholder (...)` | The cardholder has disabled this category on this card specifically. Suggest they update their preferences or pick a different card. |
| 400 | `Card is not enrolled in VIC (status: ...)` | Card exists but enrollment isn't `active`. Tell the user to finish enrollment at `/cards`. |
| 400 | `non_kyc_monthly_limit_hit` | The user's $1000/30-day spend cap is reached. |
| 403 | `Transaction amount exceeds maximum allowed size of N USDC` | Your agent's per-transaction cap. The user can raise it in Agent Settings. |
| 403 | `Transaction amount exceeds remaining allowance of N USDC` | Your agent's total allowance is exhausted. The user can top up in Agent Settings. |
| 404 | `card_not_found` | The `card_id` doesn't belong to this user. |

---

## Step 3: Wait for Cardholder Approval

The cardholder clicks the link, sees the merchant name + amount + description, and taps the Visa Payment Passkey on their device (face/fingerprint). This is **mandatory on every charge** — there is no skip-approval mode.

You don't need to do anything during this window. Don't poll faster than every ~10 seconds.

---

## Step 4: Poll for Credentials

```bash
curl https://api.paywithlocus.com/api/pay/charge-status/CHARGE_ID \
  -H "Authorization: Bearer YOUR_LOCUS_API_KEY"
```

**While pending (200):**
```json
{
  "success": true,
  "data": {
    "status": "pending_approval",
    "charge_id": "0af9...",
    "approval_url": "https://paywithlocus.com/fiat-approve/<token>"
  }
}
```

**On success (200):**
```json
{
  "success": true,
  "data": {
    "status": "completed",
    "charge_id": "0af9...",
    "credentials": {
      "number": "4111111111111111",
      "cvc": "123",
      "expiration_month": 12,
      "expiration_year": 2027
    },
    "credentials_expires_at": "2026-04-30T18:15:00Z"
  }
}
```

**Other terminal statuses:**
- `denied` — cardholder rejected.
- `expired` — cardholder didn't approve before `approval_url` TTL.
- `failed` — wallet-side error (rare).

---

## Step 5: Use the Credentials at the Merchant

The `credentials` object is a **single-use virtual card** scoped to the amount + merchant you sent in the charge. Use them in the merchant's checkout the same way you'd use any card number — they look like a normal Visa.

Constraints:
- **Single use.** Once you submit them at the merchant, they self-destruct. If the merchant retries the charge, it will fail.
- **Short TTL** (~15 minutes by default — read `credentials_expires_at`). After expiry, you have to re-run the whole flow.
- **Amount-locked.** The card will reject charges materially different from the `amount` you supplied. Don't reuse the credentials for a different purchase.
- **Merchant-locked.** If you supplied a `merchant.name`/`merchant.url`, the card may only authorize at that merchant. Be accurate at charge-create time.
- **Never log or persist the credentials.** They are PCI data while live. Use them and discard.

---

## Idempotency

`idempotency_key` lets you retry safely. Rules:

- **Same key + same amount + same card_id** → returns the existing charge (idempotent).
- **Same key + different fields** → may behave unpredictably; treat each logical attempt as one key.
- Mint one key per *logical purchase attempt*, not per HTTP retry. e.g. `flight-DFW-SFO-2026-05-12` is good; `req-uuid-from-this-second` is bad because a transient retry would create a duplicate charge.

---

## Cardholder Approval Side-Channels

If the user doesn't receive the email (rare but possible), they can find every pending agent-initiated approval at:

**`https://paywithlocus.com/approvals`**

Tell them to check there if they say "I never got the email." They can complete the same passkey ceremony from that page.

---

## Spending Controls

Real-card charges are subject to the same Agent Settings as USDC sends:

- **Allowance** — the agent's total spending cap. Decrements after each completed charge.
- **Max Transaction** — per-charge cap.
- **Approval Threshold** — does **not** apply to real-card charges. Visa requires per-charge cardholder passkey approval regardless of amount, so every real-card charge is gated by the cardholder. The threshold is still meaningful for crypto sends.

If you hit a 403 with one of these reasons, tell the user the cap was reached and link them to `https://paywithlocus.com/settings` to adjust.

---

## What This Does Not Cover

- **The user's first-time card enrollment** is a browser-only flow — the user adds and verifies a card at `https://app.paywithlocus.com/cards`. Agents cannot vault new cards on the user's behalf (PCI rules).
- **Refunds and chargebacks** are between the cardholder, the issuing bank, and the merchant. AgentPay/Locus has no API for refunding a fiat charge — the user disputes via their bank as they would any other Visa charge.
- **Settlement timing** is set by the issuing bank, typically 1–3 business days after credential use. You'll see `status: "completed"` immediately after passkey approval; the actual statement entry comes later.

---

## Quick Reference

| Endpoint | Purpose |
|----------|---------|
| `GET /api/pay/cards` | List the user's enrolled VIC cards. |
| `POST /api/pay/charge-card` | Initiate a fiat charge. Returns a `charge_id` and `approval_url`. |
| `GET /api/pay/charge-status/:id` | Poll a charge. Returns credentials once the cardholder approves. |
