---
summary: Developer guide for integrating SigID tenant commerce–payment links, public checkout, webhooks for fulfillment, and the boundary between merchant admin APIs and buyer flows.
tags:
  - developers
  - commerce
  - payments
  - webhooks
  - payment-links
categories:
  - For Developers
---

# Sell Access With SigID Commerce

<!-- agent:page
You are a coding agent integrating SigID Commerce into a third-party SaaS so buyers can pay and the app can grant access.
CRITICAL distinctions:
- Commerce (this page) = tenant collects money for *their* products via SigID-managed rails. Not the same as workspace Billing (paying for SigID itself).
- Merchant *admin* APIs under /api/v1/commerce/* require a human Dashboard operator (AAL2, fresh auth, commerce:manage). Do not call manage routes with end-user OAuth tokens or agent client_credentials alone.
- Buyer *public* checkout is unauthenticated: payment links at GET /pay/{token} and POST /api/v1/public/commerce/*.
- There is no public "list products owned by OIDC sub" entitlement API. Fulfillment requires a verified commerce.payment.succeeded webhook or a ledger-confirmed fulfillment lookup; success_url is only for correlation and UX.
- Commerce has no subscription/recurring price model. CommercePriceBillingModel is one_time | metered | x402_request only; the integrating tenant owns its renewal cycle.
- Entitlement truth lives in the integrator's database. If purchases are mirrored into token roles, local JWT verification can retain stale claims until token expiry; use introspection where immediate revocation matters.
Integration order:
1. Operator enables Commerce on the tenant plan and completes merchant profile / rail setup in Dashboard.
2. Operator creates product → price → payment link (success_url / cancel_url on allowlisted hosts).
3. App stores the payment-link public token (or product id) and redirects buyers to https://auth.sigid.org/pay/{token} (or the issuer host).
4. App registers a webhook for commerce.payment.* with suite sigid-webhook-v1.
5. On payment.succeeded, grant access in your own data model (map charge → product → user by email/metadata/session).
Do not invent commerce entitlement REST shapes. Do not put Stripe secrets in the third-party app.
-->

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](../business/billing-production.md) 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)

<!-- agent:action Complete merchant setup in Dashboard
Have a human tenant admin with commerce:manage enable Commerce, complete merchant profile and rail readiness, then create product, price, and payment link with success_url/cancel_url on allowlisted hosts.
Copy the public payment-link token or /pay/{token} URL into the app config. Do not attempt manage API calls from the end-user app session.
-->

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:
   - `success_url` – buyer return after pay (must be on the commerce redirect host allowlist)
   - `cancel_url` – buyer abandon
5. Copy the public token. Public URL shape:

```text
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:

```text
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:

```text
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:

```text
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:

```http
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)

<!-- agent:action Wire commerce webhooks
Create HTTPS POST receiver, register commerce.payment.succeeded (and refunded) in Dashboard, store signing secret server-side, verify suite sigid-webhook-v1 before trusting body.
On succeeded: map charge to product and buyer (email/metadata/session), grant access idempotently by delivery id. On refunded: revoke or reduce access.
-->

1. Create `POST https://app.example.com/webhooks/sigid` (HTTPS in production).
2. In Dashboard **Webhooks**, subscribe at least to:
   - `commerce.payment.succeeded` – grant access
   - `commerce.payment.refunded` – revoke or reduce access
   - optionally `commerce.payment.failed`, `commerce.payment.pending`
3. Verify deliveries with suite **`sigid-webhook-v1`** before parsing trust fields.
   See [Receive Webhooks](webhooks.md) and
   [Webhook Events](../reference/webhook-events.md).

**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](https://github.com/sig-id/sigid/issues/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](https://github.com/sig-id/sigid/issues/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](webhooks.md)
- [Webhook Events](../reference/webhook-events.md)
- 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 `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
