Fulfillment and entitlements
Use verified Proxy events to update the orders and entitlements your app owns after delegated checkout. Fulfillment performs the merchant action required after payment, such as marking an order paid or delivering a product. An entitlement records access to a membership, subscription, credit, seat, or download.
Configure a Proxy webhook endpoint before accepting production payments that require fulfillment or entitlement changes. The endpoint is not required to create a handoff or open checkout.
Configure the endpoint
Create a webhook endpoint in Proxy that points to your backend:
https://app.example.com/api/proxy/webhookSave the endpoint signing secret as PROXY_WEBHOOK_SIGNING_SECRET.
For one-time purchases, subscribe to:
proxy_session.paidproxy_session.provisionableproxy_session.cancelledproxy_session.failedproxy_session.expired
For subscriptions, also subscribe to:
subscription.renewedsubscription.changedsubscription.payment_failedsubscription.cancel_scheduledsubscription.cancelled
Treat subscription.changed as a current-state refresh only. It is not proof of payment or renewal and must not extend access.
Handle Proxy events
Use the server SDK to verify each webhook and resolve the latest Proxy state before updating an order or entitlement.
import { createProxyCheckoutServerClient } from "@proxy-checkout/server-js";import { updateMerchantState } from "./merchant-fulfillment";
const proxy = createProxyCheckoutServerClient({ apiKey: process.env.PROXY_SECRET_KEY!,});
// POST /api/proxy/webhookexport async function POST(request: Request) { return proxy.webhooks.handle(request, { secret: process.env.PROXY_WEBHOOK_SIGNING_SECRET!, onResolved: updateMerchantState, });}Do not fulfill from raw webhook fields. The handler verifies the signature and reads current Proxy state before calling onResolved.
updateMerchantState is your callback, not a Proxy SDK function. Proxy cannot update your order or entitlement database. Implement the callback for the resolved states your integration uses:
| Resolved kind | Required merchant action |
|---|---|
initial_provision |
Fulfill the order or grant access once for resolved.session.id. Return the merchant record id as fulfillmentReference. |
subscription_renewed |
If resolved.accessEndsAt is present, set access to that value. If it is null, preserve the current boundary and return an error so delivery can retry. Do not add time to the existing expiration. |
subscription_changed, subscription_cancel_scheduled, or subscription_cancelled |
Set the merchant subscription record from the resolved subscription state. |
payment_risk or subscription_action_required |
Apply your payment-failure or restricted-access policy from the resolved state. |
terminal_session |
Record the failed, expired, or cancelled purchase. Do not fulfill it. |
acquisition_progress, payment_attempt, or ignored |
Do not fulfill. |
proxy.webhooks.handle(...) marks the Proxy session as provisioned only after updateMerchantState returns successfully.
Enforce one initial fulfillment record for each resolved.session.id. A retried event must not fulfill the same Proxy session twice.
If payment grants access, use resolved.session.buyerReference to find the merchant-owned profile or pending entitlement. buyerReference identifies the intended entitlement owner, not the payer.
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.
Pre-account and cross-device claims
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.
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.
Idempotency
Webhook deliveries can repeat. Make each merchant-owned update converge on the resolved current state:
- For one-time fulfillment, enforce one update for each
resolved.session.id. - For subscription renewal, set access to
resolved.accessEndsAtwhen present. If it isnull, preserve the current boundary and return an error. - For subscription changes and cancellations, set the merchant record from the resolved subscription state.
Do not use buyerReference as a purchase idempotency key. One entitlement owner can complete multiple purchases.
For the same Proxy-mediated purchase, do not also fulfill from a direct payment-provider webhook. Use one merchant-owned fulfillment path for each order or subscription.