Skip to content

Entitlements

Entitlements are the access records your app owns after a payer completes delegated checkout: orders, memberships, subscriptions, credits, seats, downloads, or anything else your product unlocks.

A merchant Proxy webhook endpoint is not required to create a handoff or open a payment-level checkout. Add one when your app needs Proxy events to grant or update access, especially for production fulfillment and subscriptions.

Create a webhook endpoint in Proxy that points to your backend:

https://app.example.com/api/proxy/webhook

Save the endpoint signing secret as PROXY_WEBHOOK_SIGNING_SECRET.

For one-time purchases, subscribe to:

  • proxy_session.paid
  • proxy_session.provisionable
  • proxy_session.cancelled
  • proxy_session.failed
  • proxy_session.expired

For subscriptions, also subscribe to:

  • subscription.renewed
  • subscription.changed
  • subscription.payment_failed
  • subscription.cancel_scheduled
  • subscription.cancelled

Treat subscription.changed as a current-state refresh only. It is not proof of payment or renewal and must not extend access.

Use the server SDK to verify the webhook and resolve the latest Proxy state before updating access.

import {
createProxyCheckoutServerClient,
type ResolvedProxyEvent,
} from "@proxy-checkout/server-js";
const proxy = createProxyCheckoutServerClient({
apiKey: process.env.PROXY_SECRET_KEY!,
});
// POST /api/proxy/webhook
export async function POST(request: Request) {
return proxy.webhooks.handle(request, {
secret: process.env.PROXY_WEBHOOK_SIGNING_SECRET!,
async onResolved(resolved) {
return updateEntitlements(resolved);
},
});
}
async function updateEntitlements(resolved: ResolvedProxyEvent) {
switch (resolved.kind) {
case "initial_provision":
// Grant access for resolved.session.buyerReference.
// Key the write by resolved.session.id so repeated deliveries are safe.
return {
fulfillmentReference: "entitlement_or_pending_entitlement_id",
metadata: { claim_status: "claimed" },
};
case "subscription_renewed":
// Extend or confirm subscription access.
return;
case "subscription_changed":
// Refresh non-entitlement subscription display/configuration state only.
// This is not a renewal and must not extend access.
return;
case "subscription_cancel_scheduled":
// Keep access through the current period and mark non-renewing.
return;
case "subscription_cancelled":
// End access according to your product policy.
return;
case "payment_risk":
// Apply your dunning or restricted-access policy.
return;
case "terminal_session":
// Mark the delegated purchase failed, expired, or unavailable.
return;
case "ignored":
case "payment_attempt":
return;
}
}

Do not grant access from raw webhook payload fields alone. The handler verifies the signature and re-reads current Proxy state before calling onResolved.

For pre-account purchases, grant or mark paid a pending entitlement first, then return the pending entitlement id as fulfillmentReference. After signup or login, validate your own high-entropy claim token and attach the pending entitlement to the authenticated profile. proxy_session_id is useful for correlation and support, but it is public and should not be the claim secret.

fulfillmentReference is copied to Proxy’s proxy_session.provisioned session and audit events. Use it to point Proxy back to the merchant-owned entitlement, role, order, or pending-purchase row that fulfilled the session. It does not grant access and should not contain claim tokens or other secrets.

A pending entitlement needs two separate identifiers:

  • buyerReference: a stable, non-PII merchant id for the pending entitlement. Send this to Proxy.
  • claim token: a high-entropy secret that proves a browser, claim link, or authenticated user is allowed to attach the pending entitlement to a profile. Keep this merchant-owned and never send it to Proxy.

Store only a hash of the claim token at rest. Do not put the plaintext token in buyerReference, cartSnapshot, Proxy metadata, Stripe metadata, link previews, or logs.

For same-browser continuation, generate the claim token before the handoff request and keep it in sessionStorage until the beneficiary signs up or logs in:

function createBase64UrlToken(byteLength = 32): string {
const bytes = new Uint8Array(byteLength);
crypto.getRandomValues(bytes);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function getOrCreateSessionValue(key: string, create: () => string): string {
const existing = sessionStorage.getItem(key);
if (existing) return existing;
const value = create();
sessionStorage.setItem(key, value);
return value;
}
const checkoutRequestId = getOrCreateSessionValue("proxy.checkoutRequestId", () =>
crypto.randomUUID(),
);
const claimToken = getOrCreateSessionValue("proxy.claimToken", createBase64UrlToken);
await fetch("/api/proxy/handoffs", {
body: JSON.stringify({ beneficiaryEmail, checkoutRequestId, claimToken, offerId }),
headers: { "content-type": "application/json" },
method: "POST",
});

On your backend, hash that token and store the hash on the pending entitlement before creating the Proxy handoff:

import { createHash } from "node:crypto";
function hashClaimToken(token: string): string {
return createHash("sha256").update(`proxy-claim-v1:${token}`, "utf8").digest("base64url");
}
const pending = await findOrCreatePendingEntitlementForCheckout({
beneficiaryEmail,
checkoutRequestId,
claimTokenExpiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24),
claimTokenHash: hashClaimToken(claimToken),
offerId,
});
await proxy.sessions.createHandoff({
amountMinor,
beneficiaryContact: beneficiaryEmail ? { email: beneficiaryEmail } : undefined,
buyerReference: pending.id,
cartSnapshot,
currency,
idempotencyKey: `pre-account:${checkoutRequestId}`,
});

Same-browser storage is only a convenience. For delegated checkout, assume the payer and beneficiary may use different devices. The durable cross-device pattern is a one-time claim link that your app sends to the beneficiary after payment is confirmed:

import { createHash, randomBytes } from "node:crypto";
function createServerClaimToken(byteLength = 32): string {
return randomBytes(byteLength).toString("base64url");
}
function hashClaimToken(token: string): string {
return createHash("sha256").update(`proxy-claim-v1:${token}`, "utf8").digest("base64url");
}
export async function issuePendingEntitlementClaimLink(input: {
pendingEntitlementId: string;
recipientEmail: string;
}) {
const token = createServerClaimToken();
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7);
await savePendingEntitlementClaimLink({
claimTokenHash: hashClaimToken(token),
expiresAt,
pendingEntitlementId: input.pendingEntitlementId,
});
return {
expiresAt,
to: input.recipientEmail,
url: `https://app.example.com/claim?claim_token=${encodeURIComponent(token)}`,
};
}

Call that from your initial_provision webhook after the pending entitlement is marked paid or provisionable. Make claim-link creation idempotent for webhook retries by reusing an existing unexpired unused link for the same pending entitlement, or by superseding the prior link before creating a replacement.

Your authenticated claim endpoint should accept either the same-browser token from sessionStorage or the claim_token URL parameter from a cross-device link:

export async function claimPendingAccess(input: {
claimToken: string;
profileEmail?: string;
profileId: string;
}) {
return claimPendingEntitlement({
claimTokenHash: hashClaimToken(input.claimToken),
profileEmail: input.profileEmail,
profileId: input.profileId,
});
}

claimPendingEntitlement(...) should run in one database transaction: find an unexpired pending entitlement by token hash, lock it, reject already-claimed rows unless they are already claimed by the same profile, optionally compare the intended beneficiary email with the authenticated profile email, set profileId and claimedAt, clear or rotate the stored claim-token hash, and grant access only if the pending entitlement is already paid or provisionable.

Webhook deliveries can repeat. Make entitlement writes idempotent by keying them to your own access record plus the Proxy object that caused the update:

  • one-time access: resolved.session.id
  • subscription access: resolved.subscription.id
  • buyer-owned access: resolved.session.buyerReference

buyerReference should be stable and non-PII. Use an existing account/profile id when the beneficiary already has an account, or a pending entitlement id when the beneficiary will claim access later.

For a Proxy-mediated delegated purchase, do not also grant access from a direct Stripe webhook path for the same order or subscription. Stripe should send payment evidence to Proxy, and Proxy should send normalized, signed events to your backend.