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.
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-slugOn 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.
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}`);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
});| Event | When |
|---|---|
| payment.confirmed | A payment settled on-chain (checkout, subscription pull, AI agent). data.paymentId, customerId, customerEmail, productIds, amount, network, txHash. |
| subscription.charged | A subscription period was collected. data.periodIndex, amount, txHash, currentPeriodEnd. |
| subscription.canceled | The buyer or you canceled. data.cancelAtPeriodEnd, canceledBy (buyer | merchant). |
| refund.succeeded | Funds 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"
}
}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.