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_live_ secret key from the console, stored as SAPHRA_SECRET_KEY on 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://api.saphra.one/v1";
const KEY = process.env.SAPHRA_SECRET_KEY; // sk_live_…

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

  1. Create one session per order

    Send the order total as amount. Use the order id as the Idempotency-Key so a double click or a network retry returns the same session instead of creating a second one. Put your order id in client_reference_id so you can find the order from the session later.

  2. Show the buyer what they're paying for

    Either a description (up to 140 characters) or line_items. Line items are for display; they must add up to amount exactly or the request is refused.

  3. Store the session id, then redirect

    Save session.id on the order, then answer with a 303 redirect to session.url. Don’t open it in an iframe: the page refuses to be framed.

  4. Confirm on the return page

    Your success_url receives session_id. Retrieve the session with your secret key and fulfil only when status is complete. The redirect itself proves nothing: anyone can open that URL.

  5. 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 open sessions (or lists them) and fulfils any that turned complete. See reconciling.

Redirect URLs

RuleDetail
https onlyhttp 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.
LengthUp to 2,048 characters.
No credentialsURLs with a username or password (https://user:pass@…) are refused.
cancel_urlOptional. 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.