Sell Access With SigID Commerce¶
Use this page when your product sells digital access (courses, seats, credits) and you want SigID Commerce to collect payment. SigID is the collection and settlement layer; your app owns entitlements after payment succeeds.
This is not workspace Billing (paying for your SigID subscription). See Billing And Production for that.
What You Are Building¶
- A merchant catalog (product, price, payment link) created by a human operator.
- A buyer checkout URL your app can redirect to.
- A signed webhook receiver that grants or revokes access after ledger-confirmed payment.
- Optional: a logged-in
success_urlreturn that refreshes the buyer’s local session cache.
Operator Setup (Dashboard)¶
In Dashboard, under the tenant Commerce area:
- Enable Commerce (plan feature
commerce). - Complete merchant profile and payment rail readiness (for example Stripe Managed Payments).
- Create a product and price.
- Create a payment link with:
success_url– buyer return after pay (must be on the commerce redirect host allowlist)cancel_url– buyer abandon- Copy the public token. Public URL shape:
(Use your deployment’s auth/identity host if self-hosted.)
Auth model for manage APIs: POST /api/v1/commerce/* mutations require a
direct human bearer, AAL2, fresh authentication (~10 minutes), and
commerce:manage. List/read need commerce:list / commerce:read. Machine
client credentials alone are not a substitute for operator step-up.
App Integration¶
Redirect to checkout¶
Store the payment-link token (or a product-specific token map) in your app. Redirect the signed-in user:
Prefer building success_url so the buyer lands on a route that already has
your app session cookie, for example:
Dynamic Return URLs (Per-Buyer success_url)¶
The browser entry GET /pay/{token} uses the success_url/cancel_url fixed
on the payment link, so it cannot carry a per-buyer deep link or an
order/session reference on its own. When you need a dynamic return path
(for example to land the buyer on /purchase/complete?order=abc123), do not
try to append query params to /pay/{token} – create the checkout session
server-side per buyer and redirect to its hosted URL instead:
1. Buyer is signed in to your app and hits your /purchase/start route.
2. Your server creates an order/intent row containing the expected `product_id`,
`price_id`, `currency`, and `gross_amount_minor`, then stores a short-lived
signed cookie (or opaque token) keyed to that order.
3. Your server calls:
POST {issuer}/api/v1/public/commerce/checkout-sessions
with every required field, for example:
```json
{
"price_id": "<expected_price_id>",
"success_url": "https://app.example.com/purchase/complete?order=<order_id>",
"cancel_url": "https://app.example.com/purchase/cancelled?order=<order_id>",
"idempotency_key": "checkout:<order_id>",
"metadata": { "order_id": "<order_id>" }
}
```
The `price_id` comes from the order's trusted server-side product mapping;
do not accept an arbitrary buyer-supplied price. The idempotency key must be
unique per order.
4. Persist `response.charge.id` on the order before issuing a 302 to
`response.checkout_url`. The charge id is required for fulfillment lookup
if the webhook is completely missed.
5. The buyer may land on your success_url with your app session cookie. Use
the order token to show a pending status until payment is verified; do not
grant access from the return.
6. Grant access only after verifying a `commerce.payment.succeeded` webhook and
matching its `charge_id` and metadata correlation key to the order. Also
compare the trusted `product_id`, `price_id`, `currency`, and settled
`amount_minor` against the order's expected values; metadata alone is not an
authorization decision. A ledger-confirmed fulfillment lookup by the saved
charge id is the reconciliation backstop for a missed or delayed webhook;
apply the same comparisons to its `product_id`, `price_id`, `currency`, and
`gross_amount_minor`.
The success_url host must be on the commerce.checkout_redirect_allowed_hosts
allowlist; the path/query can vary per buyer. This is the supported dynamic-return
mechanism, but reaching the URL is not proof of payment: callers can request it
directly. Never grant an entitlement from the return alone. (A client-supplied
return_to on the public /pay/{token} redirect
is intentionally not accepted because it would create an open-redirect risk.)
Recurring subscription checkout¶
Create a catalog price with billing_model: "recurring", a
recurring_interval (day, week, month, or year), and an interval count
from 1 through 365. Start buyer checkout with:
The request uses the same allowlisted success_url, cancel_url, correlation
metadata, and idempotency rules as one-time checkout, and additionally requires
customer_email. Persist the returned subscription.id before redirecting to
checkout_url. Each paid Stripe invoice becomes a separate
commerce.payment.succeeded charge and settlement. Grant or extend recurring
access on commerce.subscription.activated and
commerce.subscription.renewed; those events are emitted only after the
invoice charge is ledger-confirmed. Handle commerce.subscription.payment_failed
and commerce.subscription.canceled according to your grace-period policy.
Do not infer renewal from the browser return or a raw Stripe status update.
x402 prices remain exact-price request payments and cannot be recurring.
Public HTTP surfaces (buyer)¶
| Method | Path | Purpose |
|---|---|---|
GET |
/pay/{payment_link_token} |
Browser entry for a payment link |
POST |
/api/v1/public/commerce/checkout-sessions |
Create a checkout session |
POST |
/api/v1/public/commerce/subscription-checkout-sessions |
Create checkout for a recurring price |
POST |
/api/v1/public/commerce/payment-links/{token}/checkout |
Checkout from a payment link |
GET/POST |
/api/v1/public/commerce/payment-links/{token}/x402 |
x402 challenge / settle |
Redirect hosts for success_url / cancel_url are allowlisted server-side
(commerce.checkout_redirect_allowed_hosts). Failures are validation errors,
not silent drops.
Fulfillment via webhooks (required for reliable grants)¶
- Create
POST https://app.example.com/webhooks/sigid(HTTPS in production). - In Dashboard Webhooks, subscribe at least to:
commerce.payment.succeeded– grant accesscommerce.payment.refunded– revoke or reduce access- optionally
commerce.payment.failed,commerce.payment.pending - Verify deliveries with suite
sigid-webhook-v1before parsing trust fields. See Receive Webhooks and Webhook Events.
Payment event payload (fulfillment fields): ledger-confirmed payment events include, when known on the charge:
| Field | Purpose |
|---|---|
charge_id |
Stable charge identifier |
amount_minor, currency |
Settled amount |
metadata |
The merchant-defined JSON you set on the payment link / checkout session (your correlation key, e.g. app_user_id, sku) |
product_id, price_id |
Catalog identifiers (optional) |
buyer_id |
Commerce buyer id (optional; not the same as OIDC sub) |
customer_email |
Buyer email when collected (optional) |
They still may not include your app’s OIDC sub. Plan mapping:
| Strategy | When to use |
|---|---|
product_id + customer_email on the webhook |
Match product and user by email |
success_url + logged-in session |
Correlate the returning browser and display status; never grant from the return alone |
| Payment-link / charge metadata | You set metadata when creating the link with your user id / product key |
| Charge read API | Operator or service with commerce:read loads /api/v1/commerce/charges/{id} |
| Public fulfillment lookup | GET /api/v1/public/commerce/fulfillment/{charge_id} authenticated with an active webhook signing secret scoped to at least one commerce.payment.* lifecycle event – no Dashboard step-up required (reconciliation backstop for missed/delayed webhooks) |
There is no public REST “entitlements for this sub” resource. Your
database is the source of truth for “has paid.”
Revocable role-backed access¶
If your fulfillment worker maps a settled purchase to a SigID role, give its
bearer credential entitlements:grant instead of policies:manage, and mark
only the purchasable roles is_commerce_grantable. The narrow scope can create
and revoke assignments for those roles; it cannot create roles, edit their
permissions, or touch any unmarked role.
SigID increments the affected subject's authorization version on every role
grant or revoke. Requests validated through SigID, including OAuth token
introspection, reject an older token immediately as stale access token.
Local JWT verification against JWKS cannot observe mutable authorization
versions: a resource server using only local verification continues to accept
the signed token until exp. Money-revocable access must therefore use
introspection at the authorization boundary or explicitly accept a window no
longer than the configured access-token lifetime.
Public buyer HTTP paths are also listed in openapi-public.json (tag
Commerce) when openapi.exposure = "public".
What not to do¶
- Do not hand-roll Stripe Checkout in the app for SigID Commerce products.
- Do not call
/api/v1/commerce/*manage routes with end-user access tokens. - Do not treat a
success_urlhit as proof of payment or grant any entitlement from it. Require a verified signed webhook or ledger-confirmed fulfillment lookup for every grant. - Do not confuse
/api/v1/identity/billing/entitlements(SigID workspace plan) with commerce product ownership.
Recurring Products And Entitlement Lifetime¶
Subscriptions are not modelled¶
Commerce prices are one_time, metered, or x402_request. There is no
recurring price, no billing period, and no renewal state machine. If you sell a
monthly plan, you charge repeatedly and own the renewal cycle yourself.
This is not the same thing as your SigID workspace subscription (you paying
SigID for a plan), which is fully modelled and has its own surface under
/api/v1/billing/*. The two never meet.
Tracked in #2342.
"Has paid" belongs in your database¶
Grant on a verified commerce.payment.succeeded; revoke on
commerce.payment.refunded and on dispute events. That record is authoritative
and has no staleness window.
You may additionally mirror a purchase into a SigID token by assigning the
buyer a role, which then appears in the access token's roles claim. Understand
what you are trading before you do.
Token lifetime is the real entitlement lifetime¶
Assigning or revoking a role does not invalidate access tokens that were already issued. The claim changes at the next token issuance, so after a refund the buyer keeps paid access for the remainder of the current token's life:
| Application auth profile | Access token lifetime | Worst-case stale entitlement |
|---|---|---|
critical |
300s | 5 minutes |
strict |
300s | 5 minutes |
normal (default) |
900s | 15 minutes |
relaxed |
1800s | 30 minutes |
The deployment-wide default is jwt.access_token_lifetime_human_secs = 900, and
configuration accepts values up to 86400 – so a permissive deployment can extend
that window to a day.
A resource server that verifies the token locally against JWKS cannot observe
revocation at all; it only sees signature and exp. Server-side introspection
is what closes the gap, and only for callers that use it.
Tracked in #2343.
Pick the mechanism by who verifies¶
| Verifier | Use |
|---|---|
| Your own app or backend | Your database. You just processed the webhook; a token claim only adds a stale cache in front of authoritative data. |
| Your own services across a trust boundary | roles claim, short access-token lifetime, introspection if revocation timing matters. |
| A third party, or an anonymous/agent buyer | A short-lived, audience-bound artifact rather than a long-lived identity claim. |
For anything money-revocable, prefer a short window you have measured over a claim you cannot withdraw.
Scopes And Plan Features¶
| Gate | Meaning |
|---|---|
Plan feature commerce |
Tenant may use commerce routes |
commerce:list |
List products, prices, links, charges, … |
commerce:read |
Read charge/receipt/balance detail |
commerce:manage |
Create/update products, prices, links, refunds, payouts (human AAL2 + fresh auth) |
entitlements:grant |
Assign/revoke only roles marked is_commerce_grantable; intended for fulfillment automation |
Related Docs¶
- Receive Webhooks
- Webhook Events
- Public OpenAPI subset:
openapi-public.json(OAuth/agent; commerce admin omitted by design)
Production Checklist¶
- Commerce plan feature on for the tenant
- Merchant profile and rail ready
- Payment link success/cancel URLs on allowlisted hosts
- Webhook endpoint HTTPS, secret ≥ 32 characters, suite verification
- Idempotent grant keyed by
X-SigID-Delivery - Refund path revokes access
- Recurring products: renewal cycle owned by your app (Commerce has no subscription model)
- If entitlement is mirrored into a
rolesclaim, access-token lifetime is short enough that a post-refund stale grant is acceptable - Logs never include webhook secrets or payment provider secrets