POS Integration Guide

End-to-end walkthrough for integrating Decal payments into a point-of-sale system.

Integrate Decal payments into your point-of-sale system. This guide walks through the complete flow: building an order, creating a checkout session, collecting payment, and handling the result.

The integration is the same whether your POS is a web app, native desktop application, mobile app, or a tablet-based terminal — anything that can make HTTP requests and direct a customer to a URL to complete their payment (e.g. redirect in their browser or display a QR code to scan-and-pay).

Architecture overview

Your application and Decal split responsibilities cleanly:

┌─────────────────────┐                    ┌─────────────────────┐
│                     │                    │                     │
│     Your App        │                    │       Decal         │
│                     │                    │                     │
│  ┌───────────────┐  │    API calls       │  ┌───────────────┐  │
│  │ Order builder │──│───────────────────►│  │ Sessions API  │  │
│  └───────────────┘  │                    │  └───────┬───────┘  │
│                     │                    │          │          │
│  ┌───────────────┐  │    Webhook POST    │  ┌───────▼───────┐  │
│  │ Webhook       │◄─│────────────────────│  │ Hosted        │  │
│  │ handler       │  │                    │  │ checkout page │  │
│  └───────────────┘  │                    │  └───────┬───────┘  │
│                     │                    │          │          │
│  ┌───────────────┐  │    Redirect        │  ┌───────▼───────┐  │
│  │ Success /     │◄─│────────────────────│  │ Payment       │  │
│  │ cancel page   │  │                    │  │ processing    │  │
│  └───────────────┘  │                    │  └───────────────┘  │
│                     │                    │                     │
└─────────────────────┘                    └─────────────────────┘

You handle: Order building, session creation, webhook processing, fulfillment.

Decal handles: Hosted payment UI, payment processing, webhook delivery, redirects.

End-to-end flow

   Your Server                     Decal                         Customer
 ───────────────                 ─────────                     ────────────
    │                                │                              │
    │  1. POST /checkout/sessions    │                              │
    │  (items, taxes, callbackUrl)   │                              │
    │───────────────────────────────►│                              │
    │                                │                              │
    │  Session created               │                              │
    │  { id, url, order }            │                              │
    │◄───────────────────────────────│                              │
    │                                │                              │
    │  2. Send customer to           │                              │
    │     session.url                │                              │
    │────────────────────────────────│─────────────────────────────►│
    │                                │                              │
    │                                │  3. Customer opens checkout  │
    │                                │◄─────────────────────────────│
    │                                │                              │
    │                                │  4. Customer pays            │
    │                                │◄─────────────────────────────│
    │                                │                              │
    │  5. Webhook: session.completed |                              │
    │◄───────────────────────────────│                              │
    │                                │                              │
    │  Return 200 OK                 │                              │
    │───────────────────────────────►│                              │
    │                                │                              │
    │                                │  6. Redirect to successUrl   │
    │                                │─────────────────────────────►│
    │                                │                              │
    │  7. Customer arrives at        │                              │
    │     your success page          │                              │
    │◄───────────────────────────────│──────────────────────────────│
    │                                │                              │

The webhook (step 5) arrives before the customer redirects (step 6), so your server knows about the payment before the customer arrives at your success page.

Implementation

Create the checkout session

When a customer is ready to pay, your backend calls the Decal API with the order details:

curl -X POST https://api.usedecal.com/v0/checkout/sessions \
  -H "Authorization: Bearer sk_live_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "externalId": "ticket_7821",
    "items": [
      { "name": "Espresso", "quantity": 2, "unitPrice": 450 },
      { "name": "Croissant", "quantity": 1, "unitPrice": 399 }
    ],
    "taxes": [{ "type": "additive", "amount": 117, "name": "Sales Tax" }],
    "discounts": [{ "type": "percentage", "rate": 10, "name": "Loyalty Reward" }],
    "callbackUrl": "https://yourapp.com/webhooks/decal",
    "successUrl": "https://yourapp.com/checkout/success?session={SESSION_ID}",
    "customer": {
      "email": "customer@example.com"
    }
  }'
