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.
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.
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.
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.
Request body
Content-Type: application/json
| Field | Type | Required | Description |
|---|---|---|---|
amount_usdc | string | Yes | Card value in USD, as a positive decimal string (e.g. "25.00"). Maximum "10000.00" per order. |
webhook_url | string | No | HTTPS URL to receive webhook POSTs on status changes. |
metadata | object | No | Arbitrary 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.
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"
}{
"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"
}
}{
"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:
npx -y cards402@latest purchase --amount 25
# optional: --asset xlm|usdc
# default is "auto" — picks USDC if the wallet has enough, else XLMUSDC vs XLM — which to use?
| USDC | XLM | |
|---|---|---|
| Card value | Exact — 1 USDC = $1.00 | Market rate, quoted at order time |
| Setup | Requires USDC trustline | No trustline needed |
| Wallet needs | XLM (fees) + USDC balance | XLM balance only |
| Predictability | High — no price risk | Varies 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.
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.
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.
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.
: 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"}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);
}
}
}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")curl -N \
-H "X-Api-Key: cards402_..." \
-H "Accept: text/event-stream" \
https://api.cards402.com/v1/orders/<order_id>/streamThe 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.
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.
Suggested poll interval: every 5 seconds for the first 2 minutes.
{
"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"
}{
"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"
}
}{
"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"
}{
"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.
Optional query parameters:
| Param | Default | Description |
|---|---|---|
limit | 20 | Maximum number of results. Capped at 200. |
offset | 0 | Pagination offset for walking historical orders. |
status | any | Filter by internal status (e.g. pending_payment, delivered). |
since_created_at | — | ISO timestamp — only orders created at or after this time. |
since_updated_at | — | ISO timestamp — only orders updated at or after this time. Useful for delta polling without re-fetching the full history. |
[
{
"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).
A tiny state machine.
Orders move through a linear state machine. The happy path ends at delivered. Failures produce failed and queue a refund.
| Status | Meaning |
|---|---|
| awaiting_approval | Order 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_payment | Approved (or no approval needed). Waiting for your Stellar payment to confirm on-chain. |
| processing | Payment confirmed. Card is being fulfilled — typically 30–90 seconds. |
| ready | Card details are ready. Poll response includes the card object. |
| failed | Fulfillment failed. The error field contains the reason. A refund is automatically queued. |
| refunded | Payment refunded to your sender address. The response carries a refund.stellar_txid field with the on-chain transaction hash. |
| rejected | Owner rejected the approval request. The error field contains the decision note. No payment was taken. |
| expired | No payment arrived within 2 hours. No funds were taken. Create a new order to retry. |
Happy path: awaiting_payment → processing → ready
With approval gate: awaiting_approval → awaiting_payment → processing → ready
Failure path: processing → failed → refunded
Approval rejected: awaiting_approval → rejected
No payment within 2 hours: awaiting_payment → expired
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.
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
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));
}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.
Typed, stable, documented.
All errors return a JSON body with an error string and optional message field.
{
"error": "invalid_amount",
"message": "amount_usdc must be a positive number"
}| Code | HTTP status | Meaning |
|---|---|---|
missing_api_key | 401 | X-Api-Key header not provided. |
invalid_api_key | 401 | API key not found or disabled. |
api_key_expired | 401 | The API key reached its configured expires_at. Mint a new key from the dashboard and revoke the old one. |
invalid_amount | 400 | amount_usdc is missing, not a positive decimal string, below $0.01, or above the $10,000.00 per-order ceiling. |
invalid_webhook_url | 400 | webhook_url failed SSRF validation (non-HTTPS, loopback, private IP, etc.). Use a public HTTPS endpoint. |
spend_limit_exceeded | 403 | This key has reached its USDC spend limit. |
order_not_found | 404 | Order does not exist or belongs to another key. |
idempotency_conflict | 409 | Idempotency-Key was reused with a different request body. Mint a new key or resend the original body. |
rate_limit_exceeded | 429 | Too many orders (60/hour) or too many polls (600/minute). Back off and retry; the window is rolling. |
service_temporarily_unavailable | 503 | Circuit 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:
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.
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().
{
"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