Skip to content

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

  1. A merchant catalog (product, price, payment link) created by a human operator.
  2. A buyer checkout URL your app can redirect to.
  3. A signed webhook receiver that grants or revokes access after ledger-confirmed payment.
  4. Optional: a logged-in success_url return that refreshes the buyer’s local session cache.

Operator Setup (Dashboard)

In Dashboard, under the tenant Commerce area:

  1. Enable Commerce (plan feature commerce).
  2. Complete merchant profile and payment rail readiness (for example Stripe Managed Payments).
  3. Create a product and price.
  4. Create a payment link with:
  5. success_url – buyer return after pay (must be on the commerce redirect host allowlist)
  6. cancel_url – buyer abandon
  7. Copy the public token. Public URL shape:
https://auth.sigid.org/pay/{payment_link_token}

(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:

GET https://auth.sigid.org/pay/{token}

Prefer building success_url so the buyer lands on a route that already has your app session cookie, for example:

https://app.example.com/purchase/complete?product=pro

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:

POST /api/v1/public/commerce/subscription-checkout-sessions

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)

  1. Create POST https://app.example.com/webhooks/sigid (HTTPS in production).
  2. In Dashboard Webhooks, subscribe at least to:
  3. commerce.payment.succeeded – grant access
  4. commerce.payment.refunded – revoke or reduce access
  5. optionally commerce.payment.failed, commerce.payment.pending
  6. Verify deliveries with suite sigid-webhook-v1 before 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_url hit 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

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 roles claim, access-token lifetime is short enough that a post-refund stale grant is acceptable
  • Logs never include webhook secrets or payment provider secrets