Project commerce
Sell products and memberships with project-owned Stripe credentials or Connect direct charges.
Sell products and memberships
Project commerce is the released, project-scoped sales contract exposed by client.commerce and /v1/projects/:project_id/commerce routes.
It gives applications one tenant-bound model for products, prices, purchases, memberships, refunds, entitlements, and fulfillment without coupling them to FFDB platform billing.
Use it when the application itself sells a one-time product or recurring membership through Stripe.
Requirements for Project commerce
- Prerequisite — A project owner/admin session or commerce_manage developer key for configuration and management.
- Prerequisite — Either project-owned Stripe secret/webhook keys or deployment-enabled Stripe Connect; HTTPS return, refresh, success, and cancel URLs for production.
- Required value — Project ID, provider mode, product and immutable price terms, integer minor-unit amount, currency, and durable idempotency keys.
- Required value — For memberships: an individual, team, or organization subject and explicit entitlement map.
Status: complete project commerce API
Project commerce is isolated from the organization subscription that pays for FFDB. Every project chooses exactly one provider mode: encrypted BYO Stripe credentials or optional Stripe Connect with direct charges. The same provider-neutral products, prices, Checkout, orders, payments, refunds, subscriptions, entitlements, and fulfillment APIs run above both modes.
const account = await ffdb.commerce.account();
await ffdb.commerce.configureByo({
secret_key: process.env.PROJECT_STRIPE_SECRET_KEY!,
webhook_secret: process.env.PROJECT_STRIPE_WEBHOOK_SECRET!,
});
// Or use Accounts v2 Connect onboarding:
const onboarding = await ffdb.commerce.connectOnboarding({
country: "US",
email: "owner@example.com",
return_url: "https://app.example.com/settings/payments/return",
refresh_url: "https://app.example.com/settings/payments/refresh",
});Products and immutable prices
Products describe what the application sells. Prices snapshot currency, minor-unit amount, billing cadence, and recurring entitlement grants. Retiring a price prevents new Checkout sessions without changing historical orders or subscriptions.
- Amounts are integer minor units and bounded to JavaScript's safe-integer range.
- Recurring entitlements are validated and keyed before the provider Price is created.
- Catalog reads are public by default; inactive catalog entries require commerce administration.
const product = await ffdb.commerce.createProduct({
name: "Team Pro",
description: "Ten seats and advanced exports",
tax_code: null,
});
const price = await ffdb.commerce.createPrice({
product_id: product.id,
lookup_key: "team_pro_monthly",
currency: "USD",
unit_amount_minor: 1500,
billing: { type: "recurring", interval: "month", interval_count: 1 },
entitlements: {
seats: { type: "quantity", value: 10 },
exports: { type: "enabled", value: true },
},
});One-time and recurring Checkout
FFDB creates hosted Stripe Checkout Sessions. A one-time cart snapshots every order line before redirect. A recurring Checkout binds one immutable price to an individual, team, or organization membership subject. A browser redirect is navigation only; captured payment and active subscription webhooks are state authority.
const checkout = await ffdb.commerce.recurringCheckout({
price_id: price.id,
quantity: 1,
subject: { kind: "team", id: teamId },
customer_email: "billing@example.com",
success_url: "https://app.example.com/billing/success",
cancel_url: "https://app.example.com/billing",
}, { idempotencyKey: checkoutAttemptId });
location.assign(checkout.url);Subscriptions, Customer Portal, and entitlements
Subscription webhooks apply only when their project metadata and connected-account binding match. Active or trialing periods materialize the immutable price entitlement set. Past-due, unpaid, paused, canceled, and expired states revoke or expire access according to the verified provider lifecycle. After Checkout binds a Stripe Customer to the subject, FFDB can create a subject-authorized Customer Portal session for payment-method and subscription self-service.
const entitlements = await ffdb.commerce.entitlements({
kind: "team",
id: teamId,
});
const portal = await ffdb.commerce.customerPortal({
subject: { kind: "team", id: teamId },
return_url: "https://app.example.com/settings/billing",
});
await ffdb.commerce.cancelSubscription(
subscriptionId,
{ at_period_end: true },
{ idempotencyKey: cancellationId },
);Refunds and paid fulfillment
Refund reservations are serialized against captured funds, preventing concurrent over-refunds. Provider refund webhooks reconcile final state. Physical or asynchronous fulfillment can move to processing or fulfilled only while captured funds minus successful and pending refunds still cover the full order total.
const refund = await ffdb.commerce.refund({
payment_id: paymentId,
amount_minor: 500,
reason: "requested_by_customer",
}, { idempotencyKey: refundAttemptId });
await ffdb.commerce.updateFulfillment(
orderId,
"fulfilled",
"carrier tracking 123",
{ idempotencyKey: fulfillmentAttemptId },
);Webhook boundary and raw HTTP routes
BYO projects receive the exact raw provider body at POST /v1/projects/:project_id/commerce/webhooks/stripe. Connect uses one deployment endpoint at POST /v1/commerce/webhooks/stripe-connect: FFDB verifies its dedicated endpoint secret before parsing event.account, resolves exactly one connected project, and rechecks account and livemode. The BYO endpoint rejects Connect events. Both paths bind payload hashes to durable event IDs before applying ordered changes.
Unused BYO or Connect configuration can be removed with commerce.disconnectAccount(). Disconnect is audited and idempotent, removes only FFDB's local binding and encrypted project secrets, never closes the external Stripe account, and fails with commerce.account_in_use after catalog, customer, order, or subscription state exists.
GET /v1/projects/:project_id/commerce/account
DELETE /v1/projects/:project_id/commerce/account
POST /v1/projects/:project_id/commerce/account/byo
POST /v1/projects/:project_id/commerce/account/connect/onboarding
GET|POST /v1/projects/:project_id/commerce/products
GET|POST /v1/projects/:project_id/commerce/prices
POST /v1/projects/:project_id/commerce/checkouts/one-time
POST /v1/projects/:project_id/commerce/checkouts/recurring
POST /v1/projects/:project_id/commerce/customer-portal
GET /v1/projects/:project_id/commerce/orders
GET /v1/projects/:project_id/commerce/payments
POST /v1/projects/:project_id/commerce/refunds
GET /v1/projects/:project_id/commerce/subscriptions
GET /v1/projects/:project_id/commerce/entitlements
POST /v1/projects/:project_id/commerce/webhooks/stripe # BYO only
POST /v1/commerce/webhooks/stripe-connect # Connect onlyProject commerce workflow
- 1. Configure BYO credentials with commerce.configureByo() or create Accounts v2 onboarding with commerce.connectOnboarding().
- 2. For BYO, register the account summary's per-project webhook URL; for Connect, register the single deployment /v1/commerce/webhooks/stripe-connect URL with its dedicated project-Connect endpoint secret.
- 3. Create the product and immutable one-time or recurring price.
- 4. Create a hosted Checkout session and navigate to its URL.
- 5. Treat the redirect as navigation only and let verified webhooks reconcile orders, payments, invoices, subscriptions, refunds, and entitlements.
- 6. Read entitlements for the authenticated membership subject and advance fulfillment only after the order is paid.
Verify project commerce
The project sells through its own merchant account while FFDB maintains tenant-bound, idempotent, webhook-reconciled commerce state.
Troubleshoot project commerce
- Account status is restricted — inspect requirements_due and finish provider onboarding.
- A Connect event sent to the per-project BYO route is rejected — deliver it to the global account-routed Connect endpoint.
- Disconnect returns commerce.account_in_use — preserve the binding because provider-bound commerce records exist.
- A Checkout redirect returns but no access appears — diagnose the signed webhook instead of trusting the browser redirect.
- A reused idempotency key has different input — issue a new key for the new logical operation.
- Fulfillment is rejected — verify captured funds still cover the order after pending and successful refunds.
Continue from Project commerce
- Run the project-commerce acceptance matrix with Stripe sandbox events.
- Configure production webhook delivery and merchant operational ownership.