Web Integration Guide
End-to-end walkthrough for accepting payments on your website with Decal's hosted checkout.
Accept payments on your website with Decal's hosted checkout. This guide walks through the complete flow: adding a checkout button, creating a session from your backend, fulfilling the order when payment completes, and showing a success page to your customer.
This guide assumes a standard web application — a server-rendered site, a single-page app with an API backend, or any framework that can make server-side HTTP requests and host a webhook endpoint. You do not need a Solana wallet integration on your frontend; Decal hosts the payment page and settles funds to your configured account automatically.
Architecture overview
Your website and Decal split responsibilities cleanly:
┌─────────────────────┐ ┌─────────────────────┐
│ │ │ │
│ Your Website │ │ Decal │
│ │ │ │
│ ┌───────────────┐ │ │ │
│ │ Cart / order │ │ │ │
│ │ in your DB │ │ │ │
│ └───────┬───────┘ │ │ │
│ │ │ │ │
│ ┌───────▼───────┐ │ Create session │ ┌───────────────┐ │
│ │ Your backend │──│───────────────────►│ │ Sessions API │ │
│ └───────────────┘ │ │ └───────┬───────┘ │
│ │ │ │ │
│ ┌───────────────┐ │ Webhook POST │ ┌───────▼───────┐ │
│ │ Webhook │◄─│────────────────────│ │ Hosted │ │
│ │ handler │ │ │ │ checkout page │ │
│ └───────────────┘ │ │ └───────┬───────┘ │
│ │ │ │ │
│ ┌───────────────┐ │ Redirect │ ┌───────▼───────┐ │
│ │ Thank-you / │◄─│────────────────────│ │ Payment │ │
│ │ success page │ │ │ │ processing │ │
│ └───────────────┘ │ │ └───────────────┘ │
│ │ │ │
└─────────────────────┘ └─────────────────────┘You handle: Cart / order record in your database, session creation, webhook fulfillment, thank-you page.
Decal handles: Hosted payment UI, payment collection, webhook delivery, customer redirect.
End-to-end flow
Your Server Decal Customer
─────────────── ───────── ────────────
│ │ │
│ │ 1. Clicks "Pay" on your │
│ │ checkout page │
│◄─────────────────────────────│──────────────────────────────│
│ │ │
│ 2. POST /checkout/sessions │ │
│ (items, customer, URLs) │ │
│─────────────────────────────►│ │
│ │ │
│ Session created │ │
│ { id, url, order } │ │
│◄─────────────────────────────│ │
│ │ │
│ 3. Redirect customer to │ │
│ session.url │ │
│──────────────────────────────│─────────────────────────────►│
│ │ │
│ │ 4. Customer pays │
│ │◄─────────────────────────────│
│ │ │
│ 5. Webhook: session.completed │
│◄─────────────────────────────│ │
│ │ │
│ Return 200 OK │ │
│─────────────────────────────►│ │
│ │ │
│ │ 6. Redirect to successUrl │
│ │─────────────────────────────►│
│ │ │
│ 7. Customer arrives at │ │
│ your thank-you page │ │
│◄─────────────────────────────│──────────────────────────────│
│ │ │The webhook (step 5) arrives before the customer is redirected (step 6), so your fulfillment
logic runs before the customer lands on your successUrl thank-you page.
Implementation
Create the checkout session from your backend
When the customer clicks "Pay" (or "Place order", "Buy now", etc.) on your site, your frontend posts the cart to your backend. Your backend then calls the Decal Sessions API to create a checkout session.
Never call the Sessions API directly from the browser — your API key is a secret key and must stay on the server.
// app/api/checkout/route.ts (Next.js App Router example)
export async function POST(req: Request) {
const { cartId } = await req.json();
// 1. Load the cart from your database and create an internal order record
const cart = await db.cart.findUnique({ where: { id: cartId } });
const localOrder = await db.order.create({
data: {
cartId: cart.id,
userId: cart.userId,
status: "pending_payment",
},
});
// 2. Create a Decal checkout session for that order
const response = await fetch("https://api.usedecal.com/v0/checkout/sessions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DECAL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
externalId: localOrder.id,
items: cart.lines.map(line => ({
name: line.productName,
quantity: line.quantity,
unitPrice: line.unitPriceCents,
externalId: line.sku,
})),
taxes: [{ type: "additive", amount: cart.taxCents, name: "Sales Tax" }],
discounts: cart.promo
? [{ type: "percentage", rate: cart.promo.rate, name: cart.promo.label }]
: [],
customer: {
externalId: cart.userId,
email: cart.user.email,
name: cart.user.name,
},
successUrl: `https://yoursite.com/thank-you?order=${localOrder.id}&session={SESSION_ID}`,
callbackUrl: `https://yoursite.com/api/webhooks/decal`,
requireFromCustomer: {
shippingAddress: cart.requiresShipping,
},
}),
});
const { checkoutSession } = await response.json();
// 3. Store the Decal IDs on your order for later lookup
await db.order.update({
where: { id: localOrder.id },
data: {
decalSessionId: checkoutSession.id,
decalOrderId: checkoutSession.order.id,
},
});
// 4. Return the hosted checkout URL so the frontend can redirect
return Response.json({ url: checkoutSession.url });
}// Frontend click handler
async function handleCheckout(cartId: string) {
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cartId }),
});
const { url } = await res.json();
window.location.href = url;
}Key points:
- All monetary amounts are in cents and USD currency based (e.g.
$29.99→2999). - Top-level
externalIdlinks the Decal order to your internal order record. It's unique per organization and mode, so retrying the same session-creation call with the sameexternalIdcan never create two Decal orders — you'll get409 DUPLICATE_EXTERNAL_IDback on the retry. Use this as your idempotency key. customer.externalIdlinks the Decal customer record to your user — the same user gets the same Decal customer record across future purchases.requireFromCustomer.shippingAddress: truemakes the hosted checkout page collect a shipping address from the customer. Other supported fields arephoneandlocation.{SESSION_ID}and{ORDER_ID}insuccessUrlandcallbackUrlare replaced with the formatted session and order IDs (cs_live_...,ord_live_...) when the session is created. Include whichever your thank-you or webhook handler needs to look up.
See OrderItem, OrderTax, OrderDiscount, and OrderCustomer for every available field.
Redirect the customer to the hosted checkout
The simplest integration is a full-page redirect:
window.location.href = checkoutSession.url;The customer lands on Decal's hosted checkout page at pay.usedecal.com, which displays the order
summary and the payment methods you have enabled (stablecoins, card, stored value, etc.). They can
pay with their preferred method without leaving the flow.
If you want the checkout to feel more embedded, you can open it in a new tab or a popup — but a full-page redirect is the most reliable across browsers and devices and is what we recommend.
Fulfill the order via webhook
After the customer pays, Decal POSTs a checkout.session.completed event to your callbackUrl with
the full session and order data. This is the signal to fulfill the order in your system:
// app/api/webhooks/decal/route.ts
export async function POST(req: Request) {
const { event, session } = await req.json();
if (event === "checkout.session.completed") {
const { order } = session;
const internalOrderId = order.externalId;
if (internalOrderId) {
await db.order.update({
where: { id: internalOrderId },
data: {
status: "paid",
paidAmountCents: order.amounts.paid,
paidAt: order.paidAt,
shippingAddress: order.shippingAddress,
},
});
// Enqueue follow-up work rather than doing it here
await jobs.enqueue("send-order-confirmation", { internalOrderId });
await jobs.enqueue("reserve-inventory", { internalOrderId });
}
}
return new Response("ok", { status: 200 });
}Important
Your endpoint must respond within 5 seconds or the request will time out. Fast database writes are usually safe inline; enqueue anything slower (emails, third-party APIs, batch work) on a background job. See Webhooks for delivery details.
What the webhook payload contains:
The session.order in the webhook is the complete, post-payment order with:
status: "completed"andpaymentStatus: "paid"amounts.paid— the actual amount received (may differ fromtotalin partial-pay scenarios)paidAt— when payment was confirmed- Full
customer,items,taxes,discounts, and (if collected)shippingAddress - Your
externalIdandmetadata, echoed back so you can correlate with your local records
See Checkout Sessions Event for the full schema.
Show the thank-you page
After firing the webhook, Decal redirects the customer to your successUrl. Since the webhook ran
first, your database already reflects the paid order:
// app/thank-you/page.tsx (Next.js)
export default async function ThankYouPage({ searchParams }: { searchParams: { order: string; session: string } }) {
const order = await db.order.findUnique({ where: { id: searchParams.order } });
if (order?.status === "paid") {
return <OrderConfirmation order={order} />;
}
// Webhook hasn't arrived yet (rare) — verify directly with Decal
const { checkoutSession } = await fetch(
`https://api.usedecal.com/v0/checkout/sessions/${searchParams.session}`,
{ headers: { Authorization: `Bearer ${process.env.DECAL_API_KEY}` } },
).then(r => r.json());
if (checkoutSession.order.paymentStatus === "paid") {
await db.order.update({
where: { id: searchParams.order },
data: { status: "paid", paidAt: checkoutSession.order.paidAt },
});
return <OrderConfirmation order={checkoutSession.order} />;
}
return <PaymentPendingPage orderId={order.id} />;
}The API fallback covers the rare case where the customer arrives before the webhook is processed (network blip, server restart, cold start) and keeps your thank-you page correct.
No successUrl?
You don't have to provide one. If successUrl is omitted, the customer stays on Decal's hosted
checkout page after paying and sees a built-in success state with the order summary, amount paid,
and confirmation details. This is a common choice for checkouts that don't need a post-payment
landing page of their own (e.g. one-off payment links, invoice-style flows) — rely on the webhook
alone for fulfillment and skip the thank-you-page handler entirely.
Handle abandoned and cancelled checkouts
Customers don't always complete payment. They close the tab, get distracted, or change their mind.
No webhook fires in those cases — the session simply remains active with an unpaid order, and
expires after its expiresInMinutes window (default: 60 minutes). You have a few options:
Let it expire. The simplest path. The session becomes inactive, and your local order is still in
pending_payment — you can show it in the customer's "abandoned cart" flow or clean it up on a
schedule.
Reuse the URL. The session.url stays valid until the session expires. If the same customer
comes back — or you email them a "complete your order" link — you can send them the same URL and
they pick up where they left off. Use the
update order API endpoint as needed to update the
customer's order before redirecting them back to the session url for payment.
Cancel explicitly. If you know the order should not proceed (the customer changed the cart, items sold out, a fraud check failed), cancel the underlying order from your backend:
await fetch(`https://api.usedecal.com/v0/orders/${decalOrderId}/cancel`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.DECAL_API_KEY}` },
});Cancellation is idempotent — calling it on an already-cancelled order returns 200. Only unpaid
orders can be cancelled; orders that have received payment return 400. After cancellation, the
checkout page shows a cancelled state if the customer tries to access it.
Common website integration patterns
Linking Decal orders to your internal records
Use two complementary mechanisms to keep your database and Decal in sync:
- Top-level
externalId— set at session creation, stored on the order, returned on every API response and webhook. Makes webhook handling a constant-time lookup and — because it's unique per org/mode — doubles as your idempotency key. Retrying a failed session-creation call with the sameexternalIdreturns409 DUPLICATE_EXTERNAL_IDinstead of creating a second order. - Store
session.idandsession.order.idon your local order at creation time. Lets you look up, cancel, or extend the Decal session later when you don't have the webhook payload handy.
// When creating the session
body: JSON.stringify({
externalId: localOrder.id,
// ...
});
// When handling the webhook
const localId = session.order.externalId;Use metadata for additional key-value data you want round-tripped on the order (e.g. a source
attribution, campaign ID) — not for the primary linkage, since metadata has no uniqueness
guarantee and no structural validation.
Pre-filling customer information
If the buyer is signed in on your website or app, pass their data to customer so they don't need
to re-enter it:
customer: {
externalId: user.id, // your internal user ID
email: user.email, // optional
phone: user.phoneNumber, // optional
name: user.displayName, // optional
}Decal resolves or creates a customer record keyed by externalId. Subsequent purchases by the same
user attach to the same Decal customer, so you get a consistent identity for reporting and repeat
customer detection.
For guest checkout, omit customer entirely — the hosted checkout page collects the email itself.
Collecting shipping addresses
For physical goods, set requireFromCustomer.shippingAddress: true. The hosted checkout page will
collect a complete address before payment, and it will be included on the order in the webhook
payload:
requireFromCustomer: {
shippingAddress: true;
}The returned order.shippingAddress follows the
ShippingAddress schema. Persist it on your local order so
your fulfillment / shipping pipeline can use it.
Preventing duplicate sessions on double-clicks
Frontend onClick handlers often fire twice — users are impatient, networks are slow. Defend
against creating multiple sessions for one cart at three layers:
- Disable the button while the request is in flight (UX layer).
- Reuse an existing session if your local order already has a
decalSessionIdand it's still active (application layer). - Rely on the
externalIdunique constraint as the final backstop: if two parallel requests reach the API, one succeeds and the other gets409 DUPLICATE_EXTERNAL_ID— catch that and fall back to looking up / reusing the winning session.
const existing = await db.order.findFirst({
where: { cartId, status: "pending_payment" },
});
if (existing?.decalSessionId) {
const { checkoutSession } = await fetch(
`https://api.usedecal.com/v0/checkout/sessions/${existing.decalSessionId}`,
{ headers: { Authorization: `Bearer ${process.env.DECAL_API_KEY}` } },
).then(r => r.json());
// `status` is the canonical session state; `active` is a deprecated boolean alias
if (checkoutSession.status === "pending") return Response.json({ url: checkoutSession.url });
}
try {
// Create a new session as in Step 1, passing externalId: localOrder.id
} catch (err) {
if (err.code === "DUPLICATE_EXTERNAL_ID") {
// A concurrent request won the race — look up the order we already created
// and fetch its active session.
const order = await db.order.findUnique({ where: { id: localOrder.id } });
if (order?.decalSessionId) {
return Response.json({ url: buildCheckoutUrl(order.decalSessionId) });
}
}
throw err;
}Taxes and discounts
Compute taxes and discounts on your side so they match what the customer saw on your cart page, then pass them in explicitly. Decal renders them on the checkout page exactly as provided:
taxes: [{ type: "additive", amount: 230, name: "Sales Tax" }],
discounts: [
{ type: "fixed", amount: 500, name: "Welcome Credit" },
{ type: "percentage", rate: 10, name: "Loyalty Reward" },
],"additive"taxes are added on top of the subtotal"inclusive"means they're already built into your item prices and are shown for display only.- Percentage discounts are calculated against the full
itemssubtotal automatically.
See OrderTax and OrderDiscount.
Extending a session's expiry
If a customer is still on the checkout page but the session is about to expire (e.g. they stepped away), extend it rather than forcing them to restart:
await fetch(`https://api.usedecal.com/v0/checkout/sessions/${sessionId}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.DECAL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ extendExpiry: 30 }), // adds 30 minutes
});What to store in your database
At minimum, persist these identifiers on your local order when the session is created:
| Field | Example | Purpose |
|---|---|---|
session.id | cs_live_clx8k2m9abc1234 | Match webhook events, verify payments, extend |
session.order.id | ord_live_xyz789 | Query order status, cancel, or update the order |
session.url | https://pay.usedecal.com/... | Re-send to the customer if they abandon and return |
Pass externalId: localOrder.id on the Decal side so webhook and success-page lookups don't require
an extra index on decalSessionId, and so duplicate-submission attempts fail safely at the database
unique constraint.
Verifying payment status
There are three ways to confirm payment, in order of preference:
- Webhook (preferred)
- Your callbackUrl receives the event automatically.
- Arrives before the customer's redirect.
- API verification (recommended fallback on success page)
- GET
/v0/checkout/sessions/:sessionId - Check
session.order.paymentStatus === "paid"
- Order lookup
- GET
/v0/orders/:orderId - Check
order.paymentStatus === "paid"
For production, use the webhook as the primary notification and the session API as a thank-you-page fallback. Orders that took webhook delivery timeouts or where the user clears cookies mid-redirect are still recoverable this way.
Error handling
Session creation fails
Always handle non-2xx responses from the Sessions API and show the customer a useful message
instead of a blank screen:
const response = await fetch("https://api.usedecal.com/v0/checkout/sessions", {
/* ... */
});
if (!response.ok) {
const error = await response.json();
// error.code — machine-readable (e.g. "SESSION_CREATION_FAILED")
// error.message — human-readable description
// error.errorId — unique ID for support lookups
logger.error({ err: error }, "Decal session creation failed");
return Response.json(
{ message: "We couldn't start checkout. Please try again." },
{ status: 500 },
);
}Common codes on session creation: SESSION_CREATION_FAILED, INVALID_PAYMENT_DESTINATION,
ORGANIZATION_NOT_FOUND. See the full list in the
Checkout Sessions API reference.
Webhook doesn't arrive
Webhook delivery is best-effort with a 5-second timeout and no retries. If your endpoint is down or
slow during a payment, the payment still completes — you just don't get pinged. The thank-you page
API fallback in Step 4 handles this case. For belt-and-suspenders resilience, consider a periodic
reconciliation job that lists orders from the last N hours and syncs any that are paid on Decal's
side but still pending_payment locally:
const res = await fetch("https://api.usedecal.com/v0/orders?paymentStatus=paid&limit=100", {
headers: { Authorization: `Bearer ${process.env.DECAL_API_KEY}` },
});
const { orders } = await res.json();
for (const order of orders) {
const localId = order.metadata?.internalOrderId;
if (localId) await ensureLocalOrderMarkedPaid(localId, order);
}Session expires
Sessions expire after expiresInMinutes (default: 60). The session becomes inactive, the order
stays open and unpaid, and the checkout URL no longer accepts payments. Your local order is
untouched — clean it up via whatever "abandoned checkout" policy fits your business.
Quick reference
| Step | Your website does | Decal does |
|---|---|---|
| 1 | Collect cart, render "Pay" button | — |
| 2 | Create local order, call Sessions API | Create session + order |
| 3 | Redirect customer to session.url | Host the checkout page |
| 4 | — | Collect payment |
| 5 | Receive webhook, fulfill order | POST checkout.session.completed |
| 6 | Render thank-you page | Redirect customer to successUrl |
| — | (Optional) Verify via API as fallback | Return session / order data |
| — | (Optional) Cancel order on abandon | Mark order as cancelled |
Related docs
- Checkout Sessions API — Full endpoint reference.
- Orders API — Manage and query orders from your backend.
- Events — Event catalog and payload schemas.
- Webhooks — Delivery semantics and handling guide.
- Order types — Order, items, taxes, discounts, customer schemas.
- Payment Routing — Where funds settle and how to override the default destination.