const { checkoutSession } = await fetch("https://api.usedecal.com/v0/checkout/sessions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${DECAL_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    externalId: posTicketId, // your POS ticket / check ID
    items: [
      { name: "Espresso", quantity: 2, unitPrice: 450 },
      { name: "Croissant", quantity: 1, unitPrice: 399 },
    ],
    taxes: [{ type: "additive", amount: 117, name: "Sales Tax" }],
    discounts: [{ type: "percentage", rate: 10, name: "Loyalty Reward" }],
    callbackUrl: "https://yourapp.com/webhooks/decal",
    successUrl: "https://yourapp.com/checkout/success?session={SESSION_ID}",
    customer: { email: "customer@example.com" },
  }),
}).then(r => r.json());

// Store these for later
const sessionId = checkoutSession.id; // "cs_live_clx8k2m9abc1234"
const orderId = checkoutSession.order.id; // "ord_live_xyz789"
const paymentUrl = checkoutSession.url; // "https://pay.usedecal.com/s/clx8k2m9abc1234"

Key points:

  • All amounts are in cents (e.g. $4.50 = 450)
  • callbackUrl is where Decal sends the webhook after payment
  • {SESSION_ID} and {ORDER_ID} in URLs are replaced with the formatted session and order IDs (cs_live_..., ord_live_...) at creation time. Use whichever your success and webhook handlers need for lookup.
  • externalId links the Decal order to your POS ticket/check ID. It's unique per organization and mode — retrying the same session-creation call with the same externalId returns 409 DUPLICATE_EXTERNAL_ID instead of creating a second order. Use it as an idempotency key so a flaky terminal or duplicate "Pay with Decal" tap can't produce two orders for the same ticket.
  • Store session.id and session.order.id in your database for later reference

See OrderItem, OrderTax, OrderDiscount, and OrderCustomer for all available fields.

Direct the customer to pay

Direct the customer to session.url in order to complete their payment for your order. How you do this depends on your platform:

PlatformApproach
Web appRedirect the browser: window.location.href = session.url
Mobile appOpen in a system browser or in-app webview
POS terminalDisplay a QR code encoding session.url for the customer to scan
Email / SMSSend the URL as a payment link
API-only (headless)Return the URL to your client and let it handle navigation

The customer lands on Decal's hosted checkout page where they complete payment using their preferred method based on what you have configured and enabled (stablecoins, card, stored value, etc.).

Receive the webhook

After the customer pays, Decal sends a POST request to your callbackUrl with the full session and order data:

// Express.js example
app.post("/webhooks/decal", (req, res) => {
  const { event, session } = req.body;

  if (event === "checkout.session.completed") {
    const { order } = session;

    // Look up the POS ticket by the externalId you passed at creation time
    await db.orders.update({
      where: { posTicketId: order.externalId },
      data: {
        status: "paid",
        paidAmount: order.amounts.paid,
        paidAt: order.paidAt,
      },
    });
  }

  // Always return 200 quickly
  res.status(200).send("ok");
});
// Next.js route handler example
export async function POST(req: Request) {
  const { event, session } = await req.json();

  if (event === "checkout.session.completed") {
    const { order } = session;

    await db.orders.update({
      where: { posTicketId: order.externalId },
      data: {
        status: "paid",
        paidAmount: order.amounts.paid,
        paidAt: order.paidAt,
      },
    });
  }

  return new Response("ok", { status: 200 });
}

Important

Your endpoint must respond within 5 seconds. Quick database writes are fine — avoid calling third-party APIs or sending emails before responding. See Webhooks for full 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 reflecting the actual amount received
  • paidAt timestamp of when payment was confirmed
  • Full customer, items, taxes, discounts data

See Checkout Sessions Event for the full schema.

Handle the customer redirect

After payment, the customer is redirected to your successUrl. Since the webhook already fired, your server already knows the payment succeeded:

// GET /checkout/success?session=cs_live_clx8k2m9abc1234
export async function GET(req: Request) {
  const url = new URL(req.url);
  const sessionId = url.searchParams.get("session");

  // Look up the order in your database (already marked paid by webhook)
  const order = await db.orders.findFirst({
    where: { decalSessionId: sessionId },
  });

  if (order?.status === "paid") {
    return renderSuccessPage(order);
  }

  // Webhook hasn't arrived yet — verify directly with Decal
  const { checkoutSession } = await fetch(
    `https://api.usedecal.com/v0/checkout/sessions/${sessionId}`,
    { headers: { Authorization: `Bearer ${DECAL_API_KEY}` } },
  ).then(r => r.json());

  if (checkoutSession.order.paymentStatus === "paid") {
    return renderSuccessPage(checkoutSession.order);
  }

  return renderPendingPage();
}

