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.992999).
  • Top-level externalId links the Decal order to your internal order record. It's unique per organization and mode, so retrying the same session-creation call with the same externalId can never create two Decal orders — you'll get 409 DUPLICATE_EXTERNAL_ID back on the retry. Use this as your idempotency key.
  • customer.externalId links the Decal customer record to your user — the same user gets the same Decal customer record across future purchases.
  • requireFromCustomer.shippingAddress: true makes the hosted checkout page collect a shipping address from the customer. Other supported fields are phone and location.
  • {SESSION_ID} and {ORDER_ID} in successUrl and callbackUrl are 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" and paymentStatus: "paid"
  • amounts.paid — the actual amount received (may differ from total in partial-pay scenarios)
  • paidAt — when payment was confirmed
  • Full customer, items, taxes, discounts, and (if collected) shippingAddress
  • Your externalId and metadata, 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 same externalId returns 409 DUPLICATE_EXTERNAL_ID instead of creating a second order.
  • Store session.id and session.order.id on 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 decalSessionId and it's still active (application layer).
  • Rely on the externalId unique constraint as the final backstop: if two parallel requests reach the API, one succeeds and the other gets 409 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 items subtotal 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:

FieldExamplePurpose
session.idcs_live_clx8k2m9abc1234Match webhook events, verify payments, extend
session.order.idord_live_xyz789Query order status, cancel, or update the order
session.urlhttps://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:

  1. Webhook (preferred)
  • Your callbackUrl receives the event automatically.
  • Arrives before the customer's redirect.
  1. API verification (recommended fallback on success page)
  • GET /v0/checkout/sessions/:sessionId
  • Check session.order.paymentStatus === "paid"
  1. 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

StepYour website doesDecal does
1Collect cart, render "Pay" button
2Create local order, call Sessions APICreate session + order
3Redirect customer to session.urlHost the checkout page
4Collect payment
5Receive webhook, fulfill orderPOST checkout.session.completed
6Render thank-you pageRedirect customer to successUrl
(Optional) Verify via API as fallbackReturn session / order data
(Optional) Cancel order on abandonMark order as cancelled
  • 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.