Cards402 · HTTP API

The reference.

Everything an agent needs to go from a Stellar payment to a real Visa card. One base URL, eight endpoints, zero hosted checkout. Sign in at /dashboard with your email to mint an API key in seconds.

Base URLhttps://api.cards402.com
01 · Authentication

One header, one key.

Every request must include an X-Api-Key header. Keys are prefixed with cards402_ and scoped to a spend limit in USDC.

Create a key: sign in at /dashboard with any email (you'll get a 6-digit login code), open the Keys tab, and click New key. The raw token is shown once in a copy modal, then stored as a salted hash — keep it in your password manager.

Header
X-Api-Key: cards402_a1b2c3d4e5f6...

If the key is missing, invalid, or disabled, the API returns 401 Unauthorized. If the key has a spend limit and it is exceeded, the API returns 403 Forbidden.

02 · Create order

One transaction in, one card out.

Creates a new card order and returns Stellar payment instructions. The agent must send the exact amount to the Stellar address within the quoted window.

POST/v1/orders

Request body

Content-Type: application/json

FieldTypeRequiredDescription
amount_usdcstringYesCard value in USD, as a positive decimal string (e.g. "25.00"). Maximum "10000.00" per order.
webhook_urlstringNoHTTPS URL to receive webhook POSTs on status changes.
metadataobjectNoArbitrary JSON stored with the order and echoed back on every read.

Asset choice happens at payment time, not order creation. The response carries a payment.usdc quote in every case and a payment.xlm quote when the XLM price oracle is available — call pay_usdc() or pay_xlm() on the Soroban contract with whichever asset you want to settle in. If payment.xlm is absent from the response (oracle outage) the order can still be paid in USDC; retry order creation a few seconds later if you need an XLM quote.

Request
POST /v1/orders
X-Api-Key: cards402_your_key_here
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json

{
  "amount_usdc": "25.00",
  "webhook_url": "https://your-agent.com/webhook"
}
Response — 201 Created
{
  "order_id": "a3f7c2d1-4e8b-4f0a-9c2d-1b3e5a7f9c0e",
  "status": "pending_payment",
  "phase": "awaiting_payment",
  "amount_usdc": "25.00",
  "payment": {
    "type": "soroban_contract",
    "contract_id": "CAAAA...cards402_receiver_contract...",
    "order_id": "a3f7c2d1-4e8b-4f0a-9c2d-1b3e5a7f9c0e",
    "usdc": {
      "amount": "25.0000000",
      "asset": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"
    },
    "xlm": { "amount": "192.84" }
  },
  "poll_url": "/v1/orders/a3f7c2d1-4e8b-4f0a-9c2d-1b3e5a7f9c0e",
  "budget": {
    "spent_usdc": "15.00",
    "limit_usdc": "100.00",
    "remaining_usdc": "85.00"
  }
}
Response — 202 Accepted (awaiting owner approval)
{
  "order_id": "a3f7c2d1-4e8b-4f0a-9c2d-1b3e5a7f9c0e",
  "phase": "awaiting_approval",
  "approval_request_id": "ar_9f3a1b2c",
  "amount_usdc": "25.00",
  "message": "Amount exceeds per-txn cap of $10.00",
  "note": "The account owner has been notified. Poll GET /v1/orders/<id> to check status.",
  "expires_at": "2026-04-08T14:00:00.000Z"
}

When the API key has an approval-required spend policy and the requested amount trips that policy, the server returns 202 Accepted with this shape instead of 201 Created. No paymentblock is included because there's nothing to pay yet — poll the returned order_id until phase transitions to awaiting_payment (approved) or rejected (denied). Approval TTL defaults to 2 hours; tune via the operator dashboard.

Skipping all of this with the CLI

Most agents should never talk to the raw API. After cards402 onboard --claim <code>, a single command handles create-order → sign Soroban tx → stream → return card:

Shell
npx -y cards402@latest purchase --amount 25
# optional: --asset xlm|usdc
# default is "auto" — picks USDC if the wallet has enough, else XLM

USDC vs XLM — which to use?

USDCXLM
Card valueExact — 1 USDC = $1.00Market rate, quoted at order time
SetupRequires USDC trustlineNo trustline needed
Wallet needsXLM (fees) + USDC balanceXLM balance only
PredictabilityHigh — no price riskVaries with XLM/USD rate

The SDK handles both automatically. If using the MCP setup_wallet tool, the USDC trustline is added once the wallet has at least 2 XLM.

Payment window

Orders in pending_payment expire after 2 hours if no on-chain payment is detected. Expired orders return phase: "expired" and no funds are taken. Create a new order to retry.

Overpayment

Send exactly the quoted amount. If you send more than the quoted amount, the excess is retained and will not be refunded. Underpayments are not matched — the order will expire after 2 hours and no card will be issued.

Using the SDK?

Call purchaseCardOWS() — it creates the order, signs and submits the Soroban transaction, and polls for the card in a single call. No contract interaction or order ID handling required.

03 · Stream (SSE)

Preferred. One open connection, pushed phases.

Subscribe to live phase updates over Server-Sent Events. One open connection, pushed to on every transition, closed cleanly when the order reaches a terminal phase. Prefer this over polling for anything that runs as a long-lived process.

GET/v1/orders/:id/stream

Each event carries the full order state (same JSON shape as GET /orders/:id) as its data: payload, so a client that reconnects always sees the latest phase on the first message — no Last-Event-ID handling required.

Stream
: connected

id: 1776023489012
event: phase
data: {"order_id":"a3f7c2d1-...","status":"pending_payment","phase":"awaiting_payment","amount_usdc":"25.00","updated_at":"2026-04-08T12:00:00Z"}

id: 1776023510234
event: phase
data: {"order_id":"a3f7c2d1-...","status":"ordering","phase":"processing","updated_at":"2026-04-08T12:00:21Z"}

id: 1776023534567
event: phase
data: {"order_id":"a3f7c2d1-...","status":"delivered","phase":"ready","amount_usdc":"25.00","card":{"number":"4111 2345 6789 0123","cvv":"847","expiry":"12/27","brand":"Visa"},"updated_at":"2026-04-08T12:00:45Z"}
Minimal client — TypeScript / Node 18+ / browser
const res = await fetch(`${apiUrl}/orders/${orderId}/stream`, {
  headers: { 'X-Api-Key': key, Accept: 'text/event-stream' },
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  buf += decoder.decode(value, { stream: true });
  let i;
  while ((i = buf.indexOf('\n\n')) !== -1) {
    const event = buf.slice(0, i); buf = buf.slice(i + 2);
    const line = event.split('\n').find((l) => l.startsWith('data: '));
    if (!line) continue;
    const state = JSON.parse(line.slice(6));
    if (state.phase === 'ready') { console.log(state.card); return; }
    if (['failed','refunded','expired','rejected'].includes(state.phase)) {
      throw new Error(state.error ?? state.phase);
    }
  }
}
Minimal client — Python 3.10+ / httpx
import json
import httpx

TERMINAL = {"failed", "refunded", "expired", "rejected"}

def wait_for_card(api_url: str, order_id: str, key: str):
    headers = {"X-Api-Key": key, "Accept": "text/event-stream"}
    url = f"{api_url}/orders/{order_id}/stream"
    with httpx.stream("GET", url, headers=headers, timeout=None) as res:
        buf = ""
        for chunk in res.iter_text():
            buf += chunk
            while "\n\n" in buf:
                event, buf = buf.split("\n\n", 1)
                data_line = next(
                    (l for l in event.split("\n") if l.startswith("data: ")),
                    None,
                )
                if not data_line:
                    continue
                state = json.loads(data_line[6:])
                if state["phase"] == "ready":
                    return state["card"]
                if state["phase"] in TERMINAL:
                    raise RuntimeError(state.get("error") or state["phase"])

    raise RuntimeError("stream ended before terminal phase")
Minimal client — curl
curl -N \
  -H "X-Api-Key: cards402_..." \
  -H "Accept: text/event-stream" \
  https://api.cards402.com/v1/orders/<order_id>/stream

The Cards402 SDK's waitForCard() already uses this path with polling as an automatic fallback, so SDK users get SSE for free. The server emits an SSE comment (: keepalive) every 15s to prevent intermediate proxies from idle-killing the connection.

04 · Poll (fallback)

When SSE isn't an option.

Poll the status of an order when SSE isn't an option (e.g. middleboxes that strip text/event-stream). The response is the same shape as each SSE event's data: payload.

GET/v1/orders/:id

Suggested poll interval: every 5 seconds for the first 2 minutes.

Response — pending
{
  "order_id": "a3f7c2d1-4e8b-4f0a-9c2d-1b3e5a7f9c0e",
  "status": "payment_confirmed",
  "phase": "processing",
  "amount_usdc": "25.00",
  "payment_asset": "usdc_soroban",
  "created_at": "2026-04-08T12:00:00.000Z",
  "updated_at": "2026-04-08T12:00:05.000Z"
}
Response — delivered
{
  "order_id": "a3f7c2d1-4e8b-4f0a-9c2d-1b3e5a7f9c0e",
  "status": "delivered",
  "phase": "ready",
  "amount_usdc": "25.00",
  "created_at": "2026-04-08T12:00:00.000Z",
  "updated_at": "2026-04-08T12:01:02.000Z",
  "card": {
    "number": "4111 2345 6789 0123",
    "cvv": "847",
    "expiry": "12/27",
    "brand": "Visa"
  }
}
Response — failed
{
  "order_id": "a3f7c2d1-4e8b-4f0a-9c2d-1b3e5a7f9c0e",
  "status": "failed",
  "phase": "failed",
  "amount_usdc": "25.00",
  "error": "Stage 1 scrape timed out after 3 retries.",
  "created_at": "2026-04-08T12:00:00.000Z",
  "updated_at": "2026-04-08T12:03:15.000Z"
}
Response — refunded
{
  "order_id": "a3f7c2d1-4e8b-4f0a-9c2d-1b3e5a7f9c0e",
  "status": "refunded",
  "phase": "refunded",
  "amount_usdc": "25.00",
  "error": "Stage 1 scrape timed out after 3 retries.",
  "refund": {
    "stellar_txid": "e4f5a1b2c3d4...9e0f"
  },
  "created_at": "2026-04-08T12:00:00.000Z",
  "updated_at": "2026-04-08T12:05:27.000Z"
}

Any metadata object you passed at POST /orders creation time is echoed back in every subsequent GET /orders/:id response (and SSE event). Use it as the join key between Cards402 orders and your own internal records.

List orders (recovery / reconciliation)

Agents that crash mid-flight can rehydrate state by listing the orders created under their key. The response is an array of order summaries ordered by created_at descending.

GET/v1/orders

Optional query parameters:

ParamDefaultDescription
limit20Maximum number of results. Capped at 200.
offset0Pagination offset for walking historical orders.
statusanyFilter by internal status (e.g. pending_payment, delivered).
since_created_atISO timestamp — only orders created at or after this time.
since_updated_atISO timestamp — only orders updated at or after this time. Useful for delta polling without re-fetching the full history.
Response — array of order summaries
[
  {
    "id": "a3f7c2d1-4e8b-4f0a-9c2d-1b3e5a7f9c0e",
    "status": "delivered",
    "amount_usdc": "25.00",
    "payment_asset": "usdc_soroban",
    "created_at": "2026-04-08T12:00:00.000Z",
    "updated_at": "2026-04-08T12:01:02.000Z"
  },
  {
    "id": "b9e1f3a2-7c4d-4b0e-8f1a-2c4e6a8b0d2f",
    "status": "pending_payment",
    "amount_usdc": "10.00",
    "payment_asset": "usdc_soroban",
    "created_at": "2026-04-08T11:59:12.000Z",
    "updated_at": "2026-04-08T11:59:12.000Z"
  }
]

Note the key shape difference: list items use id (not order_id) and omit the phase field and the card object. To retrieve card details for a delivered order in the list, call GET /orders/:idor use the SDK's client.getOrder(id).

05 · Order statuses

A tiny state machine.

Orders move through a linear state machine. The happy path ends at delivered. Failures produce failed and queue a refund.

StatusMeaning
awaiting_approvalOrder is held for owner approval because the key has a spend policy that requires it. No payment instructions yet — poll approval_request_id for a decision. Expires after 2 hours.
awaiting_paymentApproved (or no approval needed). Waiting for your Stellar payment to confirm on-chain.
processingPayment confirmed. Card is being fulfilled — typically 30–90 seconds.
readyCard details are ready. Poll response includes the card object.
failedFulfillment failed. The error field contains the reason. A refund is automatically queued.
refundedPayment refunded to your sender address. The response carries a refund.stellar_txid field with the on-chain transaction hash.
rejectedOwner rejected the approval request. The error field contains the decision note. No payment was taken.
expiredNo payment arrived within 2 hours. No funds were taken. Create a new order to retry.

Happy path: awaiting_paymentprocessingready

With approval gate: awaiting_approvalawaiting_payment processingready

Failure path: processingfailedrefunded

Approval rejected: awaiting_approvalrejected

No payment within 2 hours: awaiting_paymentexpired

Each poll response also includes a status field with the internal pipeline state (e.g. ordering, stage1_done). Treat these as informational only — build your logic against phase, which is stable across backend changes.

06 · Webhooks

Signed, retried, optional.

If you provide a webhook_url when creating an order, Cards402 will POST to it when the order reaches delivered or failed status.

Each webhook request includes two headers for verification:

  • X-Cards402-Signature: sha256=<hmac> — HMAC-SHA256 over <timestamp>.<body>
  • X-Cards402-Timestamp: <unix-ms> — send time in milliseconds

Always verify the signature and reject requests with a timestamp older than 5 minutes. Webhooks are retried automatically on failure: 30 seconds, 5 minutes, then 30 minutes (3 attempts total). Use polling as your primary status source — webhooks are a convenience notification, not a delivery guarantee.

Five distinct webhook events are emitted depending on how the order resolves. Every payload includes order_id and status — the rest is event-specific.

delivered — card issued successfully

{
  "order_id": "a3f7c2d1-4e8b-4f0a-9c2d-1b3e5a7f9c0e",
  "status": "delivered",
  "amount_usdc": "25.00",
  "payment_asset": "usdc_soroban",
  "card": {
    "number": "4111 2345 6789 0123",
    "cvv": "847",
    "expiry": "12/27",
    "brand": "Visa"
  }
}

failed — fulfilment error (refund queued)

{
  "order_id": "a3f7c2d1-4e8b-4f0a-9c2d-1b3e5a7f9c0e",
  "status": "failed",
  "amount_usdc": "25.00",
  "payment_asset": "usdc_soroban",
  "error": "Stage 1 scrape timed out after 3 retries."
}

expired — no payment within 2 hours

{
  "order_id": "a3f7c2d1-4e8b-4f0a-9c2d-1b3e5a7f9c0e",
  "status": "expired",
  "phase": "expired",
  "note": "Payment window expired. No funds were taken."
}

approved — approval request accepted (order unblocked)

{
  "order_id": "a3f7c2d1-4e8b-4f0a-9c2d-1b3e5a7f9c0e",
  "status": "pending_payment",
  "phase": "awaiting_payment",
  "note": "Approved."
}

Fires only for keys with an approval-required spend policy. The order transitions from awaiting_approval to awaiting_payment and is now paid for normally.

rejected — approval request denied

{
  "order_id": "a3f7c2d1-4e8b-4f0a-9c2d-1b3e5a7f9c0e",
  "status": "rejected",
  "phase": "rejected",
  "error": "Outside approved merchant category."
}

Signature verification

Node.js
const { createHmac, timingSafeEqual } = require('crypto');

function verifyWebhook(rawBody, signature, timestamp, secret) {
  if (Math.abs(Date.now() - parseInt(timestamp)) > 5 * 60 * 1000) return false;
  const expected = 'sha256=' + createHmac('sha256', secret)
    .update(timestamp + '.' + rawBody).digest('hex');
  return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
Python 3
import hmac, hashlib, time

def verify_webhook(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    # Reject anything older than 5 minutes to thwart replay attacks.
    if abs(time.time() * 1000 - int(timestamp)) > 5 * 60 * 1000:
        return False
    message = f"{timestamp}.".encode() + raw_body
    expected = "sha256=" + hmac.new(
        secret.encode(), message, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, expected)

Return 200 to acknowledge. Any other response is treated as a failure.

07 · Error codes

Typed, stable, documented.

All errors return a JSON body with an error string and optional message field.

Error shape
{
  "error": "invalid_amount",
  "message": "amount_usdc must be a positive number"
}
CodeHTTP statusMeaning
missing_api_key401X-Api-Key header not provided.
invalid_api_key401API key not found or disabled.
api_key_expired401The API key reached its configured expires_at. Mint a new key from the dashboard and revoke the old one.
invalid_amount400amount_usdc is missing, not a positive decimal string, below $0.01, or above the $10,000.00 per-order ceiling.
invalid_webhook_url400webhook_url failed SSRF validation (non-HTTPS, loopback, private IP, etc.). Use a public HTTPS endpoint.
spend_limit_exceeded403This key has reached its USDC spend limit.
order_not_found404Order does not exist or belongs to another key.
idempotency_conflict409Idempotency-Key was reused with a different request body. Mint a new key or resend the original body.
rate_limit_exceeded429Too many orders (60/hour) or too many polls (600/minute). Back off and retry; the window is rolling.
service_temporarily_unavailable503Circuit breaker tripped after repeated failures. Try again later.

Handling errors by type (SDK)

The Cards402 SDK converts the error string from each response into a typed subclass of Cards402Error, so agents can handle each case without string-parsing:

TypeScript
import {
  Cards402Client,
  SpendLimitError,
  RateLimitError,
  ServiceUnavailableError,
  InvalidAmountError,
  AuthError,
  OrderFailedError,
  WaitTimeoutError,
  ResumableError,
} from 'cards402';

const client = new Cards402Client({ apiKey });

try {
  const card = await client.waitForCard(orderId);
} catch (err) {
  if (err instanceof SpendLimitError) {
    // err.limit, err.spent — ask the operator to raise the cap
  } else if (err instanceof RateLimitError) {
    // back off before retrying
  } else if (err instanceof ServiceUnavailableError) {
    // circuit breaker tripped; retry in a few minutes
  } else if (err instanceof OrderFailedError) {
    // err.orderId, err.refund.stellar_txid — payment refunded
  } else if (err instanceof WaitTimeoutError) {
    // err.orderId — order may still complete; poll again later
  } else if (err instanceof ResumableError) {
    // err.orderId — resume with cards402 purchase --resume <id>
  } else if (err instanceof AuthError) {
    // key is missing or revoked
  } else {
    // Cards402Error base, or a non-cards402 error — rethrow
    throw err;
  }
}

All typed errors extend Cards402Error, which carries code, status, and raw(the original JSON body) for cases the SDK doesn't have a dedicated subclass for. ResumableError is the one you actually need for production — it wraps every non-terminal failure of purchaseCardOWS() and hands you anorderId you can pass to --resume.

08 · Rate limits

Per-key, per-hour, per-minute.

The following limits are enforced per API key:

  • Order creation — 60 per hour
  • Status polling — 600 per minute (10/s)

Exceeded limits return 429 rate_limit_exceeded.

However, each API key can have an optional spend_limit_usdc configured by the admin. Once the cumulative spend reaches this limit, the key returns 403 spend_limit_exceeded until the limit is raised.

If the fulfillment system has 3 consecutive failures, a circuit breaker freezes all new orders and returns 503 service_temporarily_unavailable until an admin manually unfreezes the system.

Check your current usage

Call GET /usage to get the current budget and order counts for your key. The SDK exposes this as client.getUsage().

GET/v1/usage
Response
{
  "api_key_id": "ak_a3f7c2d1",
  "label": "production-agent",
  "budget": {
    "spent_usdc": "15.00",
    "limit_usdc": "100.00",
    "remaining_usdc": "85.00"
  },
  "orders": {
    "total": 3,
    "delivered": 2,
    "failed": 0,
    "refunded": 1,
    "in_progress": 0
  }
}

The budget.limit_usdc field is null for keys without a spend limit configured; in that case remaining_usdc is also null and only the global 60 orders/hour rate limit applies.

Questions? api@cards402.com

Read /skill.md