Handle unpaid sessions and cancellation

If a customer doesn't complete payment — they close the browser tab, navigate away, or simply don't pay — the order stays open and unpaid. No webhook fires. You have three options:

Retry: Send the customer the same session.url again. The session stays active until it expires.

Cancel: Void the order from your server when you know it should not proceed (e.g. the POS operator voids the transaction, or the customer asks to start over):

const orderId = checkoutSession.order.id; // stored from step 1

const response = await fetch(`https://api.usedecal.com/v0/orders/${orderId}/cancel`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${DECAL_API_KEY}`,
  },
});

const order = await response.json();
console.log(order.status); // => "cancelled"

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.

Expire: Let the session expire naturally (default: 60 minutes). The order remains open and unpaid — clean up on your side as needed.

What to store in your database

At minimum, store these identifiers from the session creation response:

FieldExamplePurpose
session.idcs_live_clx8k2m9abc1234Match webhook events, verify payments
session.order.idord_live_xyz789Query order status, cancel orders
session.urlhttps://pay.usedecal.com/...Send to customer for payment

Send your POS ticket / check ID as externalId on creation — the session and webhook payloads echo it back on the order as the top-level externalId field, so you can look up the local ticket without storing an extra mapping. externalId is unique per organization and mode, so the database enforces one Decal order per ticket ID.

Verifying payment status

There are three ways to confirm payment, in order of preference:

┌─────────────────────────────────────────────────────────────────────┐
│                                                                     │
│  1. Webhook (preferred)                                             │
│     Your callbackUrl receives the event automatically.              │
│     Fastest — arrives before customer redirects.                    │
│                                                                     │
│  2. API verification (recommended as fallback)                      │
│     GET /v0/checkout/sessions/:sessionId                            │
│     Check session.order.paymentStatus === "paid"                    │
│                                                                     │
│  3. Order lookup                                                    │
│     GET /v0/orders/:orderId                                         │
│     Check order.paymentStatus === "paid"                            │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

For production systems, use the webhook as your primary notification and the API as a fallback for edge cases (webhook timeout, server restart during delivery, etc.).

Error handling

Session creation fails

If the API returns an error when creating a session, handle it in your application:

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
  console.error(`Checkout failed: ${error.message}`);
  return;
}

Webhook doesn't arrive

The webhook is best-effort with a 5-second timeout. If your endpoint is down or slow, the payment still completes but you may not get notified. Handle this on your success page by falling back to an API call:

// On your success page, if webhook hasn't marked the order as paid
const { checkoutSession } = await fetch(
  `https://api.usedecal.com/v0/checkout/sessions/${sessionId}`,
  { headers: { Authorization: `Bearer ${DECAL_API_KEY}` } },
).then(r => r.json());

if (checkoutSession.order.paymentStatus === "paid") {
  // Process the payment in your system
}

Session expires

Sessions expire after the configured expiresInMinutes (default: 60 minutes). If a customer hasn't paid and the session expires:

  • The session's active field becomes false
  • The order remains open and unpaid
  • You can create a new session for the same items if the customer wants to try again

Testing

Use your sk_test_ API key to create test sessions. Test sessions:

  • Use sandbox payment methods (no real money moves)
  • Return test-mode IDs (cs_test_..., ord_test_...)
  • Fire webhooks to your callbackUrl just like live sessions
  • Are fully isolated from live data

Tools like webhook.site or ngrok are useful for inspecting webhook payloads during development without deploying your webhook handler.

Quick reference

StepYou doDecal does
1Build order, call Sessions APICreate session + order
2Send customer to session.urlHost the checkout page
3Collect payment from customer
4Receive webhook, update your databaseFire webhook to your callbackUrl
5Serve success pageRedirect customer to successUrl
6(Optional) Verify via APIReturn session/order data
Cancel order if neededMark order as cancelled