Unlock paid plans in your app

Four pieces: a payment link, a button, a signed webhook, and one API call to check access. Buyers pay in USDC on the chain they already use; monthly and yearly prices can be paid as subscriptions.

Create a product Add a webhook

§ 01Payment link

In the dashboard, create a product with a monthly or yearly price and delivery "App access". Its payment link is permanent and opens a fresh checkout per visit:

https://settlekit-checkout.onrender.com/l/your-product-slug

On a recurring price the buyer can subscribe with one wallet approval capped at price x periods (smart-wallet spend permission, Permit2, or a Solana delegate) or choose emailed renewal invoices. The first period is charged immediately; later ones are collected on schedule and paid straight to your wallet.

§ 02Embed a button

Any page, no script needed:

<a href="https://settlekit-checkout.onrender.com/l/your-product-slug"
   style="display:inline-block;padding:12px 20px;border-radius:6px;
          background:#1e40a2;color:#fff;font:600 15px system-ui;text-decoration:none">
  Subscribe with USDC
</a>

To keep buyers on your page, add embed.js: links marked data-settlekit-checkout open the checkout in an overlay. Add your site under Settings > Embedding to receive the success event.

<script src="https://settlekit-checkout.onrender.com/embed.js" async></script>

<a href="https://settlekit-checkout.onrender.com/l/your-product-slug" data-settlekit-checkout>Subscribe with USDC</a>

<script>
  // Optional: react when the payment settles (sites listed under
  // Settings > Embedding receive this; others still get a working checkout).
  document.addEventListener("settlekit:success", (event) => {
    console.info("paid", event.detail.sessionId, event.detail.paymentId);
  });
  // Or open it from code:
  // SettleKit.open("https://settlekit-checkout.onrender.com/l/your-product-slug", { onSuccess: (d) => {}, onClose: () => {} });
</script>

To send a signed-in user to checkout and back to your app afterwards, open the session from your server:

// Open a fresh checkout for a signed-in user and send them to it.
const res = await fetch("https://settlekit-api.onrender.com/v1/public/links/your-product-slug/sessions", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ successUrl: "https://app.example.com/billing?paid=1" }),
});
const { data } = await res.json();
redirect(`https://settlekit-checkout.onrender.com/c/${data.sessionId}`);
§ 03Verify webhooks (Node)

Every event is POSTed with SettleKit-Signature: t=..,v1=.. and SettleKit-Event headers, signed with your endpoint's secret. Non-2xx responses are retried with backoff; event ids are stable, so handle them idempotently.

import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.SETTLEKIT_WEBHOOK_SECRET; // Dashboard > Webhooks > Reveal
const TOLERANCE_SECONDS = 300;

/**
 * SettleKit-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
 * After a secret rotation the header carries one v1 per active secret for a
 * grace period, so accept the delivery when ANY v1 matches.
 */
function verify(rawBody, header) {
  const parts = header.split(",").map((kv) => kv.trim().split("="));
  const t = Number(parts.find(([k]) => k === "t")?.[1]);
  const signatures = parts.filter(([k]) => k === "v1").map(([, v]) => v);
  if (!Number.isInteger(t) || signatures.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
  const expected = createHmac("sha256", SECRET).update(`${t}.${rawBody}`).digest();
  return signatures.some((v1) => {
    const given = Buffer.from(v1, "hex");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}

// Use the RAW body: re-serialized JSON will not match the signature.
app.post("/webhooks/settlekit", express.raw({ type: "application/json" }), async (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verify(raw, req.get("SettleKit-Signature") ?? "")) return res.status(400).send("bad signature");
  const event = JSON.parse(raw); // { id, type, organizationId, data, createdAt }

  switch (event.type) {
    case "payment.confirmed":      // one-time purchase or a subscription period
    case "subscription.charged":   // renewal collected; data.currentPeriodEnd
      await grantPlan(event.data.customerEmail, event.data.customerId, event.data.productIds ?? [event.data.productId]);
      break;
    case "subscription.canceled":  // data.cancelAtPeriodEnd: keep access until the period ends
    case "refund.succeeded":
      await scheduleDowngrade(event.data.customerId);
      break;
  }
  res.sendStatus(200); // anything else is retried with backoff
});
EventWhen
payment.confirmedA payment settled on-chain (checkout, subscription pull, AI agent). data.paymentId, customerId, customerEmail, productIds, amount, network, txHash.
subscription.chargedA subscription period was collected. data.periodIndex, amount, txHash, currentPeriodEnd.
subscription.canceledThe buyer or you canceled. data.cancelAtPeriodEnd, canceledBy (buyer | merchant).
refund.succeededFunds went back to the buyer. data.refundId, paymentId, amount, txHash, source (operator | escrow | manual).

Example payload:

{
  "id": "evt_4c1f0d2a9b7e6c5d4e3f2a1b",
  "type": "subscription.charged",
  "organizationId": "org_...",
  "createdAt": "2026-10-01T12:00:00.000Z",
  "data": {
    "subscriptionId": "sub_...",
    "onchainSubscriptionId": "osub_...",
    "customerId": "cus_...",
    "customerEmail": "buyer@example.com",
    "productId": "prod_...",
    "network": "base",
    "method": "permit2",
    "periodIndex": 1,
    "amount": "29",
    "txHash": "0x...",
    "currentPeriodEnd": "2026-10-31T12:00:00.000Z"
  }
}
§ 04Check entitlement

Webhooks tell you when access changes; the entitlement API is the source of truth whenever you need to check. Access stays active through the paid period and is expired automatically if a renewal fails after dunning.

// Server-side only: create the key in Dashboard > API keys.
async function hasPaidPlan(email, productId) {
  const res = await fetch("https://settlekit-api.onrender.com/v1/entitlements/verify", {
    method: "POST",
    headers: {
      authorization: `Bearer ${process.env.SETTLEKIT_API_KEY}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({ email, productId }), // or { customerId } from the webhook
  });
  const { data } = await res.json(); // { allowed, reason?, entitlement? }
  return data.allowed === true;
}

List everything a buyer owns with GET /v1/entitlements?email=buyer@example.com&activeOnly=true. Keep the API key on your server; never ship it to a browser.