Headless checkout
Your products, your cart, your payment UI. Your server sends Swop the cart and Swop returns a payment for the buyer to complete on your page.
Headless checkout gives you full control of the buying experience. It's how swopme.co/shop works.
The flow
- The buyer builds a cart on your site.
- Your server sends the cart to Swop:
POST /checkout-intents. Swop prices everything from your catalogue and returns a checkout. - Your page shows the amount and a way to pay: a USDC payment link/QR code, and a card form if card is offered.
- The buyer pays. Swop detects the payment, creates the order and emails the receipt.
- Your page polls
GET /checkout-intents/{intentId}until it'spaid, and your back office gets acheckout.paidwebhook.
Key scopes: commerce.checkout (create) and commerce.read (poll). commerce.checkout is the only merchant scope that can create something payable, so give it only to the server that needs it.
1. Create the checkout
POST https://apps.apiswop.co/api/v5/merchant/checkout-intents
| Field | Type | Notes |
|---|---|---|
items | array, required | [{ productId, quantity, selectedOptions }]. Up to 60 lines. quantity from 1 to 999 (default 1). selectedOptions looks like { "Color": "Black" }; send one value for every option group the product has. |
customerInfo | object | { name, email, phone, address: { line1, line2, city, state, postalCode, country } }. Required for physical items: name, email, address.line1, address.city, address.postalCode, address.country. |
paymentRail | string | Leave it out for crypto only. Send "buyer_choice" to offer card too, when your account and cart qualify. Any other value is a 400. |
description | string | Optional. Defaults to one built from the items. |
expiresAt | ISO 8601 | Optional. Default 24 hours, maximum 30 days. |
Never send prices
Swop prices every line, including shipping, from your catalogue. A request with price, amount or unitPrice on any item is rejected, not ignored. That way a tampered cart in the browser can never change what the buyer pays.
curl -X POST https://apps.apiswop.co/api/v5/merchant/checkout-intents \
-H "Authorization: Bearer $SWOP_MERCHANT_KEY" \
-H "Content-Type: application/json" \
-d '{
"items": [
{ "productId": "66a1b2c3d4e5f6a7b8c9d0e1", "quantity": 1, "selectedOptions": { "Color": "Black" } }
],
"customerInfo": {
"name": "Ada Buyer",
"email": "ada@example.com",
"address": { "line1": "1 Main St", "city": "Austin", "state": "TX", "postalCode": "78701", "country": "US" }
},
"paymentRail": "buyer_choice"
}'The 201 response is the full checkout. See the checkout-intents reference for every field; these are the ones your page uses:
| Field | Use it to |
|---|---|
intentId | Store with your order; poll with it. |
fees.totalDueAmount | Show the buyer what they pay (USDC, shipping included). |
fees.merchantReceivesAmount | Show yourself what you'll receive after the 0.5% fee. |
paymentRequest | Present the USDC payment (see below). null while the buyer still has to choose card or crypto. |
cardOffered | Whether to show a card option. |
checkoutUrl | Swop's hosted page for this checkout, if you'd rather redirect or frame it. |
expiresAt | When it stops being payable. |
Retries create duplicates
POST /checkout-intents has no idempotency key today. If your request times out and you retry, you may create a second checkout. Only one will ever be paid, but keep the intentId you showed the buyer and ignore the other.
2. Take the payment
Crypto (USDC)
When paymentRequest is set, it's a Solana Pay request for USDC on Solana:
"paymentRequest": {
"rail": "solana_pay",
"amount": 30,
"currency": "USDC",
"tokenMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"url": "solana:…",
"status": "awaiting_payment",
"expiresAt": "2026-10-28T18:00:00.000Z"
}Show paymentRequest.url as an Open your wallet link (mobile) and as a QR code (desktop). The buyer's wallet pays it; Swop sees the transfer on-chain and completes the order. You don't call anything to confirm.
Card
When cardOffered is true, paymentRequest stays null until the buyer picks a method. Show two buttons and call one of these public endpoints. They need no API key: the unguessable intentId authorises them, so you can call them from the browser or your server.
| Buyer picks | Call | You get |
|---|---|---|
| USDC | POST https://apps.apiswop.co/api/v5/checkout-intents/{intentId}/select-crypto (no body) | Confirmation. Read the checkout again to get paymentRequest. |
| Card | POST https://apps.apiswop.co/api/v5/checkout-intents/{intentId}/card-payment-intents (no body) | clientSecret and publishableKey. Mount Stripe's Payment Element with them. |
| Back to USDC after choosing card | POST …/{intentId}/card-payment/release, then select-crypto |
Card numbers go from the buyer's browser straight to Stripe and never touch Swop or your server. These public endpoints are rate-limited (20 requests per 15 minutes per client).
3. Show "paid"
Poll GET /checkout-intents/{intentId} every few seconds while the buyer is on your payment step:
const res = await fetch(`https://apps.apiswop.co/api/v5/merchant/checkout-intents/${intentId}`, {
headers: { Authorization: `Bearer ${process.env.SWOP_MERCHANT_KEY}` },
});
const { data } = await res.json();status | Meaning | Show the buyer |
|---|---|---|
active, pending_payment | Waiting for payment | Keep waiting |
paid, settled | Paid; the order exists | "Payment received" |
conversion_failed, settlement_failed | Swop has the money, but a step after payment failed | "We have your payment. Support will complete your order or refund you." Never say "you weren't charged". |
refunding, refunded | Being refunded / refunded | Say so |
expired, cancelled | No longer payable | Offer to start again |
For card, the checkout can stay active while Stripe processes the charge. Also read GET https://apps.apiswop.co/api/v5/checkout-intents/{intentId}/card-payment (public) and treat a state of transfer_pending, transfer_in_progress or transferred as paid.
Polling is for the buyer's screen. For your order system, use webhooks.
Go-live checklist
- Key in a server environment variable, never in the browser.
- Key has only
commerce.checkoutandcommerce.read(addcommerce.embedonly if you frame). - No prices in the checkout request.
- Physical items send name, email and a full address.
- Your webhook endpoint verifies
Swop-Signatureand de-duplicates deliveries. - You ship on
checkout.paidand book revenue onpayout.released. - Error states never tell a buyer they weren't charged.
- One real low-value purchase completed end to end.