Guides
Accept a payment
This guide wires the Checkout API into a real store: a Pay with USDC button that creates a session, a redirect to the hosted page, and a return page that confirms the payment on your server.
Before you start
- A
sk_test_secret key from the console, stored asSAPHRA_SECRET_KEYon your server. - A place in your database to store the session id against each order.
- Your order total as a decimal string (for example
"25.00"). Between 0.50 and 10,000 per session.
The integration
// server.js — npm install express
import express from "express";
const app = express();
app.use(express.json());
const API = process.env.SAPHRA_API_BASE ?? "https://sandbox.api.saphra.one/v1";
const KEY = process.env.SAPHRA_SECRET_KEY; // sk_test_…
async function saphra(path, init = {}) {
const response = await fetch(`${API}${path}`, {
...init,
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
...init.headers,
},
});
const body = await response.json();
if (!response.ok) {
throw Object.assign(new Error(body.error.message), body.error, { status: response.status });
}
return body;
}
// 1. The buyer clicks "Pay with USDC".
app.post("/checkout", async (req, res) => {
const order = await db.orders.findForCart(req.body.cartId); // your code
const session = await saphra("/checkout/sessions", {
method: "POST",
// One key per order: a retry can never create a second session.
headers: { "Idempotency-Key": `order-${order.id}` },
body: JSON.stringify({
amount: order.total, // "25.00" — a string, never a float
currency: "USDC",
description: `Order #${order.number}`,
client_reference_id: String(order.id),
customer_email: order.email,
line_items: order.lines.map((line) => ({
name: line.title,
quantity: line.quantity,
unit_amount: line.unitPrice,
})),
metadata: { cart_id: req.body.cartId },
success_url: "https://shop.example/thanks?session_id={CHECKOUT_SESSION_ID}",
cancel_url: "https://shop.example/cart",
}),
});
await db.orders.update(order.id, { saphraSessionId: session.id });
res.redirect(303, session.url);
});
// 2. The buyer comes back. Check the session on the server before trusting it.
app.get("/thanks", async (req, res) => {
const session = await saphra(`/checkout/sessions/${encodeURIComponent(req.query.session_id)}`);
const order = await db.orders.findBySession(session.id);
if (session.status === "complete") {
await fulfil(order, session); // idempotent: safe to call twice
return res.render("thanks", { order });
}
res.render("pending", { order }); // e.g. a card payment still clearing
});
app.listen(3000);What the code does
Create one session per order
Send the order total as
amount. Use the order id as theIdempotency-Keyso a double click or a network retry returns the same session instead of creating a second one. Put your order id inclient_reference_idso you can find the order from the session later.Show the buyer what they're paying for
Either a
description(up to 140 characters) orline_items. Line items are for display; they must add up toamountexactly or the request is refused.Store the session id, then redirect
Save
session.idon the order, then answer with a303redirect tosession.url. Don’t open it in an iframe: the page refuses to be framed.Confirm on the return page
Your
success_urlreceivessession_id. Retrieve the session with your secret key and fulfil only whenstatusiscomplete. The redirect itself proves nothing: anyone can open that URL.Catch payments the buyer never came back from
Some buyers close the tab after paying. Run a job every few minutes that retrieves your recent
opensessions (or lists them) and fulfils any that turnedcomplete. See reconciling.
Redirect URLs
| Rule | Detail |
|---|---|
| https only | http is allowed only for localhost with a sandbox key, so you can test on your machine. |
| Template | {CHECKOUT_SESSION_ID} anywhere in the URL is replaced with the session id. It is the only placeholder. |
| Length | Up to 2,048 characters. |
| No credentials | URLs with a username or password (https://user:pass@…) are refused. |
| cancel_url | Optional. Shown as “Cancel and return” and used for the Back button on expired sessions. |
Choosing an expiry
A session stays payable for 60 minutes by default. Set expires_in_minutes from 30 to 1,440 (24 hours). If a card or cross-chain payment is already on its way when the time runs out, the session stays open for it (up to 72 hours), because money that was sent must never go unmatched.
When the buyer abandons the order
If the cart changes or the order is cancelled on your side, call expire so the old link can’t be paid. It is refused once money has arrived.
