# AI Onboarding (/docs/ai-onboarding) An agent needs two kinds of context to work with Commet: curated knowledge that explains billing behavior, and versioned contracts that define exact operations. Give it both instead of asking it to infer business rules from API schemas. ## Choose how the organization is created ### Human-first Create an account at [commet.co](https://commet.co), open the sandbox organization, and create an API key under **Settings → API Keys**. Use OAuth when the agent's MCP client can open a browser; use the sandbox API key for headless clients. ### Agent-first A headless agent can provision paired live and sandbox organizations before a human signs in: ```bash curl -X POST https://commet.co/api/v1/provision \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 3f5ebf70-8afd-4fc0-ae6e-6eaf0f647c91" \ -d '{"name":"Acme","country":"US","businessType":"company"}' ``` The response contains one live key, one sandbox key, and a claim URL. Store the keys immediately, use sandbox for implementation, and give the claim URL to the human owner. Repeating the same UUID and body returns the same result; reusing it with another body fails. ## Connect the MCP server Use the hosted endpoint: ```json { "mcpServers": { "commet": { "url": "https://commet.co/mcp/v2" } } } ``` OAuth fixes an interactive connection to the organization selected in the browser. An `x-api-key` connection is fixed to the key's organization. Always confirm whether that organization is sandbox or live before a write. MCP v2 generates one `api_*` tool per current OpenAPI operation and also exposes documentation search. See [MCP Server](/docs/mcp-server) for client-specific configuration. ## Give the agent the right documentation Use the smallest source that answers the task: 1. `node_modules/@commet/node/docs/README.md` for the API that matches the installed SDK version. 2. [Documentation index](https://commet.co/docs/llms.txt) to discover the right curated concept or business rule. 3. Append `.md` to a page URL for focused Markdown, such as `https://commet.co/docs/how-does-billing-work.md`. 4. [Full documentation](https://commet.co/docs/llms-full.txt) only when the task genuinely needs the complete curated corpus. 5. [OpenAPI](https://commet.co/openapi.json) and generated API Reference for the exact current platform contract. Installed SDK docs are version-matched. The curated Documentation explains how dashboard and API work together. The Knowledge Base defines business rules. API Reference and Webhooks define the current generated contracts. Prepare an existing repository with a managed, reversible block in `AGENTS.md`: ```bash commet agents setup commet agents setup --check --output agent ``` Then validate the local integration without changing files, contacting Commet, or printing secret values: ```bash commet doctor --output agent ``` `@commet/next`, `@commet/ai-sdk`, and `@commet/better-auth` keep their integration-specific guidance in their package READMEs and share the Node SDK contract above. ## Install billing skills ```bash npx skills add commet-labs/skills ``` Skills provide task-specific instructions for SDK integration, billing behavior, webhooks, CLI workflows, and AI billing. Universal pricing and subscription knowledge is available from the standalone packages listed in [Commet Skills](/docs/commet-skill). ## Use a safe implementation loop 1. Ask the agent to identify the billing model and the customer-visible outcome. 2. Configure or inspect the catalog in sandbox. 3. Implement one canonical flow with stable customer IDs and idempotency keys. 4. Confirm asynchronous outcomes from signed webhooks, not redirects. 5. Advance the Test Clock and verify renewal, failure, and recovery. 6. Review every intended live mutation before changing credentials. Start with [Choose a Billing Model](/docs/choose-a-billing-model) and the runnable [Examples](/docs/examples). # Billing Best Practices Skill (/docs/billing-best-practices-skill) The Billing Best Practices skill provides AI agents with comprehensive knowledge for building production-ready billing integrations. It covers subscription lifecycle, failed payments, proration, multi-currency, tax compliance, and invoicing. ## Installation Install the skill using the following command: ```bash npx skills add commet-labs/billing-best-practices ``` ## Advantages - **Subscription lifecycle management**: Patterns for handling every state transition from trial to cancellation. - **Failed payment recovery**: Dunning flows, retry schedules, grace periods, and involuntary churn prevention. - **Proration logic**: How mid-cycle upgrades, downgrades, and plan changes affect invoicing. - **Multi-currency support**: Currency Pricing, request-country Markets, zero-decimal currencies. - **Tax compliance**: Merchant of Record model, automatic tax calculation, when you need MoR vs DIY. - **Pre-launch checklist**: Step-by-step verification before going live with billing. ## Learn More - [**View on GitHub**](https://github.com/commet-labs/billing-best-practices) - [**View on skills.sh**](https://skills.sh/commet-labs/billing-best-practices) # Commet Skills (/docs/commet-skill) Agent Skills give AI agents modular billing capabilities — SDK integration, pricing models, subscription patterns, and billing best practices. Install all skills with a single command. ## Install all skills ```bash npx skills add commet-labs/skills ``` ## Install a single skill ```bash npx skills add commet-labs/skills --skill commet npx skills add commet-labs/skills --skill ai-billing npx skills add commet-labs/skills --skill billing-behaviors npx skills add commet-labs/skills --skill commet-webhooks npx skills add commet-labs/skills --skill commet-cli npx skills add commet-labs/skills --skill migrate-commet-v7-to-v8 npx skills add commet-labs/skills --skill migrate-commet-v8-to-v9 ``` ## Available skills | Skill | Description | | ------------------------- | ----------------------------------------------------------------------------------------- | | `commet` | Core SDK — @commet/node, @commet/next, @commet/ai-sdk, @commet/better-auth | | `billing-behaviors` | Business rules — proration, plan changes, subscription lifecycle | | `commet-cli` | CLI — config-as-code: pull and push `commet.config.ts` with `commet pull` / `commet push` | | `commet-webhooks` | Webhooks — event handling, signature verification, framework handlers | | `ai-billing` | AI billing — tracked() middleware, balance model, cost calculation | | `migrate-commet-v7-to-v8` | Migration — upgrade SDK v7 and API `2026-07-11` integrations to v8 and `2026-07-24` | | `migrate-commet-v8-to-v9` | Migration — upgrade v8 Offers and Markets to SDK v9 and API `2026-07-31` | ## Standalone skills Universal billing knowledge that works with any stack. Code examples use `@commet/node`. ```bash npx skills add commet-labs/billing-best-practices npx skills add commet-labs/pricing-models npx skills add commet-labs/subscription-patterns ``` ## Supported agents Claude Code, Cursor, Codex, Gemini CLI, GitHub Copilot, Windsurf, OpenCode, and 40+ more. ## Learn More - [**commet-labs/skills on skills.sh**](https://skills.sh/commet-labs/skills) - [**billing-best-practices**](https://skills.sh/commet-labs/billing-best-practices) - [**pricing-models**](https://skills.sh/commet-labs/pricing-models) - [**subscription-patterns**](https://skills.sh/commet-labs/subscription-patterns) # MCP Server (/docs/mcp-server) ## What is an MCP Server? MCP is an open protocol that standardizes how applications provide context to LLMs. Among other benefits, it provides LLMs tools to act on your behalf. ## What can Commet's MCP Server do? Commet's MCP server gives your AI agent native access to the full Commet platform through a single integration. You can manage all aspects of your billing infrastructure using natural language. - **Organization** — See the live or sandbox organization fixed to the connection - **Commet API** — Use every operation in the current public OpenAPI document - **Docs** — Search the live Commet documentation As an example, you could use this to create a full billing setup, manage plans and features, inspect customer subscriptions, or review invoices and usage data. ## Prerequisites The MCP endpoint is the same for sandbox and live data: ``` https://commet.co/mcp/v2 ``` OAuth opens Commet in the browser and asks you to choose one organization for the connection. The connection stays fixed to that organization even if you later switch organizations in the dashboard; reconnect to choose another one. API key authentication uses the exact sandbox or live organization that created the key. OAuth currently exposes the same API tools to owners, admins, and members. To use it, you'll need to: - [Create a Commet account](https://commet.co) - Have an MCP-compatible client (Cursor, Claude Code, Claude Desktop, etc.) > **Note** > > Browser-based clients can use OAuth with no API key. If your client runs in CI, on a server, or anywhere browser login is not possible, pass a Commet API key in the `x-api-key` header. > **Warning** > > Switch to a sandbox organization when experimenting or setting up billing for the first time. Operations on a live organization affect real customers immediately. ## How to use the MCP Server Choose your preferred client below. ### cursor Open the command palette and choose "Cursor Settings" > "MCP" > "Add new global MCP server". ```json { "mcpServers": { "commet": { "url": "https://commet.co/mcp/v2" } } } ``` ### claude-code ```bash claude mcp add --transport http commet https://commet.co/mcp/v2 ``` ### claude-desktop Open Claude Desktop settings > "Developer" tab > "Edit Config". ```json { "mcpServers": { "commet": { "url": "https://commet.co/mcp/v2" } } } ``` ### codex ```bash codex mcp add commet --url https://commet.co/mcp/v2 ``` The command opens the browser for OAuth authentication. Once complete, verify with: ```bash codex mcp list ``` ### copilot Add the following to your VS Code `settings.json`: ```json { "mcp": { "servers": { "commet": { "type": "http", "url": "https://commet.co/mcp/v2" } } } } ``` ### gemini Add the following to `~/.gemini/settings.json`: ```json { "mcpServers": { "commet": { "httpUrl": "https://commet.co/mcp/v2" } } } ``` ### opencode Add the following to `opencode.json`: ```json { "mcp": { "commet": { "type": "remote", "url": "https://commet.co/mcp/v2" } } } ``` ### windsurf ```json { "mcpServers": { "commet": { "serverUrl": "https://commet.co/mcp/v2" } } } ``` ### API key authentication For clients that cannot open browser login, add an `x-api-key` header: ```bash claude mcp add --transport http commet https://commet.co/mcp/v2 --header "x-api-key: ck_xxxxxxxxx" ``` For JSON-based clients: ```json { "mcpServers": { "commet": { "url": "https://commet.co/mcp/v2", "headers": { "x-api-key": "ck_xxxxxxxxx" } } } } ``` API-key sessions authenticate as the user who created the key and use the exact organization that created the key. A sandbox key can only act on its sandbox organization; a live key can only act on its live organization. Create the key from a sandbox organization when experimenting. ## Available Tools MCP v2 exposes one generated tool for every operation in the current Commet OpenAPI document. The generated names use `api_` plus the operation ID in snake case: ```text list-plans → api_list_plans create-plan → api_create_plan track-usage → api_track_usage ``` Each generated tool groups its input the same way: - `path` for URL parameters - `query` for filters, cursors, and pagination - `body` for the endpoint's exact OpenAPI request body - `idempotencyKey` for optional POST, PUT, and PATCH idempotency Generated tools always execute the latest API version published by Commet. They do not read or change an older API version pinned for the organization's SDKs. The following Commet-specific tools remain available: | Tool | Description | | -------------------------- | ------------------------------------------------------ | | `get_current_organization` | Get the organization used by API tools | | `search_docs` | Search the live Commet documentation by semantic query | The previous MCP endpoint remains available at `https://commet.co/mcp` with its original tools and behavior. New connections should use v2. > **Warning** > > Before calling a write or delete tool, check `get_current_organization`. Operations on a live organization affect real customers and money immediately. # Pricing Models Skill (/docs/pricing-models-skill) The Pricing Models skill helps AI agents choose and implement the right pricing model for any SaaS product. It covers metered, credits, balance, seats, and boolean models with decision frameworks and implementation patterns. ## Installation Install the skill using the following command: ```bash npx skills add commet-labs/pricing-models ``` ## Advantages - **Decision framework**: Three-question flowchart to pick the right model for any product. - **Metered billing**: Pay-per-use with included amounts and overage pricing. - **Credits model**: Prepaid blocks that stop when exhausted — ideal for generation-based products. - **Balance model**: Dollar-denominated prepaid spend — ideal for AI token billing. - **Seat-based pricing**: Per-user billing with hybrid advance and true-up charging. - **Hybrid patterns**: Combining models — base plan + metered, seats + usage, addons. ## Learn More - [**View on GitHub**](https://github.com/commet-labs/pricing-models) - [**View on skills.sh**](https://skills.sh/commet-labs/pricing-models) # Subscription Patterns Skill (/docs/subscription-patterns-skill) The Subscription Patterns skill provides AI agents with universal patterns for the full subscription lifecycle. It covers trials, intro offers, upgrades, downgrades, proration, dunning, cancellation flows, and add-ons. ## Installation Install the skill using the following command: ```bash npx skills add commet-labs/subscription-patterns ``` ## Advantages - **Free trials and intro offers**: Trial setup, conversion flows, discounted first cycles, eligibility rules. - **Upgrades and downgrades**: When to apply immediately vs at renewal, feature access during transition. - **Proration logic**: Time-based credit formula, edge cases, multiple changes in the same period. - **Dunning and retries**: Failed payment recovery, retry schedules, grace period access, notification patterns. - **Cancellation flows**: Immediate vs end-of-period, save offers, reactivation, data retention. - **Add-ons**: Purchasable feature extensions, prorated activation, consumption model compatibility. ## Learn More - [**View on GitHub**](https://github.com/commet-labs/subscription-patterns) - [**View on skills.sh**](https://skills.sh/commet-labs/subscription-patterns) # Balance and Top-Ups (/docs/how-do-balance-and-top-ups-work) A Balance plan includes a monetary allowance that usage draws down. The balance amount is part of the subscription's consumption model; it is not invoice credit. ## What changes the balance? - A plan reset restores the configured plan amount. - A paid top-up charges the saved payment method and adds balance. - An administrative adjustment adds or removes balance without charging. - A usage event subtracts the feature's calculated monetary cost. Plan balance resets monthly for monthly, quarterly, yearly, free, and one-time plans. Weekly plans reset every seven days. **Top-ups reset with the plan balance; they do not persist like credit packs.** Use Credits instead when purchased units must remain across resets. Use Customer Credits when the goal is to reduce an invoice rather than fund product usage. Before expensive work, check the current allowance. Track the event only after the work completes and use an idempotency key so retries do not deduct twice. See [Balance and Top-Ups](/docs/balance-and-top-ups) for dashboard and API steps. # Credits, Credit Packs, and Customer Credits (/docs/how-do-credits-credit-packs-and-customer-credits-differ) These mechanisms share a name but affect different ledgers. | Mechanism | Represents | Reset or expiry | Used by | | ------------------- | -------------------------------------------------------------- | ---------------------------------- | ------------------------------------ | | **Plan credits** | Recurring product units included in a Credits plan | Reset with the plan allowance | Usage events | | **Credit pack** | Product units the customer buys | Persists across plan resets | Usage events after recurring credits | | **Customer credit** | Money that reduces eligible recurring invoices in one currency | Optional expiration; consumed FIFO | Invoice calculation before tax | | **Plan grant** | Temporary access beyond the subscribed plan | Ends when revoked or expired | Feature access, not a credit ledger | Use plan credits and packs when the customer thinks in actions such as generations or exports. Use customer credit when the adjustment is monetary, such as USD 25 after a service incident. Use a plan grant when no invoice should exist at all. Revoking unused customer credit does not rewrite invoices that already consumed it. Purchased credit packs do not disappear at the next plan reset. See [Credit Packs](/docs/credit-packs), [Customer Credits](/docs/customer-credits), and [Plan Grants](/docs/plan-grants) for implementation. # Discounts (/docs/how-do-discounts-work) Commet stores discount terms in independent Offers. Introductory, Promotional, and Promo Code describe **how one Offer is selected**, not different discount entities. ## Selection channels | Channel | Configuration | Selection | Typical use | | ------------ | ------------------------------------------------ | ------------------------------------ | -------------------------------------------- | | Introductory | Attach a compatible Offer to one base plan price | Automatic for an eligible customer | Acquisition and onboarding | | Promotional | Keep the Offer independent | Your integration passes `offerId` | Campaigns, retention, and experiments | | Promo Code | Reference a compatible Offer from a code | Customer enters the code at checkout | Public distribution with redemption controls | A direct Offer does not need a prior association with the selected plan or price. ## Eligibility and exclusivity Automatic introductory selection currently excludes customers with an `active` or `past_due` subscription in the organization. Other historical statuses do not create a lifetime ban. An explicit `offerId` overrides automatic introductory selection. A Promo Code is rejected with `intro_offer_active` while an eligible introductory placement applies. `offerId`, `promoCode`, `customTrialDays`, and `skipTrial: true` are mutually exclusive selection controls. ## Duration and phases An Offer may combine a free trial with ordered discount or fixed-price phases. A finite `durationCycles` counts billing periods, not calendar months. A yearly Offer phase with `durationCycles: 2` lasts two yearly billing cycles. ``` Pro costs $99/month with 50% off for 3 cycles: Cycle 1: $49.50 Cycle 2: $49.50 Cycle 3: $49.50 Cycle 4: $99.00 ``` The final discount phase may use `durationCycles: null` to remain active until the accepted application ends. Promo Codes are intentionally narrower: they can reference only one `percentage` or `amount_off` phase. ## Plan changes The Offer Application belongs to the price target accepted by the subscription. An immediate plan change ends that application. The change may pass a new `offerId`; otherwise the new plan begins at its normal price. Scheduled plan changes do not accept an Offer. ## What gets discounted Current subscription Offer channels apply to the plan base price. Overage, add-on, credit-pack, and seat-overage charges remain at their normal prices. ``` Plan base: $100.00 20% Offer: −$20.00 Overage: $50.00 Total: $130.00 ``` > **Note** > > Accepted phases are stored in an immutable Offer Application. Editing, deactivating, or archiving the catalog Offer changes future applications only. ## Related - [Offers](/docs/offers) - [Introductory Offers](/docs/introductory-offers) - [Promotional Offers](/docs/promotional-offers) - [Promo Codes](/docs/promo-codes) - [Plan Changes](/docs/what-happens-when-a-customer-changes-plans) # Free Plans (/docs/how-do-free-plans-work-without-payment) A free plan has no price and no checkout. When you assign a customer to a free plan, they're activated immediately. Their usage resets every month, just like a paid plan. ## How Free Plans Differ from Paid Plans | | Paid plan | Free plan | | -------------- | ----------------------------- | ---------------------------------------------------------- | | Price | Has a price | $0 | | Billing | Monthly, quarterly, or yearly | No charges — usage resets monthly | | How they start | Checkout + payment | **Activated immediately** | | Invoices | Yes | Only for purchases (credits, balance, add-ons) | | Overage | Configurable per feature | **Never allowed** — usage is blocked at the included limit | | Usage resets | Every month | Every month | ## Monthly Usage Resets Even though there's no billing cycle, your customer's usage resets **every month** — the same cadence as a paid monthly plan. If you give a free plan 1,000 API calls or $50 in balance, that allowance refreshes on their billing day each month. When a customer hits their included limit, they're blocked until the next reset. Overage is never charged on a free plan. ### Example ``` Your free plan includes $100 in balance. Your customer uses $100 by day 15. → They're blocked for the rest of the month. → On their billing day, balance resets to $100. ``` ## What Your Customer Sees in the Portal The Customer Portal adapts when someone is on a free plan: | Section | Paid plan | Free plan | | ----------------------- | ------------------------------------------------- | -------------------------------------------- | | Subscription | Shows plan, price, and next billing date | Shows plan and status only — no billing date | | Invoices | Visible | Visible only if they've made purchases | | Payment method | Visible | Visible only if they've added one | | MRR | Shows amount | Shows $0.00/mo | | Usage (balance/credits) | Visible, with "Add Funds" or "Buy Credits" button | Visible, with purchase buttons | ## Purchasing on a Free Plan Free plan customers can purchase add-ons, credit packs, and balance top-ups — the same one-off purchases available on paid plans. Since they didn't go through checkout, they don't have a card on file. The first purchase prompts them to enter a payment method, which is saved for future purchases. > **Note** > > The plan itself is free. Purchases are optional extras that customers choose to buy. ## Changing a Free Plan's Included Balance or Credits If you change the included balance or credits on a free plan, the change applies at **each customer's next monthly reset**. Balances already granted for the current month don't change. ### Example ``` You increase the free plan's included balance from $100 to $150. A customer whose billing day is the 20th keeps their current balance until the 20th. On the 20th, their monthly reset grants $150. ``` ## When Your Customer Upgrades to a Paid Plan The upgrade is always **immediate**. Since the free plan costs $0, there's no credit to give — your customer simply pays the full price of the new plan from that day. ### Example ``` Your customer is on a Free plan with $100 included balance. They upgrade to Pro at $99/mo. Credit from free plan: $0 (it's free) They pay: $99 (full month) Invoices, payment method, and billing info appear in their portal. ``` ## Related - [Plan Changes](/docs/what-happens-when-a-customer-changes-plans) — Upgrades, downgrades, and switching - [Trials](/docs/how-do-trial-periods-work) — Another way to let customers try before paying - [Invoices](/docs/what-invoices-do-customers-receive-and-when) — What invoices your customers receive # Billing Intervals (/docs/how-do-monthly-quarterly-and-yearly-billing-work) Plans can be billed weekly, monthly, quarterly, or yearly. Even on quarterly and yearly plans, Commet checks for usage charges **every month**. Here's what your customers can expect. ## Weekly Plans Your customer is billed every 7 days. The billing period, consumption resets, and invoicing all happen on a weekly cycle. - Plan base charged every week - Usage (credits, balance, metered) resets every week - Extra seats charged every week - The billing day anchors to the day of the week the subscription started (e.g., every Monday) > **Note** > > Weekly plans reset consumption **every 7 days**, not monthly. A customer on a weekly plan with 1,000 included API calls gets 1,000 calls each week. ## Monthly Plans Straightforward: your customer is billed every month for their plan base, extra usage, and extra seats. ## Quarterly and Yearly Plans Your customer pays the plan base every 3 or 12 months, but **usage charges can happen every month**. Here's how it works: ### Example: Quarterly plan at $300/quarter ``` Month 1: → Your customer used extra API calls? They get a small invoice for just the overage. → No extra usage? No invoice at all. Month 2: → Same thing — only charged if they had extra usage. Month 3 (renewal month): → Full invoice: plan base ($300) + any extra usage + any extra seats. → Billing cycle resets for the next quarter. ``` > **Warning** > > Usage resets **every month**, not every quarter or year. A customer on a quarterly plan with 10,000 included API calls gets 10,000 calls each month — they don't accumulate. ## What Your Customer Sees | | Months between renewals | Renewal month | | --------------------- | ----------------------- | ----------------------- | | Plan base | Not charged | Charged | | Extra usage | Charged (if any) | Charged | | Extra seats | Not charged | Charged | | No extra usage at all | No invoice | Invoice (has plan base) | ## Plan Changes Mid-Cycle If your customer upgrades a quarterly or yearly plan mid-cycle, they get credit for the unused portion and start a new cycle from the change date. See [Proration](/docs/how-is-proration-calculated-when-changing-plans) for the exact calculation. ### Example ``` Your customer is on Plan A at $300/quarter (January 1 – April 1). They upgrade to Plan B at $600/quarter on February 15. → They get credit for the unused portion of Plan A. → They're charged Plan B's full price minus that credit. → Their new cycle starts February 15, next renewal May 15. ``` ## Related - [Invoices](/docs/what-invoices-do-customers-receive-and-when) — What invoices your customers receive and when - [Proration](/docs/how-is-proration-calculated-when-changing-plans) — How mid-cycle charges are calculated - [Plan Changes](/docs/what-happens-when-a-customer-changes-plans) — Upgrades, downgrades, and switching # Offers, Promo Codes, and Card Promotions (/docs/how-do-offers-promo-codes-and-card-promotions-work-together) An Offer owns the economic terms: trial, percentage discount, amount off, or temporary fixed price. Introductory Offers, direct Promotional Offers, Promo Codes, and Card Promotions decide how those terms reach a customer. | Channel | Who selects it | Additional rule | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | | **Introductory** | Commet selects it from the plan price | New-customer eligibility | | **Direct Promotional** | Your application sends `offerId` | Your application decides the audience | | **Promo Code** | Customer enters a code | Plan, interval, expiration, and redemption limits | | **Card Promotion** | Your application preselects `cardPromotionId`, or Commet discovers an auto-apply promotion; checkout verifies the card | BIN, billing interval, and Offer compatibility | Only one channel applies to the initial checkout. An explicit Offer overrides automatic introductory selection. A Promo Code cannot combine with an explicit Offer, and a Card Promotion must remain conditional until the entered card is verified. When terms are accepted, Commet stores an immutable Offer Application. Editing the catalog Offer changes future applications, not the phases already accepted by an existing subscription. See [Offers](/docs/offers) and [Card Promotions](/docs/card-promotions) for setup. # Trials (/docs/how-do-trial-periods-work) A trial is a `free_trial` phase in an Offer. It delays the first charge; the Offer's application channel determines whether Commet selects it automatically or your integration assigns it directly. ## What the customer experiences | | Regular checkout | Trial checkout | | -------------- | --------------------------- | ----------------------------------------- | | Payment | Charged immediately | Payment method saved, no immediate charge | | Primary action | Pay | Start free trial | | After checkout | Subscription becomes active | Subscription becomes `trialing` | The trial starts after payment-method setup succeeds. If the card requires 3D Secure, the customer completes verification before the trial begins. ## How it works ``` 1. Commet resolves an Offer with a free_trial phase. 2. Checkout saves the payment method without charging it. 3. The subscription becomes trialing and records trialEndsAt. 4. At the end, Commet charges the selected price's current catalog value. 5. The next accepted Offer phase starts, if one exists. ``` > **Warning** > > The accepted Offer phases are immutable, but the selected catalog price is not. A price change during the trial changes the first paid charge. ## Reusable and customer-specific trials Use a catalog Offer when the trial should have a reusable name and distribution strategy. It can be attached to a base price as introductory or passed directly with `offerId`. Use `customTrialDays` for terms exclusive to one subscription. Commet records those accepted terms as a custom Offer Application. Use `skipTrial: true` to bypass automatic introductory selection. ## During the trial | Behavior | During trial | | ------------------------------------- | --------------------------------------------------------- | | Plan features | Available | | Overage | Blocked at included limits | | Credit, balance, and add-on purchases | Available after checkout captures the payment method | | Metered usage reset | Monthly on the billing day for trials longer than a month | | Included balance and credits | Granted once when the trial starts | ## Related - [Trial Periods](/docs/trial-periods) - [Offers](/docs/offers) - [Free Plans](/docs/how-do-free-plans-work-without-payment) - [Payment Failures](/docs/what-happens-when-a-payment-fails) # How Billing Works (/docs/how-does-billing-work) Commet measures what a customer can consume and charges according to the plan they accepted. Your catalog defines the terms, the subscription stores the active relationship, usage records consumption, invoices calculate what is owed, and transactions record payment attempts. Those layers change at different times. Commet separates catalog changes, subscription changes, and billing recovery. Each has an explicit rule; there is no single price-based heuristic for every operation. | Layer | Source of truth | Typical question | | ------------ | -------------------------------------------------------- | ----------------------------------------------- | | Catalog | Plans, prices, features, Offers, and Markets | What can a new customer buy? | | Subscription | Selected plan, price, accepted Offer, period, and status | What did this customer accept? | | Consumption | Usage, seats, quota, credits, or balance | What did the customer use? | | Invoice | Line items, tax, credits, and amount due | What does the customer owe? | | Transaction | Provider-neutral payment attempt | Did the charge succeed, fail, retry, or refund? | ## Quick Reference ### When You Make Changes | What you do | What happens to existing customers | | --------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | Change the selected base or variant price | Current paid period stays unchanged; renewal uses that catalog row's current value | | Lower a per-unit price (usage, seats, quota) | **Applies immediately** — this period is billed at the cheaper price | | Raise a per-unit price (usage, seats, quota) | New price applies starting next period | | Increase included units, enable a feature, or make it unlimited | **Updates the current-period snapshot** | | Reduce included units, disable a feature, or remove it | Existing snapshot remains until renewal | | Hide or soft-delete a plan | Existing subscriptions keep it; new selection is blocked | | Archive a price | New selection is blocked; subscriptions already bound to it continue | | Change a free plan's included balance/credits | Applies at each customer's next monthly reset | ### When Your Customer Makes Changes | What they do | What happens | | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | Move to a higher-`sortOrder` plan in the same group without changing interval | **Immediate** — charged the new plan's full price minus a credit for unused days; the cycle restarts | | Move to a lower-`sortOrder` plan in the same group without changing interval | Keeps current plan until renewal, then switches | | Switch from free to paid | **Immediate** — pays full price, no credit (free = $0) | | Switch from paid to free | Keeps current plan until renewal, then switches | | Switch to a longer interval | **Immediate** | | Switch to a shorter interval | Takes effect at renewal | ### Automatic Events | What happens | What your customer experiences | | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | | Trial starts | They use the product, no charge. **Overage is blocked** — usage stops at included limits | | Trial ends | They're charged the current price, overage activates normally | | Renewal or retryable first-trial charge fails | Subscription becomes `past_due`; usage continues while retries run and purchases are blocked | | First-trial charge needs a payment method or customer action | Subscription remains `pending_payment` so checkout can complete | | Canceled subscription | Can be reactivated — fresh invoice at the current price, new billing period starts that day | ## The Boundaries That Matter - Interval direction determines timing first. When the interval is unchanged, the plan group's order defines upgrade and downgrade direction, not the amount. Paid-to-free changes are always scheduled. - The subscription stores the selected price ID. Renewal reads that row's current catalog value. - Current-period feature snapshots protect already-started periods from harmful feature changes while allowing beneficial changes immediately. - Accepted Offer phases are immutable snapshots; base prices are not. - Recovery retries the same outstanding invoice instead of creating a new sale. ## Explore Each Topic - [Pricing Changes](/docs/what-happens-when-you-change-your-prices) — What happens when you change your prices - [Plan Changes](/docs/what-happens-when-a-customer-changes-plans) — Upgrades, downgrades, and switching plans - [Billing Intervals](/docs/how-do-monthly-quarterly-and-yearly-billing-work) — Monthly, quarterly, and yearly billing - [Invoices](/docs/what-invoices-do-customers-receive-and-when) — What invoices your customers receive and when - [Proration](/docs/how-is-proration-calculated-when-changing-plans) — How mid-cycle charges are calculated - [Trials](/docs/how-do-trial-periods-work) — How trial periods work - [Free Plans](/docs/how-do-free-plans-work-without-payment) — How free plans work - [Payment Failures](/docs/what-happens-when-a-payment-fails) — What happens when a payment fails - [Seats](/docs/how-does-seat-based-billing-work) — How seat-based billing works # Payment Routing (/docs/how-does-payment-routing-work) For a new checkout, Commet uses the payment connection assigned to the customer's country. If no country route matches, it uses the organization's default connection. Once a subscription stores a payment method, renewals and retries continue through that same connection. Changing a country route or the default affects new selection; it does not copy an existing card between Commet, Stripe, and dLocal. This means routing is not automatic failover. A provider decline or outage is recorded as a failure. Commet does not silently move the charge to another provider account. To move an existing customer, collect a new payment method through checkout or the Customer Portal on the intended connection. That is a new setup, not a database migration of card credentials. Use the dashboard under **Settings → Payments** to connect providers, assign countries, and choose the default. Inspect the provider and connection on **Transactions** when diagnosing a payment. See [Payment Orchestration](/docs/payment-orchestration) for configuration. # Quota (/docs/how-does-quota-based-billing-work) Plans can include a quota of a durable resource at no extra cost — tasks, WhatsApp numbers, parallel automations. When your customer goes over the included amount, they're charged per extra unit. Unlike a usage meter that only counts up, a quota balance rises and falls as customers create and delete. ## How it works Units above the included amount at the start of a period are billed for the **full period in advance**. ``` Plan Pro: 15 included tasks, $0.75/extra task Your customer starts the period holding 25 tasks Included: 15 tasks (no charge) Extra: 10 tasks × $0.75 = $7.50 — billed in advance for the full period ``` ## Quota rises and falls The balance moves during the period, but billing follows the **high-water mark** — the highest quota held during the period. Each increase above the mark is billed from the day of the increase to the **end of the period**; deleting units never reduces the charge. ``` Your customer starts the month at 15 tasks. They create 10 more on day 15 (25 total), then delete them on day 20. Extra: 10 tasks billed from day 15 to the end of the period 10 × $0.75 × (15/30) = $3.75 ``` The charge is the same whether they keep the 10 tasks until the end of the period or delete them on day 20. Re-creating units below the high-water mark costs nothing — they're already billed. These charges land on the next renewal invoice. ## What happens when you change the included amount ### Adding more included units More included units **benefits your customers**, so it applies right away. ``` You increase Plan Pro from 15 to 30 included tasks. Your customer has 25 tasks (was paying for 10 extra at $7.50/mo). After the change: → All 25 tasks are now within the 30 included → Extra charges drop to $0 → Your customer sees the change immediately ``` ### Reducing included units Fewer included units harms your customers, so it applies **at renewal**. ``` You decrease Plan Pro from 30 to 15 included tasks. Your customer has 25 tasks (all included, $0 extra). This period: Still 25 within the included amount, $0 extra At renewal: 15 included + 10 extra = $7.50/mo ``` ## What happens when you change the price Quota unit prices use current-period snapshots: a lower price applies immediately, while a higher price starts next period. | Change | What your customer pays | | --------------- | -------------------------------------------- | | Lower the price | New lower price in the current period | | Raise the price | Old price this period; new price next period | ## Quota and upgrades On an upgrade, your customer is charged the new plan's **full price** and credited for the unused days of the old plan's **base price**. Extra-unit charges already paid are not credited. ### Example ``` Your customer is on Pro ($29/mo, 15 included tasks, $0.75/extra task). They have 25 tasks — paying $36.50/mo total (15 included + 10 extra). They upgrade to Scale ($79/mo, 50 included tasks, $0.50/extra task). On day 15 of their cycle. Charge: New plan: $79 (full price — the billing cycle restarts today) Extra tasks: $0 — their 25 tasks are within the 50 included Credit: Plan base: $29 × (15/30) = $14.50 Extra tasks: $0 — extra-unit charges already paid are not credited They pay today: $64.50 ``` > **Note** > > Your customer's 25 tasks are now covered by the Scale plan's 50 included tasks. They stop paying for extra tasks entirely. ## Related - [Proration](/docs/how-is-proration-calculated-when-changing-plans) — How mid-cycle charges are calculated - [Pricing Changes](/docs/what-happens-when-you-change-your-prices) — How price changes apply to existing customers - [Billing Intervals](/docs/how-do-monthly-quarterly-and-yearly-billing-work) — When quota is charged on quarterly and yearly plans # Seats (/docs/how-does-seat-based-billing-work) Plans can include a number of seats at no extra cost. When your customer uses more seats than included, they're charged per extra seat. ## How It Works ``` Plan Pro: 5 included seats, $25/extra seat Your customer has 8 seats Included: 5 seats (no charge) Extra: 3 seats × $25 = $75/mo ``` ## What Happens When You Change Included Seats ### Adding more included seats More included seats **benefits your customers**, so it applies right away. ``` You increase Plan Pro from 10 to 15 included seats. Your customer has 12 seats (was paying for 2 extra at $50/mo). After the change: → All 12 seats are now within the 15 included → Extra seat charges drop to $0 → Your customer sees the change immediately ``` ### Reducing included seats Fewer included seats harms your customers, so it applies **at renewal**. ``` You decrease Plan Pro from 15 to 10 included seats. Your customer has 12 seats (all included, $0 extra). This period: Still 12 included, $0 extra At renewal: 10 included + 2 extra = $50/mo ``` ## What Happens When You Change Seat Prices Seat unit prices use current-period snapshots: a lower price applies immediately, while a higher price starts next period. | Change | What your customer pays | | --------------- | -------------------------------------------- | | Lower the price | New lower price in the current period | | Raise the price | Old price this period; new price next period | ## Seats and Upgrades On an upgrade, your customer is charged the new plan's **full price** and credited for the unused days of the old plan's **base price**. Extra-seat charges already paid are not credited; extra seats on the new plan are billed for the full new period in advance. ### Example ``` Your customer is on Pro ($99/mo, 5 included seats, $25/extra seat). They have 8 seats — paying $174/mo total (5 included + 3 extra). They upgrade to Business ($299/mo, 10 included seats, $20/extra seat). On day 15 of their cycle. Charge: New plan: $299 (full price — the billing cycle restarts today) Extra seats: $0 — their 8 seats are within the 10 included Credit: Plan base: $99 × (15/30) = $49.50 Extra seats: $0 — extra-seat charges already paid are not credited They pay today: $249.50 ``` > **Note** > > Your customer's 8 seats are now covered by the Business plan's 10 included seats. If they had 12 seats, the 2 above the included 10 would be billed for the full new period in advance: 2 × $20 = $40. ## Related - [Proration](/docs/how-is-proration-calculated-when-changing-plans) — How mid-cycle charges are calculated - [Pricing Changes](/docs/what-happens-when-you-change-your-prices) — How price changes apply to existing customers - [Billing Intervals](/docs/how-do-monthly-quarterly-and-yearly-billing-work) — When seats are charged on quarterly and yearly plans # Proration (/docs/how-is-proration-calculated-when-changing-plans) When your customer upgrades mid-cycle, they're charged the new plan's **full price**, credited for the unused days of the old plan's base price, and their billing cycle **restarts on the day of the change**. ## The Calculation ``` Credit = Old plan base price × (days remaining / days in cycle) Charge = New plan's full price They pay = Charge - Credit The billing cycle restarts on the day of the change. ``` The credit covers the old plan's **effective base price only**. If an Offer discount was active, Commet credits what the customer actually paid, not the undiscounted list price. Metered usage consumed since the period started is charged on the same invoice. ## Example: Simple Upgrade ``` Your customer is on Starter at $29/mo, paid on January 1. They upgrade to Pro at $99/mo on January 15 (15 days remaining). Charge for Pro (full price): $99.00 Credit for unused Starter days: $29 × (15/30) = $14.50 They pay today: $84.50 New billing cycle: January 15 – February 15 Next full invoice: $99 on February 15 ``` ## Example: Upgrade with Extra Seats Extra seats are **not credited** on a plan change. Extra seats on the new plan are billed for the full new period in advance. ``` Current plan: Pro $99/mo, 5 included seats, $25/extra seat Your customer has 8 seats (5 included + 3 extra = $174/mo total) They upgrade to: Business $299/mo, 10 included seats, $20/extra seat On January 15 (15 days remaining) ``` | | Calculation | | -------------------------- | ----------------------------------------------------- | | Charge for new plan base | $299 (full price) | | Charge for new extra seats | $0 — their 8 seats are within the 10 included | | Credit for old plan base | $99 × (15/30) = $49.50 | | Credit for extra seats | $0 — extra-seat charges already paid are not credited | | **They pay today** | **$249.50** | > **Note** > > Your customer's 8 seats are now fully covered by the Business plan's 10 included seats, so they stop paying for extra seats. If they had more seats than the new plan includes, the excess would be billed for the full new period in advance. ## Quarterly and Yearly Plans The same calculation applies — the only difference is the cycle length. ### Example ``` Your customer is on Plan A at $300/quarter (January 1 – April 1). They upgrade to Plan B at $600/quarter on February 15 (45 days remaining). Charge for Plan B (full price): $600.00 Credit: $300 × (45/90) = $150.00 They pay today: $450.00 New billing cycle: February 15 – May 15 Next renewal: May 15 ``` ## Why Downgrades Aren't Prorated Downgrades take effect **at renewal**. Your customer already paid for the current cycle and keeps their plan until it expires. Since there's no mid-cycle switch, there's nothing to prorate and no refund. ## Why Free → Paid Isn't Prorated When your customer moves from a free plan to a paid plan, there's no credit to give — the free plan costs $0. They simply pay the full price of the new plan from that day. ## Related - [Plan Changes](/docs/what-happens-when-a-customer-changes-plans) — When proration applies and when it doesn't - [Seats](/docs/how-does-seat-based-billing-work) — How seat-based billing works with upgrades - [Invoices](/docs/what-invoices-do-customers-receive-and-when) — What invoices your customers receive # Plan Changes (/docs/what-happens-when-a-customer-changes-plans) Your customers can switch between plans in the same plan group through the [Customer Portal](/docs/customer-portal). The group's `sortOrder` defines upgrade and downgrade direction; Commet does not infer it from price. ## Upgrades When the billing interval stays the same, moving to a plan with a higher `sortOrder` is an **immediate** change. The customer is charged the new plan's full price, credited for the unused days of the old effective base price, and their billing cycle restarts on the day of the change. > **Warning** > > An upgrade is blocked if the customer already uses more seats than the new plan includes, when that seat feature has overage disabled. They have to remove the extra seats first, or choose a plan with enough included seats. ### Example ``` Your customer is on Starter at $29/mo, paid on January 1. They upgrade to Pro at $99/mo on January 15 (15 days left). Charge for Pro (full price): $99.00 Credit for unused Starter days: $29 × (15/30) = $14.50 They pay today: $84.50 New billing cycle: January 15 – February 15 Next full invoice: $99 on February 15 ``` ## Downgrades When the billing interval stays the same, moving to a plan with a lower `sortOrder` is scheduled **at renewal**. The customer keeps the current plan and features until the end of the paid period, then switches. > **Note** > > Your customer already paid for this period. They keep full access until it expires — no partial refunds, no disruption. ## Free to Paid When your customer moves from a free plan to a paid plan, the change is always **immediate**. There's no credit to issue since the free plan costs $0, so they pay the full price of the new plan. ### Example ``` Your customer is on a Free plan with $100 included balance. They upgrade to Pro at $99/mo. Credit from free plan: $0 (it's free) They pay today: $99 (full month) ``` ## Changing Billing Frequency | Change | What happens | | --------------------------------------------------- | ------------- | | Shorter → longer interval, such as monthly → yearly | **Immediate** | | Longer → shorter interval, such as yearly → monthly | At renewal | Interval direction takes precedence over `sortOrder` when both the plan and interval change. A paid-to-free change is always scheduled, even if the destination plan has a higher `sortOrder`. ## Deprecating a Plan When you deprecate a plan, it disappears from your pricing page, dashboard, and portal — but **your existing customers keep it**. Their billing continues normally. If they cancel, they won't be able to come back to that plan. ## Deleting a Plan Deleting a plan is a **soft delete**. The plan disappears from your pricing page and can't be assigned to new customers — but existing subscriptions are unaffected and keep billing normally. ## Reactivation A canceled subscription can be **reactivated** — from the dashboard, the API, or the Customer Portal. Reactivation generates a fresh invoice at the plan's current price, charges the saved card, and starts a new billing period on the reactivation date. If the charge is declined, the subscription stays canceled and your customer gets a link to update their card. If the cancellation is only scheduled for the end of the period, your customer can undo it in the portal before it takes effect. > **Note** > > If the plan is no longer available, reactivation is rejected — your customer starts a new subscription on a different plan. ## Feature Changes Feature changes use the current-period subscription snapshot: | What you do | Existing customers | | -------------------------------------------------------------------------------------------------- | --------------------------------------- | | Add a feature, enable it, increase its included amount, make it unlimited, or lower its unit price | **Applied to the current period** | | Remove a feature, disable it, reduce its included amount, or raise its unit price | Existing snapshot remains until renewal | ### Example ``` You lower Plan Pro from 10,000 API calls to 5,000. New customers get 5,000. Existing customers keep 10,000 until renewal, then switch to 5,000. ``` ``` You raise Plan Pro from 5,000 API calls to 10,000. All customers — new and existing — get 10,000 right away. ``` ## Related - [Proration](/docs/how-is-proration-calculated-when-changing-plans) — Exactly how mid-cycle charges are calculated - [Pricing Changes](/docs/what-happens-when-you-change-your-prices) — What happens when you change prices without changing plans - [Billing Intervals](/docs/how-do-monthly-quarterly-and-yearly-billing-work) — How quarterly and yearly billing works # Payment Failures (/docs/what-happens-when-a-payment-fails) When a payment fails, your customer's usage and seats keep working while Commet retries the charge — but new purchases are blocked. If all retries fail, their subscription is canceled. ## What Your Customer Experiences ``` Payment fails → Usage and seats keep working (grace period begins) → New purchases are blocked → Commet retries the charge automatically All retries fail → Their subscription is canceled → Access is revoked ``` | Stage | Access | What's happening | | --------------------- | -------------------------------- | ---------------------------------------------------------------------------------- | | Payment fails | **Usage and seats keep working** | Subscription moves to `past_due`, invoice becomes `outstanding`, retries scheduled | | During grace period | **Usage and seats keep working** | Automatic retries on day 1, day 3, day 5, and day 7; usage keeps accruing as debt | | All retries exhausted | Revoked | Subscription canceled, invoice marked `uncollectible` | > **Warning** > > **Purchases are blocked while `past_due`.** Plan changes, add-ons, credit packs, and balance top-ups are rejected until the outstanding invoice is paid. Usage and seat tracking keep working so your customer can pay and continue without interruption. ## First Charge After a Trial A retryable provider decline on the first paid invoice follows the same `past_due` dunning schedule. If the charge cannot start because the payment method is missing or the customer must take an action, the subscription remains `pending_payment` and checkout is used to complete payment instead of starting dunning. ## Related - [Invoices](/docs/what-invoices-do-customers-receive-and-when) — What invoices your customers receive - [Trials](/docs/how-do-trial-periods-work) — What happens if the first charge after a trial fails - [Plan Changes](/docs/what-happens-when-a-customer-changes-plans) — How a canceled customer can come back # Pricing Changes (/docs/what-happens-when-you-change-your-prices) When you change a price, **new customers pay the new price immediately**. For existing customers, the rule is asymmetric: **decreases apply immediately, increases apply at renewal**. The exception is a plan's selected base price or variant, which changes at renewal in both directions. ## Base and Variant Prices The subscription keeps the selected price ID. Changes to that catalog row take effect **at renewal**, whether you raise or lower it — the current period is already paid. If the selected row is a regional variant, its own current market price is used. | | New customers | Existing customers | | ------------------- | ------------- | -------------------------------- | | You raise the price | Pay new price | Keep current price until renewal | | You lower the price | Pay new price | Keep current price until renewal | ### Example ``` You change Plan Pro from $99/mo to $129/mo. A new customer signs up today → pays $129/mo. An existing customer on day 15 of their month → keeps paying $99/mo. That same customer at renewal → starts paying $129/mo. ``` > **Note** > > Archiving a price prevents new selection but does not break subscriptions already bound to it. Accepted Offer phases remain snapshotted separately. ## Usage-Based Pricing (Overage) Per-unit price changes are asymmetric: a **decrease applies immediately** to the current period, an **increase applies at renewal**. ### Example ``` You raise the overage price from $0.002/call to $0.005/call. Your customer has already used 5,000 extra calls this month. This month's invoice → still charged at $0.002 Next month's invoice → charged at $0.005 You lower the overage price from $0.005/call to $0.002/call. This month's invoice → charged at $0.002 — the cheaper price applies immediately ``` ## Seat Pricing Per-seat prices follow the same asymmetry: cheaper applies now, more expensive applies at renewal. ### Example ``` You raise the extra seat price from $25/seat to $35/seat. Your customer has 3 extra seats. This month → 3 × $25 = $75 Next month → 3 × $35 = $105 You lower it from $35/seat to $25/seat. This month → 3 × $25 = $75 — applied immediately ``` ## Related - [Plan Changes](/docs/what-happens-when-a-customer-changes-plans) — What happens when your customer upgrades or downgrades - [Proration](/docs/how-is-proration-calculated-when-changing-plans) — How mid-cycle charges are calculated - [Seats](/docs/how-does-seat-based-billing-work) — How seat-based billing works # Refunds and Retries (/docs/what-happens-when-you-refund-or-retry-a-transaction) A refund and a retry create different payment outcomes. Neither should be inferred from a browser redirect. ## Refund A full refund is requested against a successful transaction. Commet returns the provider-neutral refund with its actual status and emits `payment.refunded` when confirmed. The refund does not automatically invent your product's access policy. Decide whether a refunded purchase revokes access, restores balance, or requires manual review, then apply that rule idempotently from the webhook. ## Retry A retry applies to a failed subscription renewal. The failed transaction remains unchanged for audit and the retry creates a new attempt against the connection already bound to the subscription. If the retry succeeds, the outstanding invoice is settled and the subscription can return to `active`. If customer action or a new card is required, use a recovery link or payment-method update instead. See [Transactions, Refunds, and Retries](/docs/transactions-refunds-and-retries) and [Handle Failed Payments](/docs/handle-failed-payments). # Invoices (/docs/what-invoices-do-customers-receive-and-when) Your customers receive invoices automatically based on what happens in their subscription. Here's every type of invoice they might see and when it's generated. ## Types of Invoices | Invoice | When your customer receives it | | ----------------- | ---------------------------------------------------------------------------- | | Recurring | Every billing cycle — includes plan base, extra usage, and extra seats | | Overage | Between billing cycles (quarterly/yearly plans only) if they had extra usage | | Plan change | When they upgrade or switch plans mid-cycle | | Credit purchase | When they buy a credit pack | | Balance top-up | When they add funds to their balance | | Add-on activation | When they activate an add-on mid-cycle (prorated) | | One-time payment | When they pay a payment link or a standalone charge | | Reactivation | When a canceled subscription is reactivated | | Adjustment | When you manually issue a correction or one-off charge | ## Recurring This is the standard invoice your customer receives on each billing cycle. It includes everything: plan base price, extra usage charges, and extra seat charges. Extra seats are billed both ways on this invoice: **in advance** for the seats held going into the new period, and a **true-up** for seats added mid-cycle during the period that just ended, prorated by day. Quota works the same way. ### Example ``` Monthly invoice for a customer on Plan Pro at $99/mo: Plan base: $99.00 Extra usage (2,000 API calls × $0.01): $20.00 Extra seats — upcoming period (2 × $25): $50.00 Extra seats — true-up (1 × $25 × 15/30 days): $12.50 ───────────────────────────────────────────── Total: $181.50 ``` ## Overage For customers on quarterly or yearly plans, if they go over their included usage **between billing cycles**, they receive a smaller invoice covering just the extra usage. No plan base or seat charges — just the overage. ### Example ``` A customer on a quarterly plan used 500 extra API calls in month 2: Extra usage (500 × $0.01): $5.00 ───────────────────────────────── Total: $5.00 ``` If they had no extra usage that month, they don't receive any invoice. ## Plan Change When your customer upgrades through the portal, they receive an invoice charging the new plan's full price minus a credit for the unused days of the old plan's base price. Their billing cycle restarts on the day of the change. ### Example ``` Customer upgrades from Starter ($29/mo) to Pro ($99/mo) on day 15: Pro — new period (full price): $99.00 Credit for unused Starter days: -$14.50 ──────────────────────────────────────── Total: $84.50 ``` ## Other Invoices - **Credit purchase** — when your customer buys a credit pack through the portal or checkout. - **Balance top-up** — when your customer adds funds to their balance. - **Add-on activation** — when your customer activates an add-on mid-cycle; the first charge is prorated. - **One-time payment** — when your customer pays a payment link or a standalone charge, outside any subscription. - **Reactivation** — when a canceled subscription is reactivated; a fresh invoice at the plan's current price, starting a new billing period. - **Adjustment** — when you manually create a correction, refund, or one-off charge from the dashboard. ## Invoice Line Types Every invoice is built from these line types: - `plan_base`: The plan's base price. - `feature_overage`: Extra metered usage above the included amount. - `feature_seats`: Extra seat charges — advance and true-up. - `feature_quota`: Extra quota charges — advance and true-up. - `discount`: Introductory offer discount. - `promo_code_discount`: Promo code discount. - `credit`: Credit applied to the invoice. - `balance_overage`: Balance spent past zero, when the plan allows it. - `addon_base`: Add-on charge at renewal. - `one_time`: Standalone one-time payment. ## Related - [Billing Intervals](/docs/how-do-monthly-quarterly-and-yearly-billing-work) — When quarterly and yearly customers get charged - [Proration](/docs/how-is-proration-calculated-when-changing-plans) — How mid-cycle charges are calculated - [Plan Changes](/docs/what-happens-when-a-customer-changes-plans) — When plan change invoices are created # API Versioning (/docs/api-versioning) Commet uses date-based API versioning inspired by Stripe. Every breaking change is gated behind a version, and your integration stays on its pinned version until you explicitly upgrade. ## Version format Versions use the date they were released: `YYYY-MM-DD` (e.g. `2026-05-01`). The current version is **2026-08-27**. ## How versions are resolved Every API request and webhook delivery resolves a version using this priority: | Priority | Source | Description | | -------- | ----------------------- | ---------------------------------------------- | | 1 | `Commet-Version` header | Per-request override (API only) | | 2 | Endpoint pin | Per-webhook-endpoint version (webhooks only) | | 3 | Organization pin | Set when your org was created or last upgraded | | 4 | Current version | Latest version, used as fallback | For API requests, send the `Commet-Version` header to override your organization pin for that request: ```bash curl https://commet.co/api/v1/subscriptions \ -H "x-api-key: $COMMET_API_KEY" \ -H "Commet-Version: 2026-07-31" ``` For webhooks, each endpoint can have its own pinned version. If not set, it falls back to your organization's pin. ## What changes between versions A new version ships whenever a breaking change needs one — sometimes several land in the same month, sometimes months pass without any. This never affects a running integration: your organization and webhook endpoints stay on their pinned version regardless of how many versions ship after it. A breaking change is anything that: - Removes a field from a response - Renames a field - Changes a field's type - Changes the structure of a nested object - Alters default behavior Non-breaking changes ship continuously and never require a version bump: - New fields added to responses - New event types - New API endpoints - New optional request parameters ## Backward compatibility policy When a new version is released: 1. Your existing pin continues to work unchanged — responses keep the shape of your pinned version 2. Pins never expire: there is no support window and no automatic upgrade. Your version changes only when you change it 3. If a pin references a version that no longer exists, requests on it resolve to the current version 4. The legacy unversioned `/api/*` paths (predating `/api/v1`) return `Deprecation: true` and `Sunset` headers with a `Link` to their `/api/v1` successor — versioned endpoints never carry these headers ## Upgrading your version 1. Review the changelog for breaking changes between your current and target version 2. Update your code to handle the new response shapes 3. Test with the `Commet-Version` header before committing 4. Change your organization's pinned version from the dashboard For webhooks, you can pin a new endpoint to the latest version while keeping the old one active. Both receive events transformed to their respective versions, letting you validate in production before switching. ## SDK behavior Each SDK release ships pinned to the API version it was built against and sends the `Commet-Version` header automatically. This prevents the class of bug where upgrading an SDK version silently changes response shapes. Every SDK also lets you pin a different version at client construction — no SDK upgrade required: | SDK | Pin option | | ------- | ------------------------------------------ | | Node.js | `apiVersion` in the constructor options | | Python | `api_version` in the constructor | | Go | `commet.WithApiVersion(...)` client option | | Java | `Commet.builder().apiVersion(...)` | | PHP | `apiVersion` constructor argument | ## Webhook versioning Webhook payloads include an `apiVersion` field in the envelope so you always know which version shaped the data: ```json { "event": "subscription.activated", "timestamp": "2026-05-12T14:30:00.000Z", "organizationId": "org_abc123", "mode": "live", "apiVersion": "2026-07-31", "data": { ... } } ``` Each webhook endpoint can be pinned independently. When migrating, create a second endpoint pinned to the new version — both receive every event, each transformed to its own version. Once the new endpoint is working, delete the old one. # Better Auth (/docs/better-auth) [Better Auth](https://better-auth.com) is a modern authentication library for TypeScript. This plugin integrates Commet directly into your Better Auth setup. ## Features - Automatic customer creation on signup - Customer Portal for self-service billing management - Subscription management (get, cancel) - Feature access control (boolean, metered, seats) - Usage tracking for metered billing - Seat management for per-user pricing - Optional webhook handling with signature verification ## Installation ### pnpm ```bash pnpm add better-auth @commet/better-auth @commet/node ``` ### npm ```bash npm install better-auth @commet/better-auth @commet/node ``` ### yarn ```bash yarn add better-auth @commet/better-auth @commet/node ``` ### bun ```bash bun add better-auth @commet/better-auth @commet/node ``` ## Preparation Get your API key from the Commet dashboard under **Settings → API Keys**. Use a key from a sandbox organization while you're integrating; switch to a live-org key when you go to production. ```bash title=".env" COMMET_API_KEY=ck_... ``` ## Server Configuration ```typescript title="auth.ts" import { betterAuth } from "better-auth"; import { commet, portal, subscriptions, features, usage, seats, } from "@commet/better-auth"; import { Commet } from "@commet/node"; const commetClient = new Commet({ apiKey: process.env.COMMET_API_KEY!, }); export const auth = betterAuth({ // ... your config plugins: [ commet({ client: commetClient, createCustomerOnSignUp: true, use: [ portal(), subscriptions(), features(), usage(), seats(), ], }), ], }); ``` ## Client Configuration ```typescript title="auth-client.ts" import { createAuthClient } from "better-auth/react"; import { commetClient } from "@commet/better-auth"; export const authClient = createAuthClient({ plugins: [commetClient()], }); ``` ## Configuration Options ```typescript commet({ client: commetClient, // Required: Commet SDK instance createCustomerOnSignUp: true, // Auto-create customer on signup getCustomerCreateParams: ({ user }) => ({ fullName: user.name, metadata: { source: "web" }, }), use: [/* plugins */], }) ``` > **Note** > > When `createCustomerOnSignUp` is enabled, a Commet customer is automatically created using the user's ID as the `customerId`. No database mapping required. ## Portal Plugin Redirects users to the Commet customer portal for self-service billing management. ```typescript title="Server" import { commet, portal } from "@commet/better-auth"; commet({ client: commetClient, use: [ portal({ returnUrl: "/dashboard" }), ], }) ``` ```typescript title="Client" // Redirects to Commet customer portal await authClient.customer.portal(); ``` ## Subscriptions Plugin Manage customer subscriptions. ```typescript title="Server" import { commet, subscriptions } from "@commet/better-auth"; commet({ client: commetClient, use: [subscriptions()], }) ``` ```typescript title="Client" // Get current subscription const { data: subscription } = await authClient.subscription.get(); // Cancel subscription await authClient.subscription.cancel({ reason: "Too expensive", immediate: false, // Cancel at period end }); ``` ## Features Plugin Check feature access for the authenticated user. ```typescript title="Server" import { commet, features } from "@commet/better-auth"; commet({ client: commetClient, use: [features()], }) ``` ```typescript title="Client" // List all features const { data: featuresList } = await authClient.features.list(); // Get specific feature const { data: feature } = await authClient.features.get("api_calls"); // Check if feature is enabled (boolean) const { data: check } = await authClient.features.check("sso"); // Check if user can use one more unit (metered) const { data: canUse } = await authClient.features.canUse("api_calls"); // Returns: { allowed: boolean, willBeCharged: boolean } ``` ## Usage Plugin Track usage events for metered billing. ```typescript title="Server" import { commet, usage } from "@commet/better-auth"; commet({ client: commetClient, use: [usage()], }) ``` ```typescript title="Client" await authClient.usage.track({ feature: "api_calls", value: 1, idempotencyKey: `evt_${Date.now()}` }); ``` > **Note** > > The authenticated user is automatically associated with the event. ## Seats Plugin Manage seat-based licenses. ```typescript title="Server" import { commet, seats } from "@commet/better-auth"; commet({ client: commetClient, use: [seats()], }) ``` ```typescript title="Client" // List all seat balances const { data: seatBalances } = await authClient.seats.list(); // Add seats await authClient.seats.add({ featureCode: "member", count: 5 }); // Remove seats await authClient.seats.remove({ featureCode: "member", count: 2 }); // Set exact count await authClient.seats.set({ featureCode: "admin", count: 3 }); // Set all seat types at once await authClient.seats.setAll({ admin: 2, member: 10, viewer: 50 }); ``` ## Webhooks Plugin Handle Commet webhooks. This is optional since you can always query state directly. ```typescript title="Server" import { commet, webhooks } from "@commet/better-auth"; commet({ client: commetClient, use: [ webhooks({ secret: process.env.COMMET_WEBHOOK_SECRET, onPayload: (payload) => { // Catch-all handler }, onSubscriptionCreated: (payload) => {}, onSubscriptionActivated: (payload) => {}, onSubscriptionCanceled: (payload) => {}, onSubscriptionUpdated: (payload) => {}, }), ], }) ``` Configure the webhook endpoint in your Commet dashboard: `/api/auth/commet/webhooks` ## Full Example 1. ### Server Setup ```typescript title="auth.ts" import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { commet as commetPlugin, portal, subscriptions, features, usage, seats, } from "@commet/better-auth"; import { Commet } from "@commet/node"; import { db } from "./db"; import * as schema from "./schema"; const commetClient = new Commet({ apiKey: process.env.COMMET_API_KEY!, }); export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "pg", schema }), emailAndPassword: { enabled: true }, plugins: [ commetPlugin({ client: commetClient, createCustomerOnSignUp: true, getCustomerCreateParams: ({ user }) => ({ fullName: user.name, }), use: [ portal({ returnUrl: "/dashboard" }), subscriptions(), features(), usage(), seats(), ], }), ], }); ``` 2. ### Client Setup ```typescript title="auth-client.ts" import { createAuthClient } from "better-auth/react"; import { commetClient } from "@commet/better-auth"; export const authClient = createAuthClient({ baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL, plugins: [commetClient()], }); export const { signIn, signUp, signOut, useSession } = authClient; ``` 3. ### Usage in Components ```tsx title="dashboard.tsx" "use client"; import { authClient } from "@/lib/auth-client"; export function BillingSection() { const handlePortal = async () => { await authClient.customer.portal(); }; const checkFeature = async () => { const { data } = await authClient.features.canUse("api_calls"); if (data?.allowed) { // Proceed with action await authClient.usage.track({ feature: "api_calls" }); } }; return (
); } ``` ## Related - [Subscriptions](/docs/manage-subscriptions) - [Usage Events](/docs/track-usage) - [Seat Management](/docs/seat-management) - [Customer Portal](/docs/customer-portal) # Quickstart (/docs/choose-a-billing-model) Commet lets you charge through **subscriptions** and **one-time payments**. - A **subscription** connects a customer to a plan. The plan defines what they can use and what they pay; Commet manages checkout, invoices, and renewals. - A **one-time payment** charges a customer for a single purchase, without a plan or subscription. Follow [Accept One-Time Payments](/docs/accept-one-time-payments) if that is all you need. This quickstart walks you through your first paid subscription in sandbox. ## Plans, features, and consumption A **feature** is something your product provides, such as API calls, seats, or access to a capability. A **plan** groups features and sets their limits and prices. Each plan uses one consumption model to define how its measured usage consumes the included allowance and is charged: | Model | How it works | | ----------- | ----------------------------------------------------------------------------------------------------------------------------- | | **Metered** | Count usage per feature. Include a quantity in the plan and optionally charge for usage above it. | | **Credits** | Give the customer a pool of credits. Each use deducts the number of credits configured for that feature. | | **Balance** | Give the customer a monetary balance. Each use deducts its monetary cost; the plan can block when exhausted or allow overage. | A fixed monthly price can include features without extra usage charges. You do not need to charge for overage to use a plan. For this walkthrough, use a **Metered** plan with a monthly price and an included API-call allowance. See [Consumption Models](/docs/consumption-models) when you need to configure a different model. ## Your first subscription payment 1. [Create a Commet account](/login) and select your **sandbox organization**. Keep every resource in this walkthrough in that organization. Complete its [payment provider setup](/docs/payment-providers) so it can accept sandbox payments. 2. [Create a sandbox API key](/docs/create-api-key) and store it on your server. That guide covers the dashboard, CLI, MCP, and SDK initialization. 3. In **Features**, create **API Calls** with code `api_calls` and type **Usage**. The code identifies the feature when your application records consumption. You will set its included quantity on the plan. See [Configure Features](/docs/configure-features). 4. In **Plans**, create a plan named **Pro**, with code `pro`, and choose **Metered**. Add a **USD 20 monthly price**, include **1,000 API calls**, and leave overage disabled for this first example. Use a paid price without a trial so the next steps lead directly to payment. See [Manage Plans](/docs/create-plans). 5. Create a test customer with a billing email and a stable identifier from your application, such as `demo_customer`. The customer represents the person or business you are going to bill. See [Manage Customers](/docs/manage-customers) for the dashboard and SDK flow. 6. Connect that customer to the **Pro** plan using its monthly price. Creating the subscription for this paid plan returns a `checkoutUrl`. Open it or redirect the customer there to enter their billing details and pay. See [Manage Subscriptions](/docs/manage-subscriptions#create) for the request in your SDK. 7. Use the sandbox checkout's **Test Data** panel to fill in test payment details and complete checkout. Confirm that the invoice is paid and the subscription is active in the dashboard. Your first subscription payment is complete. Commet manages subsequent renewals for the monthly plan. See [Testing](/docs/testing-sandbox) for the available test tools. ## Connect it to your product With the first payment working, [check feature access](/docs/configure-features#current-feature-state) and [track usage](/docs/track-usage) when your customer uses the product. Use [signed webhooks](/docs/webhooks/introduction) to keep your application in sync with subscription and payment changes; a checkout redirect alone is not payment confirmation. If a coding agent is implementing the flow, connect it through [MCP](/docs/mcp-server) or install the [Commet Skill](/docs/commet-skill). Use the same sandbox organization and the feature, plan, and customer identifiers from this walkthrough. # CLI (/docs/cli) > The official command-line tool for Commet. Manage billing, forward webhooks, and automate your workflow from the terminal. ## Install ### pnpm ```bash pnpm add -g commet ``` ### npm ```bash npm install -g commet ``` ### yarn ```bash yarn global add commet ``` Requires Node.js 18+. ## Authentication **`commet login`** Authenticate via browser. Opens a device-code flow and stores your token at `~/.commet/auth.json`. ```bash commet login ``` In CI, set `COMMET_API_KEY` as an environment variable instead. The CLI uses two kinds of credentials, depending on the command: | Command type | Credential resolution | | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | Resource commands (`customers`, `plans`, `subscriptions`, `usage`, …) | `COMMET_API_KEY` env var → project API key (auto-generated by `commet link`, stored in `.commet/config.json`) → error | | Platform commands (`orgs`, `link`, `listen`, `create`) | `COMMET_API_KEY` env var → saved credentials from `commet login` | Resource commands never fall back to your `commet login` credentials — without an API key from the environment or a linked project, they fail and point you to `commet link`. ```bash commet logout # Remove stored credentials ``` ## Setup **`commet agents setup`** Prepare an existing repository for coding agents. The command preserves the rest of `AGENTS.md` and manages only its own delimited block, so it is safe to run again or remove later. ```bash commet agents setup commet agents setup --check --output agent ``` Use `--check --output agent` in automated workflows to verify that the managed instructions are current without rewriting the file. **`commet doctor`** Check the installed SDK, project configuration, agent instructions, and local environment before asking an agent to implement billing. Doctor is read-only: it does not contact Commet, modify files, or print secret values. ```bash commet doctor commet doctor --output agent ``` The agent output is compact and machine-readable enough to paste into an implementation task. For version-specific SDK guidance, start at `node_modules/@commet/node/docs/README.md`. **`commet link`** Link this project to a Commet organization. Re-run to switch organizations. ```bash commet link ``` ```bash commet link --org acme # By slug or ID commet link --clear # Unlink project ``` | Flag | Required | Description | | -------------------- | -------- | ---------------------------------------------------- | | `--org ` | No | Organization slug or ID. Skips interactive selection | | `--clear` | No | Unlink project from its organization | **`commet pull`** Fetch your billing config and generate `commet.config.ts` with typed features and plans. ```bash commet pull ``` ```bash commet pull --dry-run # Preview changes without writing commet pull --yes # Skip confirmation ``` | Flag | Required | Description | | ----------- | -------- | ------------------------------------------------ | | `--dry-run` | No | Show what would change without writing any files | | `-y, --yes` | No | Skip confirmation prompt | After `commet pull`, your SDK calls get autocomplete for plan codes and feature codes. **`commet push`** Push your local `commet.config.ts` to Commet. Creates or updates features and plans to match your config. ```bash commet push ``` ```bash commet push --dry-run # Preview what would change on remote commet push --yes # Push without confirmation ``` | Flag | Required | Description | | ----------- | -------- | -------------------------------------- | | `--dry-run` | No | Show what would change without pushing | | `-y, --yes` | No | Skip confirmation prompt | > **Warning** > > Feature type changes (`boolean` to `usage`, etc.) are blocked via push. Change them in the dashboard. **`commet create`** Scaffold a new Commet project from a template. Creates plans and features in your sandbox organization, generates an API key, and links the project. ```bash commet create ``` ```bash commet create myapp -t metered --org acme -y commet create --list # List available templates ``` | Flag | Required | Description | | ----------------------- | -------- | ------------------------------------------------------------------------------- | | `-t, --template ` | No | Template: `fixed`, `seats`, `metered`, `credits`, `balance-ai`, `balance-fixed` | | `--org ` | No | Sandbox organization slug or ID | | `--skills` | No | Install agent skills | | `--no-skills` | No | Skip agent skills installation | | `-y, --yes` | No | Accept defaults for optional prompts | | `--ref ` | No | Git ref to fetch templates from. Default: `main` | **`commet listen`** Forward webhook events from Commet to your local server. No ngrok or external tunneling needed. ```bash commet listen 3000 ``` The argument accepts a port, host:port, or full URL: ```bash commet listen 3000 # http://localhost:3000/ commet listen localhost:3000/webhooks # http://localhost:3000/webhooks commet listen http://local.commet.co:3010/api # http://local.commet.co:3010/api ``` Filter by event type: ```bash commet listen 3000 --events invoice.created,subscription.activated ``` | Flag | Required | Description | | ------------------ | -------- | ------------------------------------------------- | | `` | Yes | Target URL: port (`3000`), host:port, or full URL | | `--events ` | No | Comma-separated event types to forward | ``` ✓ Authenticated (org: Acme Inc) ✓ Connected to Commet webhook stream ⟶ Forwarding to http://localhost:3000/ ⟶ Signing secret: whsec_... Ready! Listening for webhook events... 14:32:01 customer.created → 200 OK (12ms) 14:32:05 subscription.updated → 200 OK (8ms) ``` The session stays open until `Ctrl+C`. Events are signed with the displayed secret. ```bash commet orgs # List all organizations (name, slug, mode) ``` ## Customers Create and manage customers, retrieve details, and handle batch operations. **`commet customers create`** Create a customer. ```bash commet customers create --email user@acme.com --id user_123 ``` ```bash commet customers create \ --email billing@corp.com \ --full-name "Acme Corp" \ --metadata '{"plan":"enterprise"}' ``` | Flag | Required | Description | | ------------------------ | -------- | ----------------------------------------------------- | | `--email ` | Yes | Billing email | | `--id ` | No | Custom customer ID | | `--external-id ` | No | ID from your system | | `--full-name ` | No | Full name | | `--tax-document ` | No | Tax document | | `--timezone ` | No | Timezone | | `--metadata ` | No | Metadata as JSON object | | `--address ` | No | Address as JSON: `{line1, city, postalCode, country}` | | `--address-id ` | No | Existing customer address ID | ```bash commet customers get --id cus_xxx # Get a customer by ID commet customers update --id cus_xxx --email new@acme.com # Update a customer commet customers list # List all customers commet customers list --limit 25 --cursor cur_xxx # Paginate commet customers list --external-id user_123 # Find by external ID commet customers create-batch --customers '[{"email":"a@co.com"},{"email":"b@co.com"}]' ``` ## Subscriptions Create, cancel, and manage subscriptions. Change plans, activate addons, and handle balance operations. **`commet subscriptions create`** Create a subscription for a customer. ```bash commet subscriptions create \ --customer-id user_123 \ --plan-code pro \ --billing-interval monthly ``` ```bash commet subscriptions create \ --customer-id user_123 \ --plan-id pln_xxx \ --price-id pp_argentina \ --billing-interval yearly \ --initial-seats '{"team_member":5}' \ --skip-trial true ``` | Flag | Required | Description | | ------------------------------- | ------------------------------ | --------------------------------------------------------------------------------- | | `--customer-id ` | Yes | Customer ID | | `--plan-code ` | One of `plan-code` / `plan-id` | Plan code | | `--plan-id ` | One of `plan-code` / `plan-id` | Plan ID | | `--billing-interval ` | No | `weekly`, `monthly`, `quarterly`, `yearly`, `one_time` | | `--price-id ` | No | Selectable plan price. Omit it to use the interval default and its market pricing | | `--initial-seats ` | No | Initial seats as JSON: `{featureCode: count}` | | `--skip-trial ` | No | Skip trial period | | `--custom-trial-days ` | No | Override the catalog trial duration | | `--offer-id ` | No | Apply an Offer directly. Cannot be combined with `promo-code` or a custom trial | | `--promo-code ` | No | Apply the Offer referenced by a customer-entered code | | `--provider ` | No | Initial checkout provider override: `stripe`, `commet`, or `dlocal` | | `--name ` | No | Subscription name | | `--start-date ` | No | Start date (ISO 8601) | | `--success-url ` | No | Redirect URL after successful checkout | **`commet subscriptions cancel`** Cancel a subscription. Cancels at period end by default. ```bash commet subscriptions cancel --id sub_xxx ``` ```bash commet subscriptions cancel --id sub_xxx --immediate true --reason "Customer requested" ``` | Flag | Required | Description | | -------------------- | -------- | ------------------------------------------- | | `--id ` | Yes | Subscription ID | | `--reason ` | No | Cancellation reason | | `--immediate ` | No | Cancel immediately instead of at period end | ```bash commet subscriptions get-active --customer-id user_123 # Get active subscription commet subscriptions uncancel --id sub_xxx # Revert pending cancellation commet subscriptions reactivate --id sub_xxx --offer-id ofr_xxx # Reactivate a canceled subscription with a direct Offer commet subscriptions change-plan --id sub_xxx --new-plan-id pln_xxx --new-billing-interval yearly # Change plan commet subscriptions list # List all subscriptions commet subscriptions list --customer-id user_123 --status active # Filter by customer/status commet subscriptions preview-change --id sub_xxx --plan-id pln_xxx --billing-interval yearly # Preview proration commet subscriptions activate-addon --id sub_xxx --addon-id adn_xxx commet subscriptions deactivate-addon --id sub_xxx --addon-id adn_xxx commet subscriptions adjust-balance --id sub_xxx --amount 1000 --type balance --reason "Courtesy credit" commet subscriptions topup-balance --id sub_xxx --amount 5000 commet subscriptions purchase-credits --id sub_xxx --credit-pack-id cpk_xxx ``` ## Plans Create plans, attach features and prices, and configure regional pricing. **`commet plans create`** Create a plan. ```bash commet plans create --name "Pro" --code pro ``` ```bash commet plans create \ --name "Developer" \ --code developer \ --consumption-model metered \ --is-public true \ --block-on-exhaustion true ``` | Flag | Required | Description | | ------------------------------ | -------- | ------------------------------------------ | | `--name ` | Yes | Plan name | | `--code ` | Yes | Plan code | | `--description ` | No | Plan description | | `--consumption-model ` | No | `metered`, `credits`, or `balance` | | `--is-public ` | No | Whether plan is publicly visible | | `--is-free ` | No | Whether plan is free | | `--block-on-exhaustion ` | No | Block usage when balance/credits exhausted | | `--plan-group-id ` | No | Plan group ID | | `--metadata ` | No | Metadata as JSON object | **`commet plans add-feature`** Add a feature to a plan with pricing and limits. ```bash commet plans add-feature \ --plan-id pln_xxx \ --feature-id ftr_xxx \ --included-amount 1000 \ --overage-enabled true \ --overage-unit-price 100 ``` ```bash commet plans add-feature \ --plan-id pln_xxx \ --feature-id ftr_xxx \ --pricing-mode ai_model \ --margin 30 ``` | Flag | Required | Description | | -------------------------- | -------- | -------------------------------------- | | `--plan-id ` | Yes | Plan ID | | `--feature-id ` | Yes | Feature ID | | `--enabled ` | No | Whether feature is enabled | | `--included-amount ` | No | Included usage amount | | `--unlimited ` | No | Unlimited usage | | `--overage-enabled ` | No | Enable overage billing | | `--overage-unit-price ` | No | Overage price per unit (fixed pricing) | | `--pricing-mode ` | No | `fixed` or `ai_model` | | `--margin ` | No | Margin percentage (ai\_model pricing) | | `--credits-per-unit ` | No | Credits per unit | **`commet plans add-price`** Add a price to a plan. ```bash commet plans add-price \ --plan-id pln_xxx \ --billing-interval monthly \ --price 2900 ``` ```bash commet plans add-price \ --plan-id pln_xxx \ --billing-interval yearly \ --price 29000 \ --trial-days 14 ``` | Flag | Required | Description | | ------------------------------- | ----------------------------------------- | ----------------------------------------------------------- | | `--plan-id ` | Yes | Plan ID | | `--billing-interval ` | Yes | `weekly`, `monthly`, `quarterly`, `yearly`, `one_time` | | `--price ` | One of `price` / `inherits-from-price-id` | Base price in the currency's minor unit | | `--inherits-from-price-id ` | One of `price` / `inherits-from-price-id` | Base price inherited by a selectable variant | | `--trial-days ` | No | Trial period in days | | `--is-default ` | No | Set as default price | | `--included-balance ` | No | Included balance amount | | `--included-credits ` | No | Included credits amount | | `--market-prices ` | Required for a variant | Market prices as `{marketGroupId, currency, price}` objects | | `--metadata ` | No | Price metadata | Create a selectable variant that changes only Argentina and inherits the rest of the base price: ```bash commet plans add-price \ --plan-id pln_xxx \ --billing-interval monthly \ --inherits-from-price-id pp_base \ --market-prices '[{"marketGroupId":"pmg_argentina","currency":"ars","price":1500000}]' ``` ```bash commet plans list # List all plans commet plans list --include-private true # Include private plans commet plans get --id pro # Get plan details commet plans update --id pln_xxx --name "Pro Plus" --description "For growing teams" --metadata '{}' --is-public true # Update a plan commet plans delete --id pln_xxx # Delete a plan commet plans set-visibility --id pln_xxx --is-public false commet plans update-feature --plan-id pln_xxx --feature-id ftr_xxx --included-amount 2000 commet plans remove-feature --plan-id pln_xxx --feature-id ftr_xxx commet plans update-price --plan-id pln_xxx --price-id pp_xxx --price 3900 commet plans delete-price --plan-id pln_xxx --price-id pp_xxx commet plans set-default-price --plan-id pln_xxx --price-id pp_xxx ``` ### Regional prices Set local currency overrides for a price. ```bash commet plans set-regional-prices \ --plan-id pln_xxx \ --price-id pp_xxx \ --overrides '[{"currency":"EUR","price":2700},{"currency":"BRL","price":14900}]' ``` ```bash commet plans delete-regional-prices --plan-id pln_xxx --price-id pp_xxx ``` ## Features Manage the organization's feature catalog. **`commet features create`** Create a feature. ```bash commet features create --code api_calls --name "API Calls" --type usage --unit-name calls ``` | Flag | Required | Description | | ---------------------- | -------- | --------------------------------------- | | `--code ` | Yes | Feature code | | `--name ` | Yes | Feature name | | `--type ` | Yes | `boolean`, `usage`, `seats`, or `quota` | | `--description ` | No | Feature description | | `--unit-name ` | No | Unit name for metered features | ```bash commet features list # List the feature catalog commet features get --code api_calls # Get a feature definition commet features update --code api_calls --name "API Requests" --description "HTTP API calls" --unit-name requests # Update a feature commet features delete --code api_calls # Delete a feature ``` ## Feature Access Check a customer's feature access and usage. ```bash commet feature-access list --customer-id user_123 # List feature access for a customer commet feature-access get --customer-id user_123 --code api_calls # Get feature access details ``` Use `commet usage check --customer-id user_123 --feature-code api_calls --quantity 1` for a prospective consumption decision. ## Seats Manage seat allocations for per-seat billing. **`commet seats add`** Add seats for a customer. ```bash commet seats add --customer-id user_123 --feature-code team_member ``` ```bash commet seats add --customer-id user_123 --feature-code team_member --count 5 ``` | Flag | Required | Description | | ----------------------- | -------- | ------------------------------------ | | `--customer-id ` | Yes | Customer ID | | `--feature-code ` | Yes | Feature code | | `--count ` | No | Number of seats to add. Default: `1` | ```bash commet seats remove --customer-id user_123 --feature-code team_member commet seats set --customer-id user_123 --feature-code team_member --count 10 commet seats set-all --customer-id user_123 --seats '{"team_member":5,"admin":2}' commet seats get-balance --customer-id user_123 --feature-code team_member commet seats get-all-balances --customer-id user_123 ``` ## Usage Track consumption events and check remaining allowance. **`commet usage track`** Track a usage event. Supports value-based tracking and AI model token tracking. ```bash commet usage track --feature-code api_calls --customer-id user_123 --value 1 ``` ```bash commet usage track \ --feature-code gpt-4o \ --customer-id user_123 \ --model gpt-4o \ --input-tokens 500 \ --output-tokens 150 \ --cache-read-tokens 200 ``` | Flag | Required | Description | | -------------------------- | ------------------------ | ------------------------------------------------------------------------------------- | | `--feature-code ` | Yes | Feature code | | `--customer-id ` | Yes | Customer ID | | `--value ` | One of `value` / `model` | Usage value (standard tracking) | | `--model ` | One of `value` / `model` | AI model name (token tracking) | | `--input-tokens ` | No | Input tokens (model tracking) | | `--output-tokens ` | No | Output tokens (model tracking) | | `--cache-read-tokens ` | No | Cache read tokens (model tracking) | | `--cache-write-tokens ` | No | Cache write tokens (model tracking) | | `--event-id ` | No | Caller-owned event ID for deduplication. Max 200 characters | | `--timestamp ` | No | Event timestamp (ISO 8601) | | `--properties ` | No | Event properties as a JSON array of `{ "property": string, "value": string }` entries | ```bash commet usage check --customer-id user_123 --feature-code api_calls --quantity 1 ``` ## Addons Create and manage purchasable feature extensions. **`commet addons create`** Create an addon. ```bash commet addons create \ --name "Extra Storage" \ --base-price 500 \ --feature-id ftr_xxx \ --consumption-model metered ``` ```bash commet addons create \ --name "Priority Support" \ --base-price 2900 \ --feature-id ftr_xxx \ --consumption-model boolean ``` | Flag | Required | Description | | ----------------------------- | -------- | --------------------------------------------- | | `--name ` | Yes | Addon name | | `--base-price ` | Yes | Base price in cents | | `--feature-id ` | Yes | Feature ID | | `--consumption-model ` | Yes | `boolean`, `metered`, `credits`, or `balance` | | `--description ` | No | Addon description | | `--included-units ` | No | Included units (metered) | | `--overage-rate ` | No | Overage rate (metered/balance) | | `--credit-cost ` | No | Credit cost (credits) | ```bash commet addons list # List all addons commet addons list --limit 25 --cursor cur_xxx # Paginate commet addons get --id adn_xxx # Get addon details commet addons update --id adn_xxx --name "Extra Storage Pro" --description "100GB extra" --base-price 900 --included-units 100 --overage-rate 10 # Update an addon commet addons delete --id adn_xxx # Delete an addon commet addons list-active --customer-id user_123 # List active addons for a customer ``` ## Credit Packs Create and manage credit packs for credit-based plans. ```bash commet credit-packs create --name "500 Credits" --credits 500 --price 4900 commet credit-packs list # List all credit packs commet credit-packs update --id cpk_xxx --price 3900 # Update a credit pack commet credit-packs delete --id cpk_xxx # Delete a credit pack ``` | Flag | Required | Description | | ---------------------- | ------------ | ----------------------------- | | `--name ` | Yes (create) | Credit pack name | | `--credits ` | Yes (create) | Number of credits | | `--price ` | Yes (create) | Price in cents | | `--description ` | No | Credit pack description | | `--is-active ` | No | Whether credit pack is active | ## Plan Groups Organize plans into groups for pricing tables and the customer portal. ```bash commet plan-groups create --name "Main Plans" --description "Primary pricing tiers" --is-public true commet plan-groups list # List all plan groups commet plan-groups list --limit 25 --cursor cur_xxx # Paginate commet plan-groups get --id plg_xxx # Get plan group details commet plan-groups add-plan --id plg_xxx --plan-id pln_xxx --sort-order 1 commet plan-groups remove-plan --id plg_xxx --plan-id pln_xxx commet plan-groups reorder-plans --id plg_xxx --plan-ids '["pln_a","pln_b","pln_c"]' commet plan-groups update --id plg_xxx --name "Updated Plans" --description "New description" --is-public false commet plan-groups delete --id plg_xxx # Delete a plan group ``` ## Invoices List invoices, download PDFs, and create adjustments. **`commet invoices create-adjustment`** Create an adjustment invoice. Use a negative amount for credits. ```bash commet invoices create-adjustment \ --customer-id cus_xxx \ --amount -500 \ --description "Courtesy credit" ``` | Flag | Required | Description | | ---------------------- | -------- | ------------------------------------ | | `--customer-id ` | Yes | Customer ID | | `--amount ` | Yes | Amount in cents. Negative for credit | | `--description ` | No | Adjustment description | | `--metadata ` | No | Metadata as JSON object | ```bash commet invoices list # List all invoices commet invoices list --limit 25 --cursor cur_xxx # Paginate commet invoices list --subscription-id sub_xxx # Filter by subscription commet invoices list --customer-id cus_xxx --status paid # Filter by customer/status commet invoices get --id inv_xxx # Get invoice details commet invoices get-download-url --id inv_xxx # Get signed PDF URL commet invoices send --id inv_xxx # Send invoice by email commet invoices update-status --id inv_xxx --status void # Update status (paid, void) ``` ## Payments Create hosted payment links and charge saved payment methods. One-time payments, no subscription or plan required. See [Accept One-Time Payments](/docs/accept-one-time-payments) for the full lifecycle. **`commet payments create`** Create a hosted payment link. The customer opens the returned `url` and pays with any card. ```bash commet payments create \ --amount 25000 \ --currency usd \ --description "Annual report" ``` ```bash commet payments create \ --amount 25000 \ --currency usd \ --description "Annual report" \ --customer-id user_123 \ --success-url https://yourapp.com/thanks ``` | Flag | Required | Description | | ----------------------- | -------- | ----------------------------------------------- | | `--amount ` | Yes | Amount in cents | | `--currency ` | Yes | Currency code (e.g. `usd`) | | `--description ` | Yes | Shown on the payment link, invoice, and receipt | | `--customer-id ` | No | Customer ID | | `--success-url ` | No | Redirect URL after successful payment | | `--metadata ` | No | Metadata as JSON object | ```bash commet payments charge --customer-id user_123 --amount 25000 --currency usd --description "Annual report" # Charge a vaulted payment method off-session commet payments get --id pay_xxx # Get payment details commet payments list --customer-id user_123 --limit 20 # List payments commet payments cancel --id pay_xxx # Cancel a pending payment link ``` ## Transactions List transactions, process refunds, and retry failed payments. ```bash commet transactions list # List all transactions commet transactions list --limit 20 --cursor cur_xxx # Paginate commet transactions list --status succeeded # Filter by status commet transactions list --customer-email user@acme.com # Filter by customer email commet transactions get --id txn_xxx # Get transaction details commet transactions refund --id txn_xxx # Full refund commet transactions retry --id txn_xxx # Retry failed payment ``` ## Offers Create reusable Offers independently from plans and distribution channels. ```bash commet offers create \ --name "14 days free, then 25% off" \ --phases '[{"type":"free_trial","durationDays":14},{"type":"percentage","percentage":2500,"durationCycles":3}]' ``` `percentage` uses basis points, so `2500` means 25%. Other phase types are `free_trial`, `amount_off`, and `fixed_price`. ```bash commet offers list --active true commet offers get --id ofr_xxx commet offers update --id ofr_xxx --name "Launch offer" --phases '[{"type":"fixed_price","prices":[{"currency":"usd","amount":900}],"durationCycles":2}]' commet offers delete --id ofr_xxx ``` An Offer does not contain `purpose` or plan-price associations. Attach a compatible Offer to a base price in the Dashboard for automatic introductory selection, pass its ID directly as `offerId`, or reference a single-discount Offer from a Promo Code. ## Promo Codes Create and manage customer-entered distribution codes for Offers. **`commet promo-codes create`** Create a promo code. ```bash commet promo-codes create \ --code SUMMER25 \ --offer-id ofr_summer25 \ --max-redemptions 100 ``` ```bash commet promo-codes create \ --code ANNUAL \ --offer-id ofr_annual \ --billing-interval yearly \ --plan-ids '["pln_pro","pln_team"]' \ --expires-at 2026-12-31 ``` | Flag | Required | Description | | ------------------------------- | -------- | ------------------------------------------------------------ | | `--code ` | Yes | Promo code string | | `--offer-id ` | Yes | Offer ID with exactly one `percentage` or `amount_off` phase | | `--billing-interval ` | No | Restrict redemption to one billing interval | | `--max-redemptions ` | No | Maximum number of redemptions | | `--expires-at ` | No | Expiration date (ISO 8601) | | `--plan-ids ` | No | Restrict to specific plan IDs (JSON array) | ```bash commet promo-codes list # List all promo codes commet promo-codes list --limit 25 --cursor cur_xxx # Paginate commet promo-codes get --id prc_xxx # Get promo code details commet promo-codes update --id prc_xxx --active false --max-redemptions 50 --expires-at 2026-12-31 --plan-ids '["pln_pro"]' # Update ``` ## Markets Define reusable country groups, then reference them from `--market-prices` when creating or updating plan prices. ```bash commet markets create \ --name "Argentina" \ --country-codes '["AR"]' commet markets list commet markets get --id pmg_argentina commet markets update --id pmg_argentina --name "Southern Cone" --country-codes '["AR","BO","PY","UY"]' commet markets delete --id pmg_argentina ``` ## Webhooks Create and test webhook endpoints. **`commet webhooks create`** Create a webhook endpoint. ```bash commet webhooks create \ --url https://example.com/webhooks \ --events '["subscription.activated","payment.received"]' ``` | Flag | Required | Description | | ---------------------- | -------- | ----------------------------------- | | `--url ` | Yes | Webhook URL | | `--events ` | Yes | Events to subscribe to (JSON array) | | `--description ` | No | Webhook description | > **Note** > > For local development, use [`commet listen`](#setup) instead of creating a webhook endpoint. ```bash commet webhooks list # List all webhook endpoints commet webhooks list --limit 25 --cursor cur_xxx # Paginate commet webhooks delete --id whe_xxx # Delete endpoint commet webhooks test --id whe_xxx # Send a test event ``` ## Provisioning Issue a fresh claim link for a headlessly provisioned organization that has not been claimed yet. The command uses that organization's API key; it cannot issue a link after a human has claimed the organization. ```bash commet provisioning create-claim-link \ --idempotency-key claim-acme-2026-08-16 ``` Use the same idempotency key when retrying the same request. Give the returned URL to the intended human owner; do not publish it or store it in source control. ## API Keys Create and manage API keys. ```bash commet api-keys create --name "Production" --expires-in-days 365 commet api-keys list # List all API keys commet api-keys list --limit 25 --cursor cur_xxx # Paginate commet api-keys delete --id key_xxx # Delete an API key ``` | Flag | Required | Description | | ----------------------- | ------------ | ------------------ | | `--name ` | Yes (create) | API key name | | `--expires-in-days ` | No | Expiration in days | ## Portal Generate a portal URL for customer self-service. ```bash commet portal get-url --customer-id user_123 commet portal get-url --email user@acme.com ``` Provide either `--customer-id` or `--email`. ## Payouts Manage payout bank accounts and request payouts. Payout commands require a live organization API key; sandbox keys return `payouts_not_available_in_sandbox`. **`commet payouts request`** Request a payout of available balance. ```bash commet payouts request --amount 50000 --description "Weekly payout" ``` | Flag | Required | Description | | ---------------------- | -------- | ----------------------------------- | | `--amount ` | Yes | Amount in cents (USD, minimum 1000) | | `--description ` | No | Payout description | **`commet payouts add-bank-account`** Add a destination bank account to the payout account. ```bash commet payouts add-bank-account \ --account-number 000123456789 \ --account-holder-name "Acme Inc." \ --routing-number 110000000 \ --account-type checking \ --set-default true ``` | Flag | Required | Description | | ------------------------------ | -------- | --------------------------- | | `--account-number ` | Yes | Bank account number | | `--account-holder-name ` | Yes | Account holder name | | `--routing-number ` | No | Routing number | | `--account-type ` | No | `checking` or `savings` | | `--set-default ` | No | Set as default bank account | ## Test Clock Control the sandbox test clock to simulate the passage of time. > **Warning** > > **Sandbox only.** Test clock commands fail against live organizations. **`commet test-clock advance`** Move the test clock forward and process every billing deadline due before the target time. ```bash commet test-clock advance --advance-days 30 ``` ```bash commet test-clock advance --frozen-time 2026-07-01T00:00:00Z ``` | Flag | Required | Description | | -------------------- | ------------------------------------- | -------------------------------------- | | `--advance-days ` | One of `advance-days` / `frozen-time` | Days to move the clock forward | | `--frozen-time ` | One of `advance-days` / `frozen-time` | Absolute instant to move to (ISO 8601) | ```bash commet test-clock get # Get the current clock and latest run state ``` The former `commet test-clock process-billing` command is deprecated. `advance` now starts billing processing automatically. ## Quota Manage quota allowances for quota-based features. **`commet quota add`** Add to a customer's quota allowance for a feature. ```bash commet quota add --feature-code api_calls --customer-id user_123 --count 100 ``` | Flag | Required | Description | | ------------------------- | ------------------------------------ | ---------------------------------------------- | | `--feature-code ` | Yes | Feature code | | `--customer-id ` | One of `customer-id` / `external-id` | Customer ID | | `--external-id ` | One of `customer-id` / `external-id` | Customer external ID | | `--count ` | No | Amount to add. Default: `1` | | `--idempotency-key ` | No | HTTP idempotency key for request deduplication | ```bash commet quota set --feature-code api_calls --customer-id user_123 --count 500 # Set exact allowance commet quota remove --feature-code api_calls --customer-id user_123 --count 10 # Remove from allowance commet quota get --customer-id user_123 --feature-code api_calls # Get allowance for a feature commet quota get-all --customer-id user_123 # Get all allowances ``` ## Global Options These flags work on every command: | Flag | Description | | ------------------- | ----------------------------------- | | `--output ` | `human` (default) or `agent` (JSON) | | `--help` | Show help for any command | | `--version` | Print CLI version | Use `--output agent` for structured JSON output in scripts and CI pipelines: ```bash commet customers list --output agent | jq '.[0].id' ``` ## CI/CD Set `COMMET_API_KEY` as an environment variable. No `commet login` needed. ```yaml .github/workflows/billing.yml env: COMMET_API_KEY: ${{ secrets.COMMET_API_KEY }} steps: - run: npx commet push --yes --output agent ``` ```yaml .github/workflows/subscription.yml env: COMMET_API_KEY: ${{ secrets.COMMET_API_KEY }} steps: - run: | npx commet subscriptions create \ --customer-id user_123 \ --plan-code pro \ --billing-interval monthly \ --output agent ``` ## Configuration Files | File | Created by | Purpose | | --------------------- | -------------- | ------------------------------------------------------- | | `~/.commet/auth.json` | `commet login` | Global auth credentials | | `.commet/config.json` | `commet link` | Project-to-organization link and auto-generated API key | | `commet.config.ts` | `commet pull` | Generated billing config with types | > **Note** > > Run `commet --help` for the full list of flags and options on any command. # Create an API Key (/docs/create-api-key) An API key gives your server access to one Commet organization. Start with a **sandbox organization** while building your integration. Sandbox and live keys both start with `ck_`. The organization that created the key determines its environment; you cannot tell sandbox from live by reading the prefix. Both use `https://commet.co/api/v1`. ## Create a key Choose the path that fits where you are working. You only need one to get started. ### Dashboard 1. Select your **sandbox organization** in the dashboard. 2. Go to **Settings → API Keys** and click **Create API Key**. 3. Enter a descriptive name, such as `Local development`. 4. Choose **Expires In (Days)**. The dashboard accepts **1–365 days** and defaults to **365**. 5. Create the key and copy it before closing the dialog. The full secret is shown only once. ### CLI Install the [Commet CLI](/docs/cli), sign in through your browser, and link the project to your sandbox organization: ```bash npm install -g commet commet login commet link ``` Choose the organization marked **sandbox**. Linking a new organization generates a key for CLI resource commands and saves it in `.commet/config.json`. The CLI adds `.commet/` to `.gitignore`. Keep that directory private. This configures the CLI; it does **not** set `COMMET_API_KEY` for your application. To create a separate application key after linking: ```bash commet api-keys create --name "Local development" --expires-in-days 365 ``` Save the returned `apiKey` as described below. Resource commands use `COMMET_API_KEY` from the environment before the linked project's key, so check which credential is active before creating or deleting keys. ### MCP Connect your agent to the [Commet MCP server](/docs/mcp-server) at `https://commet.co/mcp/v2`. With OAuth, you can sign in through your browser and select a **sandbox organization** without an existing API key. The connection stays fixed to that organization. Ask the agent to create an application key using `api_create_api_key` with these arguments: ```json { "body": { "name": "Local development", "expiresInDays": 365 } } ``` The response contains the full secret only once. Have the agent store it directly in the intended local secret file or secret manager when your tools support that. Do not paste an existing secret into the conversation or ask the agent to repeat it in a message. Already authenticating with an API key? You can create replacements with `POST /api/v1/api-keys`. That request needs an existing key and creates another key for the same organization. See [Create API key](/docs/api-reference/api-keys/create-api-key) for the request and response. ## Store the key on your server For local development, save the key in a git-ignored environment file: ```bash title=".env.local" COMMET_API_KEY=ck_replace_with_your_key ``` Load this variable into your server process using your framework's environment support. For production, use your deployment's secret store. Never expose the key in browser code, public environment variables, logs, screenshots, or source control. Commet stores a hash of the key and cannot show the full secret again. If you lose it, create a replacement. ## Use the key in your SDK For Node.js, install the SDK: ```bash npm install @commet/node ``` Initialize it in server code after loading the environment: ```typescript import { Commet } from "@commet/node" const apiKey = process.env.COMMET_API_KEY if (!apiKey) { throw new Error("COMMET_API_KEY is required") } export const commet = new Commet({ apiKey }) ``` For other languages, follow the [Python](/docs/integrate-with-python), [Go](/docs/integrate-with-go), [Java](/docs/integrate-with-java), or [PHP](/docs/integrate-with-php) integration guide. Direct REST requests authenticate with the `x-api-key` header. ## Rotate and promote to live Use a separate key for each application or deployment that needs independent rotation. To replace a key before it expires: 1. Create a replacement in the **same organization**. 2. Update the secret in every process using the old key and deploy or restart those processes. 3. Verify a successful read with the replacement and confirm the application is using it. 4. Delete the old key from **Settings → API Keys** or through [Delete API key](/docs/api-reference/api-keys/delete-api-key). For production, create a key in your **live organization** and store it separately from sandbox credentials. Do not copy sandbox customer, plan, or subscription IDs into live configuration. Before switching, verify checkout, webhooks, and a renewal in sandbox with the [Test Clock](/docs/testing-sandbox). Next, follow the [quickstart](/docs/choose-a-billing-model) to complete your first subscription payment in sandbox. # Error Handling (/docs/error-handling) ## Error Classes ### TypeScript ```typescript import { CommetAPIError, CommetValidationError } from '@commet/node' try { await commet.customers.create({ email: 'invalid' }) } catch (error) { if (error instanceof CommetValidationError) { console.log(error.validationErrors) // { email: ['Invalid email format'] } } if (error instanceof CommetAPIError) { console.log(error.statusCode, error.message) } } ``` ### Python ```python from commet import CommetAPIError, CommetValidationError try: commet.customers.create(email='invalid') except CommetValidationError as e: print(e.validation_errors) # { 'email': ['Invalid email format'] } except CommetAPIError as e: print(e.status_code, e) ``` ### Go ```go import ( "errors" "github.com/commet-labs/commet-go/v9" ) _, err := client.Customers.Create(ctx, &commet.CreateCustomerParams{Email: "invalid"}) if err != nil { var validationErr *commet.ValidationError if errors.As(err, &validationErr) { fmt.Println(validationErr.ValidationErrors) // map[email:[Invalid email format]] } var apiErr *commet.CommetError if errors.As(err, &apiErr) { fmt.Println(apiErr.StatusCode, apiErr.Message) } } ``` ### Java ```java import co.commet.CommetApiException; import co.commet.CommetValidationException; import co.commet.params.CreateCustomerParams; try { commet.customers().create(CreateCustomerParams.builder("invalid").build()); } catch (CommetValidationException e) { System.out.println(e.getValidationErrors()); // { email: [Invalid email format] } } catch (CommetApiException e) { System.out.println(e.getStatusCode() + " " + e.getMessage()); } ``` ### PHP ```php use Commet\Exceptions\ApiException; use Commet\Exceptions\ValidationException; try { $commet->customers->create(email: 'invalid'); } catch (ValidationException $e) { print_r($e->validationErrors); // [ 'email' => ['Invalid email format'] ] } catch (ApiException $e) { echo $e->getStatusCode() . ' ' . $e->getMessage(); } ``` | Class | Use case | | ----------------------------------------------------------------------------- | -------------------------------- | | `CommetAPIError` / `CommetApiException` / `ApiException` | HTTP errors (4xx, 5xx) | | `CommetValidationError` / `CommetValidationException` / `ValidationException` | Invalid input with field details | | `CommetError` / `CommetException` | Base class for all errors | Go exposes `*commet.CommetError` and `*commet.ValidationError` as concrete types — check them with `errors.As`. ## Error Code Reference Every current API error includes a stable `code` and a version-matched `doc_url` such as [`customer_not_found`](/docs/api-reference/2026-07-31/errors/customer_not_found.md). The URL returns a dedicated English Markdown reference with handling and retry guidance for the resolved API version. Keep the `x-request-id` response header when logging or reporting an error. Commet records the same identifier in Platform, allowing the request to be correlated without including credentials or customer data. ## Automatic Retries Failed requests retry with exponential backoff (1s → 2s → 4s, max 8s). **Retryable:** 408, 429, 500, 502, 503, 504 Rate limits are the exception to the backoff: a 429 is retried only when the response carries a `Retry-After` header, and the client waits exactly that value (capped at 30s) instead of backing off. A 429 without `Retry-After` is not retried. ### TypeScript ```typescript const commet = new Commet({ apiKey: process.env.COMMET_API_KEY!, retries: 3, // default }) ``` ### Python ```python commet = Commet( api_key=os.environ['COMMET_API_KEY'], retries=3, # default ) ``` ### Go ```go client, err := commet.New( os.Getenv("COMMET_API_KEY"), commet.WithRetries(3), // default ) ``` ### Java ```java Commet commet = Commet.builder() .apiKey(System.getenv("COMMET_API_KEY")) .retries(3) // default .build(); ``` ### PHP ```php $commet = new Commet( apiKey: getenv('COMMET_API_KEY'), retries: 3, // default ); ``` ## Non-Blocking Usage Don't let tracking errors break your app: ### TypeScript ```typescript commet.usage.track({ customerId: 'user_123', featureCode: 'api_calls', }).catch(console.error) // Continue without waiting ``` ### Python ```python try: commet.usage.track(customer_id='user_123', feature_code='api_calls') except Exception as e: logger.error(e) # Continue without waiting ``` ### Go ```go go func() { _, err := client.Usage.Track(context.Background(), &commet.TrackUsageParams{ CustomerID: "user_123", FeatureCode: "api_calls", }) if err != nil { log.Printf("track failed: %v", err) } }() // Continue without waiting ``` ### Java ```java CompletableFuture.runAsync(() -> { try { commet.usage().track( TrackUsageParams.builder("api_calls", "user_123") .customerId("user_123") .build() ); } catch (Exception e) { log.error("track failed", e); } }); // Continue without waiting ``` ### PHP ```php try { $commet->usage->track( customerId: 'user_123', featureCode: 'api_calls', ); } catch (\Throwable $e) { error_log($e->getMessage()); } // Continue without waiting ``` # Examples (/docs/examples) The examples are runnable applications, not isolated API snippets. Each one shows how product state, Commet configuration, server actions, and customer access work together. ## Billing models | Example | What it demonstrates | | --------------------------------------------------------------------------------------- | ---------------------------------------------------------- | | [Fixed subscription](https://github.com/commet-labs/commet/tree/main/examples/fixed) | A recurring plan, checkout, and subscription-backed access | | [Metered usage](https://github.com/commet-labs/commet/tree/main/examples/metered) | Included usage, event tracking, and overage | | [Credits](https://github.com/commet-labs/commet/tree/main/examples/credits) | Recurring credits and credit-aware product actions | | [Fixed balance](https://github.com/commet-labs/commet/tree/main/examples/balance-fixed) | Prepaid monetary balance and top-ups | | [AI balance](https://github.com/commet-labs/commet/tree/main/examples/balance-ai) | AI token costs charged against a balance | | [Seats](https://github.com/commet-labs/commet/tree/main/examples/seats) | Team membership synchronized with seat quantities | | [Quota](https://github.com/commet-labs/commet/tree/main/examples/quota) | Capacity checks and quota mutations | ## Lifecycle The [webhooks example](https://github.com/commet-labs/commet/tree/main/examples/webhooks) shows subscription activation, past-due recovery, cancellation, signed event handling, and UI derived from confirmed billing state. ## Run an example Clone the repository, open one example directory, copy `.env.example`, and use a sandbox API key. Keep the example's database and Commet organization disposable; Test Clock operations and fixture cards are sandbox-only. ```bash git clone https://github.com/commet-labs/commet.git cd commet/examples/metered cp .env.example .env pnpm install pnpm dev ``` Read the example together with the relevant conceptual guide. The source shows one implementation; [How Billing Works](/docs/how-does-billing-work) defines the rules that remain true across frameworks. # Introduction (/docs) Commet is a billing and payments platform for SaaS and AI products. It connects what your customers use with what they pay: subscriptions, usage charges, credits, and one-time payments. You define what you sell and how you price it. Commet manages the billing cycle, invoices, and payment flows. Your application reports consumption and uses Commet's access checks and events to decide what each customer can do. - [**Quickstart**](/docs/choose-a-billing-model) - [**Build with an agent**](/docs/mcp-server) ## What can you build with Commet? ### Subscriptions that match your product Sell a monthly or annual plan with a fixed price, usage charges, or a combination of both. Define [features](/docs/configure-features) such as API calls, team seats, storage, or access to a capability, then package them into [plans](/docs/create-plans). A [subscription](/docs/manage-subscriptions) connects a customer to a plan. Commet manages its billing cycle and invoices as the subscription renews or changes. You can offer [trials](/docs/trial-periods), handle [upgrades and downgrades](/docs/upgrade-and-downgrade-plans), and configure [failed-payment recovery](/docs/handle-failed-payments). ### Pricing based on consumption An AI request, an API call, and a stored file represent different kinds of usage. Commet lets you define how that usage affects the customer's bill or available allowance. Each plan has one [consumption model](/docs/consumption-models): | Model | How it treats usage | | ----------- | ------------------------------------------------------------------------------------------------------------------ | | **Metered** | Measures usage against each feature's included allowance, with overage charges when configured. | | **Credits** | Deducts credits from a shared pool as customers use features. | | **Balance** | Converts usage into monetary amounts drawn from the customer's balance, with overage behavior defined by the plan. | Your application [reports usage](/docs/track-usage) to Commet. For AI products, [token billing](/docs/ai-token-billing) uses the model and token counts you report to calculate consumption with your configured margin. You can also manage [team seats](/docs/seat-management) and [quantities such as stored resources](/docs/quota-management). ### One-time purchases and optional extras [Accept a one-time payment](/docs/accept-one-time-payments) without creating a plan or subscription. This is useful for a standalone purchase or service alongside your recurring product. For subscribers, offer [add-ons](/docs/add-ons) to extend a plan, [credit packs](/docs/credit-packs) for more consumption, or [balance top-ups](/docs/balance-and-top-ups). These give customers a way to buy more without changing their entire subscription. ### A billing experience customers can manage themselves Use checkout to collect payment and the [customer portal](/docs/customer-portal) to let customers view invoices, update payment methods, and review usage. Configure [plan groups](/docs/plan-groups) to offer self-service plan changes. You can also create reusable [offers](/docs/offers), distribute them through [promo codes](/docs/promo-codes), and configure [regional prices](/docs/regional-prices) for different markets and currencies. ## Choose how you accept payments Use Commet's [Merchant of Record](/docs/merchant-of-record) offering, or connect your own [Stripe or dLocal account](/docs/payment-providers). For payments processed through its Merchant of Record offering, Commet handles tax collection and remittance, compliance, refunds, and disputes on your behalf. With your own Stripe or dLocal account, you remain the merchant and manage funds and payouts with that provider. Commet provides the billing layer in either setup. The guides cover [payment routing](/docs/payment-orchestration), [transactions and refunds](/docs/transactions-refunds-and-retries), and [finance and payouts](/docs/finance-overview). Before using Commet as Merchant of Record, review [supported countries](/docs/supported-countries) and [verification requirements](/docs/payouts-verification). ## Integrate the way you work Start in the dashboard to configure your product, then connect your application with an [API key](/docs/create-api-key). Choose a guide for your stack: | Stack | Integration guides | | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **JavaScript and TypeScript** | [Next.js](/docs/integrate-with-nextjs), [Nuxt](/docs/integrate-with-nuxt), [SvelteKit](/docs/integrate-with-sveltekit), [Express](/docs/integrate-with-express), or the [Node.js SDK reference](/docs/sdk-reference). | | **Python** | [Python](/docs/integrate-with-python), [FastAPI](/docs/integrate-with-fastapi), [Django](/docs/integrate-with-django), and [Flask](/docs/integrate-with-flask). | | **Go, Java, and PHP** | [Go](/docs/integrate-with-go), [Java](/docs/integrate-with-java), [PHP](/docs/integrate-with-php), and [Laravel](/docs/integrate-with-laravel). | Using Better Auth? The [Commet plugin](/docs/better-auth) connects customer creation and billing to your authentication flow. For complete applications you can adapt, explore the [examples](/docs/examples). ### Tools for developers and agents The [CLI](/docs/cli) lets you manage Commet from your terminal and work with billing configuration as code. The [REST API reference](/docs/api-reference) documents the endpoints available to your own tools and integrations. For an AI coding assistant, install the [Commet skill](/docs/commet-skill) for integration guidance. Connect the [MCP server](/docs/mcp-server) when the assistant also needs to read documentation and work with resources in your organization. The skill provides guidance; MCP provides access to tools. ## Connect billing to your application Build your first flow in [sandbox](/docs/testing-sandbox), where you can exercise checkout and billing without real charges. Use [access and usage checks](/docs/track-usage) in your application to enforce the plan you sell. Connect [signed webhooks](/docs/webhooks/introduction) to keep your application in sync with subscription and payment outcomes. Review [error handling](/docs/error-handling) before moving the integration to production. Ready to start? Follow the [quickstart](/docs/choose-a-billing-model) to create a feature, plan, customer, subscription, and test payment in order. # SDK Reference (/docs/sdk-reference) ## Setup ```typescript import { Commet } from '@commet/node' const commet = new Commet({ apiKey: process.env.COMMET_API_KEY!, }) ``` Get your API key from the dashboard under **Settings → API Keys**. The installed package includes version-matched agent documentation at `node_modules/@commet/node/docs/README.md`. Run `commet doctor --output agent` to check the local SDK and configuration without changing files or contacting Commet. ## Options | Option | Type | Default | Description | | ------------ | ------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `apiKey` | string | required | Your API key (starts with `ck_`). The org that owns the key decides whether calls run against sandbox or live data. | | `apiVersion` | string | version the SDK release was built against | API version pin, sent as the `Commet-Version` header on every request. See [API versioning](/docs/api-versioning). | | `debug` | boolean | `false` | Log requests/responses | | `timeout` | number | `30000` | Request timeout (ms) | | `retries` | number | `3` | Max retry attempts | | `telemetry` | boolean | `true` | Send anonymous client info (SDK version, runtime, platform) with requests. Set to `false` to disable. | ## Sandbox vs live There is a single API host, `commet.co`. Use an API key created in a sandbox organization while integrating; swap it for a key from a live organization when you go to production. No code change beyond the environment variable. ## Debug Mode See all requests and responses: ```typescript const commet = new Commet({ apiKey: process.env.COMMET_API_KEY!, debug: true, }) // [Commet SDK] POST https://commet.co/api/v1/customers // [Commet SDK] Response status: 200 OK ``` ## Pagination List endpoints use cursor-based pagination. ```typescript const page = await commet.customers.list({ limit: 25 }) if (page.hasMore) { const nextPage = await commet.customers.list({ limit: 25, cursor: page.nextCursor, }) } ``` | Parameter | Type | Default | Description | | --------- | ------ | ------- | ----------------------------- | | `limit` | number | 25 | Items per page (max 100) | | `cursor` | string | - | Cursor from previous response | # Testing (/docs/testing-sandbox) Sandbox is the default environment — completely isolated from production. ## Test Flow ```typescript // 1. Create customer const customerId = `test_${Date.now()}` const customer = await commet.customers.create({ email: 'test@example.com', id: customerId, }) // 2. Create subscription const subscription = await commet.subscriptions.create({ customerId, planCode: 'pro', }) // 3. Pay with test card at checkoutUrl // 4. Track usage await commet.usage.track({ customerId, featureCode: 'api_calls', }) ``` ## Dev Tools Panel A floating widget that appears on all sandbox pages. The [**Test Clock**](#test-clock) tab is available everywhere. The [**Test Data**](#test-data) tab (address and card presets) only appears on checkout pages. ### Test Clock Simulate future dates to test billing cycles, renewals, and prorations without waiting for real time to pass. Advancing the clock from the dashboard processes every due billing deadline before the clock reaches the selected time. **Current Time** — displays the simulated time in UTC, or the real time if no simulation is active. **Set Time** — pick a future date from a calendar. Before the run starts, the panel shows the target time and the number of currently scheduled deadlines it expects to process. Time can only move forward and cannot be reverted. **Quick Advance** — jump forward by a preset amount: | Button | Days | | ---------- | ---- | | + 1 Day | 1 | | + 1 Week | 7 | | + 2 Weeks | 14 | | + 1 Month | 30 | | + 3 Months | 90 | **Run progress** — after confirmation, the panel processes due renewals, trial endings, scheduled changes, cancellations, entitlement resets, and dunning retries in chronological order. Progress and per-subscription outcomes remain visible if you reload or reopen the panel. **Example workflow:** choose **+ 1 Month**, review the estimated impact, then confirm **Advance and Process**. The clock reaches the target after the run finishes. #### Test Clock with the SDK The API starts the same durable run as the dashboard. Advancing the clock returns `202 Accepted` with the new run; poll the clock state to follow its progress and terminal per-deadline outcomes. These methods are sandbox only. **Read the current state** ### TypeScript ```typescript const clock = await commet.testClock.get() ``` ### Python ```python clock = commet.test_clock.get() ``` ### Go ```go clock, err := client.TestClock.Get(ctx) ``` ### Java ```java var clock = commet.testClock().get(); ``` ### PHP ```php $clock = $commet->testClock->get(); ``` ### cURL ```bash curl "https://commet.co/api/v1/test-clock" \ -H "x-api-key: $COMMET_API_KEY" ``` **Advance the clock and process billing** Move the clock forward and process every billing deadline due before the target time. The clock can only move forward. ### TypeScript ```typescript await commet.testClock.advance({ advanceDays: 30 }) ``` ### Python ```python commet.test_clock.advance(advance_days=30) ``` ### Go ```go days := 30 _, err := client.TestClock.Advance(ctx, &commet.AdvanceTestClockParams{ AdvanceDays: &days, }) ``` ### Java ```java commet.testClock().advance(AdvanceTestClockParams.builder().advanceDays(30L).build()); ``` ### PHP ```php $commet->testClock->advance(advanceDays: 30); ``` ### cURL ```bash curl -X POST https://commet.co/api/v1/test-clock \ -H "x-api-key: $COMMET_API_KEY" \ -H "Content-Type: application/json" \ -d '{"advanceDays": 30}' ``` The former `POST /test-clock/process-billing` operation is deprecated and returns `410 Gone`. Remove separate `processBilling()` calls; advancing the clock now performs that work automatically. ### Test Data The Test Data tab automates checkout form filling with valid, country-specific test data. Open it from the Dev Tools panel on any sandbox checkout page. **Address autofill** — select a country and the checkout form is automatically filled with a valid name, email, street, city, state, and postal code for that country. No need to type anything manually. **Country-specific test cards** — after selecting a country, the panel shows the test cards that match that country. Click one and the card fields are filled for you. Expiry and CVC are always `12/34` and `123`. **Failure scenarios** — the panel also offers generic cards to test error handling: declined payment, insufficient funds, and expired card. For 3D Secure authentication, use the cards in the table below. #### Test cards by country | Country | Card | Brand | | ------------------------- | --------------------- | ------------ | | United States (US) | `4242 4242 4242 4242` | Visa | | Argentina (AR) | `4000 0032 0000 0021` | Visa | | Australia (AU) | `4000 0003 6000 0006` | Visa | | Austria (AT) | `4000 0004 0000 0008` | Visa | | Belarus (BY) | `4000 0011 2000 0005` | Visa | | Belgium (BE) | `4000 0005 6000 0004` | Visa | | Brazil (BR) | `4000 0076 0000 0002` | Visa | | Bulgaria (BG) | `4000 0010 0000 0000` | Visa | | Canada (CA) | `4000 0012 4000 0000` | Visa | | Chile (CL) | `4000 0015 2000 0001` | Visa | | China (CN) | `4000 0015 6000 0002` | Visa | | Colombia (CO) | `4000 0017 0000 0003` | Visa | | Costa Rica (CR) | `4000 0018 8000 0005` | Visa | | Croatia (HR) | `4000 0019 1000 0009` | Visa | | Cyprus (CY) | `4000 0019 6000 0008` | Visa | | Czechia (CZ) | `4000 0020 3000 0002` | Visa | | Denmark (DK) | `4000 0020 8000 0001` | Visa | | Ecuador (EC) | `4000 0021 8000 0000` | Visa | | Estonia (EE) | `4000 0023 3000 0009` | Visa | | Finland (FI) | `4000 0024 6000 0001` | Visa | | France (FR) | `4000 0025 0000 0003` | Visa | | Germany (DE) | `4000 0027 6000 0016` | Visa | | Gibraltar (GI) | `4000 0029 2000 0005` | Visa | | Greece (GR) | `4000 0030 0000 0030` | Visa | | Hong Kong (HK) | `4000 0034 4000 0004` | Visa | | Hungary (HU) | `4000 0034 8000 0005` | Visa | | India (IN) | `4000 0035 6000 0008` | Visa | | Ireland (IE) | `4000 0037 2000 0005` | Visa | | Italy (IT) | `4000 0038 0000 0008` | Visa | | Japan (JP) | `4000 0039 2000 0003` | Visa | | Japan (JP) | `3530 1113 3330 0000` | JCB | | Latvia (LV) | `4000 0042 8000 0005` | Visa | | Liechtenstein (LI) | `4000 0043 8000 0004` | Visa | | Lithuania (LT) | `4000 0044 0000 0000` | Visa | | Luxembourg (LU) | `4000 0044 2000 0006` | Visa | | Malaysia (MY) | `4000 0045 8000 0002` | Visa | | Malta (MT) | `4000 0047 0000 0007` | Visa | | Mexico (MX) | `4000 0048 4000 8001` | Visa | | Mexico (MX) | `5062 2100 0000 0009` | Carnet | | Netherlands (NL) | `4000 0052 8000 0002` | Visa | | New Zealand (NZ) | `4000 0055 4000 0008` | Visa | | Norway (NO) | `4000 0057 8000 0007` | Visa | | Panama (PA) | `4000 0059 1000 0000` | Visa | | Paraguay (PY) | `4000 0060 0000 0066` | Visa | | Peru (PE) | `4000 0060 4000 0068` | Visa | | Poland (PL) | `4000 0061 6000 0005` | Visa | | Portugal (PT) | `4000 0062 0000 0007` | Visa | | Romania (RO) | `4000 0064 2000 0001` | Visa | | Saudi Arabia (SA) | `4000 0068 2000 0007` | Visa | | Singapore (SG) | `4000 0070 2000 0003` | Visa | | Slovakia (SK) | `4000 0070 3000 0001` | Visa | | Slovenia (SI) | `4000 0070 5000 0006` | Visa | | Spain (ES) | `4000 0072 4000 0007` | Visa | | Sweden (SE) | `4000 0075 2000 0008` | Visa | | Switzerland (CH) | `4000 0075 6000 0009` | Visa | | Taiwan (TW) | `4000 0015 8000 0008` | Visa | | Thailand (TH) | `4000 0076 4000 0003` | Visa | | Thailand (TH) | `4000 0576 4000 0008` | Visa (debit) | | United Arab Emirates (AE) | `4000 0078 4000 0001` | Visa | | United Arab Emirates (AE) | `5200 0078 4000 0022` | Mastercard | | United Kingdom (GB) | `4000 0082 6000 0000` | Visa | | United Kingdom (GB) | `4000 0582 6000 0005` | Visa (debit) | | United Kingdom (GB) | `5555 5582 6555 4449` | Mastercard | | Uruguay (UY) | `4000 0085 8000 0003` | Visa | #### Failure scenarios | Card | Scenario | | --------------------- | ------------------ | | `4000 0000 0000 9995` | Insufficient funds | | `4000 0000 0000 0002` | Card declined | | `4000 0000 0000 0069` | Expired card | #### 3D Secure | Card | Description | | --------------------- | ----------------------- | | `4000 0000 0000 3220` | Requires authentication | | `4000 0025 0000 3155` | Requires authentication | ## Production ```typescript const commet = new Commet({ apiKey: process.env.COMMET_PRODUCTION_KEY!, }) ``` # addon.activated (/docs/webhooks/addon-activated) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `addon` (WebhookAddonRef) — The add-on: id and name. - `featureCode` (string) — The feature the add-on unlocks or extends. - `proratedPrice` (number) — The prorated amount charged at activation in rate scale (10000 = $1.00). Zero when nothing was charged. - `currency` (string) — The subscription currency. ```json { "event": "addon.activated", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "addon": { "id": "adn_5e6f7g8h", "name": "Extra Storage" }, "featureCode": "storage", "proratedPrice": 25000, "currency": "usd" } } ``` ## When this fires When an add-on activation completes — via `POST /subscriptions/{id}/addons` or a customer portal purchase. Any prorated activation charge has already succeeded; `proratedPrice` is the amount charged (zero when the remaining period was free). `customer.state_changed` fires alongside it with trigger `addon_activated`, reflecting the feature the add-on unlocked. Use it to enable the add-on's feature in your app the moment it is paid. # addon.deactivated (/docs/webhooks/addon-deactivated) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `addon` (WebhookAddonRef) — The add-on: id and name. - `featureCode` (string) — The feature the add-on unlocked or extended. ```json { "event": "addon.deactivated", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "addon": { "id": "adn_5e6f7g8h", "name": "Extra Storage" }, "featureCode": "storage" } } ``` ## When this fires When an active add-on is deactivated — via `DELETE /subscriptions/{id}/addons/{addonId}` or the customer portal. The add-on stops billing at the next renewal. `customer.state_changed` fires alongside it with trigger `addon_deactivated`. Use it to revoke the add-on's feature in your app. # balance.depleted (/docs/webhooks/balance-depleted) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `currentBalance` (number) — The balance after depletion in rate scale. Zero, or negative when overage is allowed. - `currency` (string) — The subscription currency. ```json { "event": "balance.depleted", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "currentBalance": 0, "currency": "usd" } } ``` ## When this fires The async usage processor fires this once when a deduction moves the prepaid balance from positive to zero or below. On plans that block on exhaustion, further usage is rejected with `insufficient_balance`; otherwise the balance goes negative and usage continues. `customer.state_changed` fires alongside it with trigger `balance_depleted`. Use it to cut off gated features or push an urgent top-up flow. # balance.low (/docs/webhooks/balance-low) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `currentBalance` (number) — The remaining balance in rate scale (10000 = $1.00 of the subscription currency). - `thresholdBalance` (number) — The low-balance threshold that was crossed: 10% of the last refill, in rate scale. - `currency` (string) — The subscription currency. ```json { "event": "balance.low", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "currentBalance": 90000, "thresholdBalance": 100000, "currency": "usd" } } ``` ## When this fires The async usage processor watches every balance deduction. When a batch moves the prepaid balance from above to at-or-below 10% of the last refill (period reset, top-up, or manual adjustment), this event fires once per crossing. A top-up re-arms it. Amounts are in rate scale (10000 = $1.00) in the subscription currency. Use it to prompt the customer to top up before usage gets blocked. # balance.topped_up (/docs/webhooks/balance-topped-up) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `invoiceId` (string) — The invoice issued for the top-up. - `invoiceNumber` (string) — The human-readable invoice number. - `amount` (number) — The topped-up value in rate scale (10000 = $1.00 of the subscription currency). - `currency` (string) — The subscription currency. ```json { "event": "balance.topped_up", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "invoiceId": "inv_t1u2v3", "invoiceNumber": "INV-0051", "amount": 500000, "currency": "usd" } } ``` ## When this fires When a customer on a balance plan tops up their prepaid balance through the customer portal and the payment succeeds. `amount` is the topped-up value in rate scale (10000 = $1.00 of the subscription currency) — the same scale `balance.low` and `balance.depleted` use. Use it to confirm the top-up in your own UI and to clear any low-balance warnings you raised on `balance.low`. # checkout.ready (/docs/webhooks/checkout-ready) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `invoiceId` (string) — The invoice this checkout collects. - `invoiceNumber` (string) — The human-readable invoice number. - `invoiceTotal` (number) — Invoice total in cents (100 = $1.00). - `invoiceCurrency` (string) — The invoice currency code. - `checkoutUrl` (string) — The hosted checkout URL to share with the customer. ```json { "event": "checkout.ready", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "invoiceId": "inv_k1l2m3", "invoiceNumber": "INV-0042", "invoiceTotal": 9900, "invoiceCurrency": "usd", "checkoutUrl": "https://pay.commet.co/checkout/tok_9f8e7d6c" } } ``` ## When this fires When a subscription is created without a payment method on file, Commet generates a hosted checkout link for the first invoice and fires this event as soon as the link is ready. Commet also emails the link to the customer — this webhook lets you deliver it through your own channels (in-app banner, chat, SMS) instead of relying on email alone. The link stays valid until the invoice is paid or voided. Trials with their own checkout flow fire `trial.checkout_ready` instead. Use the `checkoutUrl` to put the payment link in front of the customer wherever they already are. # credits.depleted (/docs/webhooks/credits-depleted) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `remainingCredits` (number) — Credits remaining after depletion. Always 0. ```json { "event": "credits.depleted", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "remainingCredits": 0 } } ``` ## When this fires The async usage processor fires this once when a deduction moves the credit balance from positive to zero. From this point, usage requests that need more credits than remain are rejected with `insufficient_credits`. `customer.state_changed` fires alongside it with trigger `credits_depleted`, carrying the customer's full current entitlement state. Use it to block gated features in your app and drive the customer to a credit pack purchase. # credits.expired (/docs/webhooks/credits-expired) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `expiredCredits` (number) — The unused plan credits that were discarded. ```json { "event": "credits.expired", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "expiredCredits": 120 } } ``` ## When this fires Plan credits expire at the end of each billing period: the period reset discards whatever remained and grants the new period's credits (which fires `credits.granted`). This event reports the discarded amount. Purchased credits never expire and are not affected. It only fires when there was something to discard — a customer who used all plan credits gets no `credits.expired`. Use it for end-of-period usage summaries ("you left 120 credits on the table"). # credits.granted (/docs/webhooks/credits-granted) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `credits` (number) — The number of credits granted. - `reason` (string) — Why the credits were granted: period\_reset or manual\_adjustment. ```json { "event": "credits.granted", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "credits": 500, "reason": "period_reset" } } ``` ## When this fires Plan-included credits are granted at the start of every billing period (`reason: "period_reset"`), and manual adjustments from the dashboard grant purchased credits (`reason: "manual_adjustment"`). Credit pack purchases are a separate flow and fire `credits.purchased` instead. The idempotency key is derived from the billing operation that granted the credits, so engine retries never send a duplicate. Use it to reset in-app usage meters at the start of a period or to confirm a support-driven credit grant reached the customer. # credits.low (/docs/webhooks/credits-low) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `remainingCredits` (number) — Total credits remaining (plan plus purchased). - `thresholdCredits` (number) — The low-credit threshold that was crossed: 10% of the period's granted plan credits. - `periodCredits` (number) — The plan credits granted at the last period reset. ```json { "event": "credits.low", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "remainingCredits": 42, "thresholdCredits": 50, "periodCredits": 500 } } ``` ## When this fires The async usage processor watches every credit deduction. When a batch of usage moves the remaining credits (plan plus purchased) from above to at-or-below 10% of the credits granted at the last period reset, this event fires once. It does not re-fire while the balance stays low, and the next period reset re-arms it. If a single burst of usage jumps straight past zero, only `credits.depleted` fires. Use it to prompt the customer to buy a credit pack before they hit zero. # credits.purchased (/docs/webhooks/credits-purchased) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `invoiceId` (string) — The invoice issued for the purchase. - `invoiceNumber` (string) — The human-readable invoice number. - `creditPackName` (string) — The purchased credit pack's name. - `credits` (number) — The number of credits purchased. ```json { "event": "credits.purchased", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "invoiceId": "inv_t1u2v3", "invoiceNumber": "INV-0051", "creditPackName": "Booster 500", "credits": 500 } } ``` ## When this fires When a customer buys a credit pack through the customer portal and the payment succeeds. Purchased credits never expire — unlike plan credits, they survive period resets. Plan-included credits granted at each period reset fire `credits.granted` instead. Use it to confirm top-up purchases in your own UI or analytics. # customer.created (/docs/webhooks/customer-created) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `id` (string) — The Commet customer ID (cus\_...). - `externalId` (string | null) — Your own identifier for this customer, if you provided one. - `fullName` (string | null) — The customer's full name. - `email` (string) — The customer's email. - `taxDocument` (string | null) — The customer's tax identification number, if provided. - `documentType` (string | null) — The local tax document type label inferred from the customer's country (e.g. CUIT, RFC, RUT), or null when no tax document was provided. - `timezone` (string | null) — The customer's timezone. - `metadata` (object | null) — Custom key-value metadata you attached to the customer. - `createdAt` (string) — ISO 8601 datetime when the customer was created. - `updatedAt` (string) — ISO 8601 datetime of the last update. ```json { "event": "customer.created", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "id": "cus_1a2b3c4d", "externalId": "user_123", "fullName": "Ada Lovelace", "email": "ada@acme.com", "taxDocument": "20-12345678-9", "documentType": "CUIT", "timezone": "UTC", "metadata": { "plan_intent": "pro" }, "createdAt": "2026-03-25T14:29:00.000Z", "updatedAt": "2026-03-25T14:29:00.000Z" } } ``` ## Resource shape and version pinning The payload is the customer resource exactly as the REST API returns it for your pinned API version. If your endpoint is pinned to a version before `2026-06-07`, the `email` field arrives as `billingEmail` — the same downgrade applied to `GET /customers` responses. The event fires for every creation path: `POST /customers`, batch create, the SDKs, and the dashboard. Creating a customer with an `externalId` that already exists returns the existing customer and does NOT fire this event again. # customer.state_changed (/docs/webhooks/customer-state-changed) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `trigger` (string) — What caused the transition. One of: subscription\_created, subscription\_activated, subscription\_canceled, subscription\_paused, subscription\_resumed, plan\_change, past\_due, trial\_started, trial\_converted, trial\_expired, cancellation\_scheduled, cancellation\_revoked, seats\_updated, addon\_activated, addon\_deactivated, credits\_depleted, balance\_depleted, quota\_exceeded, plan\_access\_granted, plan\_access\_ended. - `status` (string) — The customer's current subscription status, or "none" when no live subscription exists. Access is granted while trialing, active, or past\_due — past\_due is a permissive grace window during dunning. - `subscriptionId` (string | null) — The live subscription ID, or null when status is none. - `plan` (WebhookPlanRef | null) — The current plan (id and name), or null when status is none. - `billingInterval` (string | null) — The current billing interval. - `consumptionModel` (string | null) — The plan's consumption model: metered, credits, or balance. - `features` (unknown\[]) — Current feature access, discriminated by type. Boolean features expose enabled; usage features expose model-specific consumption; seats and quota expose usage allowances. - `seats` (WebhookSeatSummary\[]) — Summary of seats-type features: code, current, included, remaining, unlimited. - `credits` (WebhookCreditsBalance | null) — For credits plans: planCredits, purchasedCredits, totalCredits. Null otherwise. - `balance` (WebhookBalance | null) — For balance plans: currentBalance in rate scale (10000 = $1.00). Null otherwise. ```json { "event": "customer.state_changed", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "customerId": "user_123", "trigger": "subscription_activated", "status": "active", "subscriptionId": "sub_1a2b3c4d", "plan": { "id": "pln_pro_monthly", "name": "Pro" }, "billingInterval": "monthly", "consumptionModel": "metered", "features": [ { "code": "api_calls", "name": "API Calls", "unitName": "request", "type": "usage", "allowed": true, "consumption": { "model": "metered", "period": { "start": "2026-07-01T00:00:00.000Z", "end": "2026-08-01T00:00:00.000Z" }, "unitsUsed": 120, "includedUnits": 1000, "remainingUnits": 880, "unlimited": false, "overage": { "enabled": true, "units": 0, "unitPrice": { "amount": 50, "currency": "usd", "scale": 10000 } } } }, { "code": "editors", "name": "Editors", "unitName": "seat", "type": "seats", "allowed": true, "usage": { "period": { "start": "2026-07-01T00:00:00.000Z", "end": "2026-08-01T00:00:00.000Z" }, "unitsUsed": 3, "includedUnits": 5, "remainingUnits": 2, "unlimited": false, "overage": { "enabled": false, "units": 0 } } } ], "seats": [ { "code": "editors", "current": 3, "included": 5, "remaining": 2, "unlimited": false } ], "credits": null, "balance": null } } ``` ## One event to sync access Instead of handling every lifecycle event (`subscription.activated`, `subscription.canceled`, `trial.expired`, ...) to keep your access flags in sync, handle this single event. Every entitlement transition fires it with the customer's **current** state, computed at delivery time: | trigger | When | | ------------------------ | ------------------------------------------------------------------------------------------------------------ | | `subscription_created` | A subscription was created (status `pending_payment` — no access yet). | | `subscription_activated` | A payment confirmed the subscription. | | `trial_started` | A trial began. | | `trial_converted` | A trialing customer converted to paid via plan change. | | `trial_expired` | A trial ran out and regular billing began. | | `plan_change` | A plan change executed (immediate or scheduled). | | `cancellation_scheduled` | A cancellation was scheduled — access continues until period end. | | `cancellation_revoked` | A scheduled cancellation was reverted. | | `subscription_canceled` | The subscription terminated — `status` becomes `none`. | | `past_due` | A recurring payment failed — a grace window begins: usage and seats keep working, new purchases are blocked. | | `seats_updated` | A customer's seat count changed. | | `addon_activated` | An add-on was activated on the subscription. | | `addon_deactivated` | An add-on was deactivated from the subscription. | | `credits_depleted` | The subscription ran out of credits. | | `balance_depleted` | The subscription ran out of prepaid balance. | | `quota_exceeded` | Usage passed a feature's included quantity. | | `plan_access_granted` | A Plan Grant temporarily expanded the customer's access without changing subscription billing. | | `plan_access_ended` | A Plan Grant expired or was revoked; the payload contains the resulting current state. | ## Handling the payload Use `status` as the access gate (`trialing` and `active` grant access), `features` for per-feature limits, and `credits`/`balance` for consumption headroom on credits/balance plans. The payload reflects the state at delivery time — if two transitions happen back to back, the later event always carries the final state, so processing events in `timestamp` order converges to the correct result. See [Plan Grants](/docs/plan-grants) for the temporary access lifecycle. # customer.updated (/docs/webhooks/customer-updated) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `id` (string) — The Commet customer ID (cus\_...). - `externalId` (string | null) — Your own identifier for this customer, if you provided one. - `fullName` (string | null) — The customer's full name. - `email` (string) — The customer's email. - `taxDocument` (string | null) — The customer's tax identification number, if provided. - `documentType` (string | null) — The local tax document type label inferred from the customer's country (e.g. CUIT, RFC, RUT), or null when no tax document was provided. - `timezone` (string | null) — The customer's timezone. - `metadata` (object | null) — Custom key-value metadata you attached to the customer. - `createdAt` (string) — ISO 8601 datetime when the customer was created. - `updatedAt` (string) — ISO 8601 datetime of this update. ```json { "event": "customer.updated", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "id": "cus_1a2b3c4d", "externalId": "user_123", "fullName": "Ada Lovelace", "email": "ada.lovelace@acme.com", "taxDocument": "20-12345678-9", "documentType": "CUIT", "timezone": "Europe/London", "metadata": { "plan_intent": "pro" }, "createdAt": "2026-03-25T14:29:00.000Z", "updatedAt": "2026-04-02T09:10:00.000Z" } } ``` ## What counts as an update The event fires when a customer field changes: `email`, `fullName`, `timezone`, `externalId`, or `metadata`. It carries the complete current resource, not a diff — replace your local copy with the payload. Every distinct update delivers its own event, even several in quick succession. Like `customer.created`, the resource shape follows your endpoint's pinned API version. # Introduction (/docs/webhooks/introduction) Webhooks let your application receive real-time HTTP notifications when events happen in Commet — like a subscription being activated, a payment failing, or an invoice being created. ## How it works 1. You register an endpoint URL in the Commet dashboard 2. You select which events you want to receive 3. When an event occurs, Commet sends a `POST` request to your URL with the event data ## Local development Use the Commet CLI to forward webhook events to your local server in real time — no tunneling tools needed: ```bash commet listen localhost:3000/api/webhooks/commet ``` The CLI connects to Commet's event stream and replays every webhook directly to your local URL. You'll see each event, its response status, and timing in the terminal: ``` ✓ Authenticated (org: Acme Inc) ✓ Connected to Commet webhook stream ⟶ Forwarding to http://localhost:3000/api/webhooks/commet/ ⟶ Signing secret: whsec_abc123... Ready! Listening for webhook events... 14:32:01 subscription.activated → 200 OK (12ms) 14:32:05 invoice.created → 200 OK (8ms) ``` You can point to any local URL — a custom hostname, a different port, or a specific path: ```bash commet listen localhost:3000/api/webhooks/commet commet listen local.myapp.dev:3010/webhooks commet listen 4000 ``` Filter to specific events with `--events`: ```bash commet listen localhost:3000/api/webhooks/commet --events subscription.activated,payment.failed ``` The signing secret is printed when the session starts. Set it as `COMMET_WEBHOOK_SECRET` in your `.env` to verify signatures locally with the same code you'll use in production. ## Payload structure Every webhook delivers a JSON payload with this envelope: ```json { "id": "whev_8AzNvGSAZJw0YBOUMTn9vM1V", "event": "subscription.activated", "timestamp": "2026-03-25T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-05-25", "data": { // Event-specific fields } } ``` | Field | Type | Description | | ---------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | Unique event id (e.g. `whev_…`). Stable across delivery retries — deduplicate on it. Also sent as the `X-Commet-Event-Id` header | | `event` | string | The event type (e.g. `subscription.activated`) | | `timestamp` | string | ISO 8601 datetime when the event was emitted | | `organizationId` | string | Your organization ID | | `mode` | string | `"live"` or `"sandbox"` — which environment triggered the event | | `apiVersion` | string | The API version used to shape this payload (e.g. `2026-05-25`). See [API Versioning](/docs/api-versioning) | | `data` | object | Event-specific payload — see individual event pages below | The same event is retried with the **same `id`** when your endpoint doesn't respond with a `2xx`, so store processed ids and skip duplicates. ## Handling webhooks Receive events by exposing an HTTP endpoint. The Node.js SDK ships a dedicated Next.js handler that verifies signatures and routes events automatically; in other languages, verify the payload and dispatch on `event`. ### Node.js ```typescript title="app/api/webhooks/commet/route.ts" import { Webhooks } from "@commet/next" export const POST = Webhooks({ webhookSecret: process.env.COMMET_WEBHOOK_SECRET!, onSubscriptionActivated: async (payload) => { await db.update(users) .set({ isPaid: true }) .where(eq(users.id, payload.data.customerId)) }, onSubscriptionCanceled: async (payload) => { await db.update(users) .set({ isPaid: false }) .where(eq(users.id, payload.data.customerId)) }, onPayload: async (payload) => { console.log(`Received: ${payload.event}`) }, }) ``` ### Python ```python import os from flask import Flask, request, Response from commet import Commet app = Flask(__name__) commet = Commet(api_key=os.environ['COMMET_API_KEY']) @app.post('/api/webhooks/commet') def handle_webhook(): payload = commet.webhooks.verify_and_parse( raw_body=request.get_data(as_text=True), signature=request.headers.get('x-commet-signature'), secret=os.environ['COMMET_WEBHOOK_SECRET'], ) if payload is None: return Response('Invalid signature', status=401) if payload['event'] == 'subscription.activated': # Grant access pass elif payload['event'] == 'subscription.canceled': # Revoke access pass return Response('OK', status=200) ``` ### Go ```go import ( "io" "net/http" "os" "github.com/commet-labs/commet-go/v9" ) client, _ := commet.New(os.Getenv("COMMET_API_KEY")) http.HandleFunc("/api/webhooks/commet", func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) payload, err := client.Webhooks.VerifyAndParse( string(body), r.Header.Get("X-Commet-Signature"), os.Getenv("COMMET_WEBHOOK_SECRET"), ) if err != nil { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } switch payload["event"] { case "subscription.activated": // Grant access case "subscription.canceled": // Revoke access } w.WriteHeader(http.StatusOK) }) ``` ### Java ```java import co.commet.Commet; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController public class CommetWebhookController { private final Commet commet = Commet.builder() .apiKey(System.getenv("COMMET_API_KEY")) .build(); @PostMapping("/api/webhooks/commet") public ResponseEntity handle( @RequestBody String rawBody, @RequestHeader("X-Commet-Signature") String signature ) { Map payload = commet.webhooks().verifyAndParse( rawBody, signature, System.getenv("COMMET_WEBHOOK_SECRET") ); if (payload == null) { return ResponseEntity.status(401).body("Invalid signature"); } String event = (String) payload.get("event"); switch (event) { case "subscription.activated" -> { /* Grant access */ } case "subscription.canceled" -> { /* Revoke access */ } } return ResponseEntity.ok("OK"); } } ``` ### PHP ```php use Commet\Commet; $commet = new Commet(apiKey: getenv('COMMET_API_KEY')); $rawBody = file_get_contents('php://input'); $signature = $_SERVER['HTTP_X_COMMET_SIGNATURE'] ?? null; $payload = $commet->webhooks->verifyAndParse( $rawBody, $signature, getenv('COMMET_WEBHOOK_SECRET'), ); if ($payload === null) { http_response_code(401); exit('Invalid signature'); } match ($payload['event']) { 'subscription.activated' => null, // Grant access 'subscription.canceled' => null, // Revoke access default => null, }; http_response_code(200); echo 'OK'; ``` ## Verifying signatures manually If you're not using `@commet/next`, verify the HMAC-SHA256 signature yourself with the SDK: ### Node.js ```typescript import { Commet } from "@commet/node" const commet = new Commet({ apiKey: process.env.COMMET_API_KEY! }) export async function POST(request: Request) { const rawBody = await request.text() const signature = request.headers.get("x-commet-signature") const payload = commet.webhooks.verifyAndParse({ rawBody, signature, secret: process.env.COMMET_WEBHOOK_SECRET!, }) if (!payload) { return new Response("Invalid signature", { status: 403 }) } switch (payload.event) { case "subscription.activated": // Grant access break case "subscription.canceled": // Revoke access break } return new Response("OK", { status: 200 }) } ``` ### Python ```python import os from flask import Flask, request, Response from commet import Commet app = Flask(__name__) commet = Commet(api_key=os.environ['COMMET_API_KEY']) @app.post('/webhooks/commet') def commet_webhook(): raw_body = request.get_data(as_text=True) signature = request.headers.get('x-commet-signature') payload = commet.webhooks.verify_and_parse( raw_body=raw_body, signature=signature, secret=os.environ['COMMET_WEBHOOK_SECRET'], ) if payload is None: return Response('Invalid signature', status=403) if payload['event'] == 'subscription.activated': # Grant access pass elif payload['event'] == 'subscription.canceled': # Revoke access pass return Response('OK', status=200) ``` ### Go ```go import ( "io" "net/http" "os" "github.com/commet-labs/commet-go/v9" ) client, _ := commet.New(os.Getenv("COMMET_API_KEY")) http.HandleFunc("/webhooks/commet", func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) signature := r.Header.Get("X-Commet-Signature") payload, err := client.Webhooks.VerifyAndParse( string(body), signature, os.Getenv("COMMET_WEBHOOK_SECRET"), ) if err != nil { http.Error(w, "Invalid signature", http.StatusForbidden) return } switch payload["event"] { case "subscription.activated": // Grant access case "subscription.canceled": // Revoke access } w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) }) ``` ### Java ```java import co.commet.Commet; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController public class WebhookController { private final Commet commet = Commet.builder() .apiKey(System.getenv("COMMET_API_KEY")) .build(); @PostMapping("/webhooks/commet") public ResponseEntity handle( @RequestBody String rawBody, @RequestHeader("X-Commet-Signature") String signature ) { Map payload = commet.webhooks().verifyAndParse( rawBody, signature, System.getenv("COMMET_WEBHOOK_SECRET") ); if (payload == null) { return ResponseEntity.status(403).body("Invalid signature"); } String event = (String) payload.get("event"); switch (event) { case "subscription.activated" -> { /* Grant access */ } case "subscription.canceled" -> { /* Revoke access */ } } return ResponseEntity.ok("OK"); } } ``` ### PHP ```php use Commet\Commet; $commet = new Commet(apiKey: getenv('COMMET_API_KEY')); $rawBody = file_get_contents('php://input'); $signature = $_SERVER['HTTP_X_COMMET_SIGNATURE'] ?? null; $payload = $commet->webhooks->verifyAndParse( $rawBody, $signature, getenv('COMMET_WEBHOOK_SECRET'), ); if ($payload === null) { http_response_code(403); echo 'Invalid signature'; exit; } match ($payload['event']) { 'subscription.activated' => null, // Grant access 'subscription.canceled' => null, // Revoke access default => null, }; http_response_code(200); echo 'OK'; ``` ## Headers Commet sends these headers with every webhook request: | Header | Description | | -------------------- | ---------------------------------------------- | | `X-Commet-Signature` | HMAC-SHA256 hex signature of the raw body | | `X-Commet-Event` | The event type (e.g. `subscription.activated`) | | `X-Commet-Timestamp` | ISO 8601 datetime when the event was emitted | | `Content-Type` | `application/json` | ## Retry policy If your endpoint returns a non-2xx status or times out (10 seconds), Commet retries with exponential backoff. The initial delivery counts as the first attempt, followed by up to 7 retries — 8 attempts in total over a window of roughly 8 hours: | Retry | Delay after previous attempt | | --------- | ---------------------------- | | 1st retry | 1 minute | | 2nd retry | 5 minutes | | 3rd retry | 15 minutes | | 4th retry | 30 minutes | | 5th retry | 1 hour | | 6th retry | 2 hours | | 7th retry | 4 hours | After 8 failed attempts (the initial delivery plus 7 retries), the delivery is marked as failed and we email your organization's notification recipient with the endpoint URL, event type, and the last response we received (HTTP status or error code). ## Auto-disable for broken endpoints If three events in a row fail to deliver, Commet automatically disables the endpoint so it stops consuming retries. You'll get a second email confirming the endpoint was turned off. Once you've fixed the issue on your receiver, re-enable the endpoint from the Commet dashboard under **Settings → Webhooks → Endpoints**. Events that arrived while the endpoint was disabled are not replayed automatically — contact support if you need to backfill any missed events. You can monitor delivery status, inspect payloads, and retry individual deliveries from the dashboard at any time. ## Subscription status lifecycle Every `subscription.*` webhook includes a `status` field. These are the valid values and which ones grant access to your product: | Status | Grants access? | Meaning | | ----------------- | ------------------ | -------------------------------------------------------------------------------------------------------------- | | `draft` | No | Internal setup state before any event is fired | | `pending_payment` | **No** | Subscription created, waiting for the first charge to confirm | | `trialing` | **Yes** | Trial active; card captured, no charge yet | | `active` | **Yes** | Paid and current | | `past_due` | Yes (grace period) | A renewal charge failed; dunning retries in progress — usage and seats keep working, new purchases are blocked | | `canceled` | No | Terminal — the cancellation executed at period end, or dunning exhausted its retries | Typical flow: ``` draft → pending_payment → trialing → active ⇄ past_due │ ↑ │ │ └───────────────────┘ ↓ ↓ canceled ``` - Without a trial, `pending_payment` goes straight to `active` when the first charge confirms; with one, it goes to `trialing`. - `trialing → active` happens when the trial converts — and also when it expires: trial expiry activates the subscription and regular billing begins. - `active ⇄ past_due`: a failed renewal starts dunning; a recovered payment returns the subscription to `active`. - `canceled` is terminal, reached when a scheduled cancellation executes at period end or when dunning exhausts its retries. Rule of thumb: gate access on `status === "active" || status === "trialing"`. Rely on `subscription.activated` to turn access on and `subscription.canceled` to turn it off. # invoice.created (/docs/webhooks/invoice-created) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `invoiceId` (string) — The invoice ID. - `invoiceNumber` (string) — The human-readable invoice number. - `invoiceStatus` (string) — The invoice status (e.g. pending, paid). - `periodStart` (string) — ISO 8601 start of the billing period. - `periodEnd` (string) — ISO 8601 end of the billing period. - `issueDate` (string) — ISO 8601 date the invoice was issued. - `dueDate` (string) — ISO 8601 date the invoice is due. - `currency` (string) — The invoice currency code. - `subtotal` (number) — Subtotal in cents (100 = $1.00). - `total` (number) — Total in cents (100 = $1.00). - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `subscriptionId` (string | null) — The subscription ID, if the invoice is linked to a subscription. ```json { "event": "invoice.created", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "invoiceId": "inv_n4o5p6", "invoiceNumber": "INV-0043", "invoiceStatus": "pending", "periodStart": "2026-04-25T00:00:00.000Z", "periodEnd": "2026-05-25T00:00:00.000Z", "issueDate": "2026-04-25T00:00:00.000Z", "dueDate": "2026-04-25T00:00:00.000Z", "currency": "usd", "subtotal": 9900, "total": 9900, "customerId": "user_123", "subscriptionId": "sub_1a2b3c4d" } } ``` # invoice.overdue (/docs/webhooks/invoice-overdue) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `invoiceId` (string) — The invoice ID. - `invoiceNumber` (string) — The human-readable invoice number. - `invoiceStatus` (string) — Always "outstanding" for this event. - `periodStart` (string) — ISO 8601 start of the billing period. - `periodEnd` (string) — ISO 8601 end of the billing period. - `issueDate` (string) — ISO 8601 date the invoice was issued. - `dueDate` (string) — ISO 8601 date the invoice was due — now in the past. - `currency` (string) — The invoice currency code. - `subtotal` (number) — Subtotal in cents (100 = $1.00). - `total` (number) — Total in cents (100 = $1.00). - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `subscriptionId` (string | null) — The subscription ID, if the invoice is linked to a subscription. ```json { "event": "invoice.overdue", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "invoiceId": "inv_n4o5p6", "invoiceNumber": "INV-0043", "invoiceStatus": "outstanding", "periodStart": "2026-04-25T00:00:00.000Z", "periodEnd": "2026-05-25T00:00:00.000Z", "issueDate": "2026-04-25T00:00:00.000Z", "dueDate": "2026-04-25T00:00:00.000Z", "currency": "usd", "subtotal": 9900, "total": 9900, "customerId": "user_123", "subscriptionId": "sub_1a2b3c4d" } } ``` ## When this fires A daily scan finds outstanding invoices whose `dueDate` is in the past and emits this event once per invoice. The idempotency key is derived from the invoice, so re-running the scan never sends a duplicate. The invoice keeps its `"outstanding"` status — overdue is a fact about the due date, not a new status. If the invoice is later paid or voided, `payment.received` or `invoice.voided` reflects the outcome. Use it to start your own dunning flow: email the customer, show an in-app banner, or flag the account for follow-up. # invoice.upcoming (/docs/webhooks/invoice-upcoming) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `status` (string) — Always "active" for this event. - `planId` (string) — The plan ID. - `planName` (string) — The plan name. - `billingInterval` (string | null) — The billing interval (monthly, yearly). - `currentPeriodEnd` (string) — ISO 8601 datetime when the current period ends and the renewal invoice is issued. ```json { "event": "invoice.upcoming", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "status": "active", "planId": "pln_pro_monthly", "planName": "Pro", "billingInterval": "monthly", "currentPeriodEnd": "2026-04-25T00:00:00.000Z" } } ``` ## When this fires A daily scan finds active subscriptions renewing within the next 3 days and emits this event once per renewal. The idempotency key is derived from the subscription and the renewal date, so re-running the scan never sends a duplicate. Subscriptions with a scheduled cancellation are excluded — no renewal invoice will be issued for them. Use it to notify the customer before they are charged. The payload intentionally carries no amount: usage-based charges are only final at renewal time, when `invoice.created` delivers the actual invoice. # invoice.voided (/docs/webhooks/invoice-voided) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `invoiceId` (string) — The invoice ID. - `invoiceNumber` (string) — The human-readable invoice number. - `invoiceStatus` (string) — Always "void" for this event. - `periodStart` (string) — ISO 8601 start of the billing period. - `periodEnd` (string) — ISO 8601 end of the billing period. - `issueDate` (string) — ISO 8601 date the invoice was issued. - `dueDate` (string) — ISO 8601 date the invoice was due. - `currency` (string) — The invoice currency code. - `subtotal` (number) — Subtotal in cents (100 = $1.00). - `total` (number) — Total in cents (100 = $1.00). - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `subscriptionId` (string | null) — The subscription ID, if the invoice is linked to a subscription. ```json { "event": "invoice.voided", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "invoiceId": "inv_n4o5p6", "invoiceNumber": "INV-0043", "invoiceStatus": "void", "periodStart": "2026-04-25T00:00:00.000Z", "periodEnd": "2026-05-25T00:00:00.000Z", "issueDate": "2026-04-25T00:00:00.000Z", "dueDate": "2026-04-25T00:00:00.000Z", "currency": "usd", "subtotal": 9900, "total": 9900, "customerId": "user_123", "subscriptionId": "sub_1a2b3c4d" } } ``` ## When this fires An invoice is voided when it is nullified before collection: an admin marks it void through the dashboard or the API, or Commet voids unpaid invoices automatically when their subscription is canceled. Voiding is terminal — a void invoice is never retried or collected. The payload carries the same invoice resource shape as `invoice.created`, with `invoiceStatus` set to `"void"`. Use this event to reverse anything you keyed off `invoice.created` for this invoice — pending dunning reminders, accounting entries, or in-app banners about an open invoice. # payment.dispute_resolved (/docs/webhooks/payment-dispute-resolved) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `paymentTransactionId` (string) — The disputed payment transaction ID. - `provider` ("stripe" | "commet" | "dlocal") — The payment provider the charge was routed to: stripe, commet, or dlocal. - `paymentLinkId` (string | null) — The payment link the payment originated from, or null when the payment did not come from a payment link. - `invoiceId` (string | null) — The invoice the payment collected, or null for payments without an invoice. - `invoiceNumber` (string | null) — The human-readable invoice number, if available. - `customerId` (string | null) — The customer ID, when the payment is linked to an invoice. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `subscriptionId` (string | null) — The subscription ID, if the invoice is linked to a subscription. - `disputeAmount` (number) — The contested amount in cents (100 = $1.00). - `currency` (string) — The dispute currency code. - `disputeReason` (string | null) — The provider's reason code, or null when none is given. - `outcome` (string) — The resolution: "won" or "lost". ```json { "event": "payment.dispute_resolved", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "paymentTransactionId": "txn_q7r8s9", "provider": "stripe", "paymentLinkId": "pay_l1m2n3", "invoiceId": "inv_n4o5p6", "invoiceNumber": "INV-0043", "customerId": "user_123", "subscriptionId": "sub_1a2b3c4d", "disputeAmount": 9900, "currency": "usd", "disputeReason": "fraudulent", "outcome": "won" } } ``` ## When this fires When the payment provider closes a dispute that previously fired `payment.disputed`. The payload carries the same identifiers plus the `outcome`: - `won` — the dispute was resolved in your favor; the frozen amount is restored to your payout balance and the payment returns to succeeded. - `lost` — the chargeback stands; the disputed amount stays deducted. Use it to close the internal flag you opened on `payment.disputed`, and on `lost` to revoke whatever the original payment was funding. # payment.disputed (/docs/webhooks/payment-disputed) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `paymentTransactionId` (string) — The disputed payment transaction ID. - `provider` ("stripe" | "commet" | "dlocal") — The payment provider the charge was routed to: stripe, commet, or dlocal. - `paymentLinkId` (string | null) — The payment link the payment originated from, or null when the payment did not come from a payment link. - `invoiceId` (string | null) — The invoice the payment collected, or null for payments without an invoice. - `invoiceNumber` (string | null) — The human-readable invoice number, if available. - `customerId` (string | null) — The customer ID, when the payment is linked to an invoice. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `subscriptionId` (string | null) — The subscription ID, if the invoice is linked to a subscription. - `disputeAmount` (number) — The contested amount in cents (100 = $1.00). - `currency` (string) — The dispute currency code. - `disputeReason` (string | null) — The provider's reason code (e.g. fraudulent, product\_not\_received), or null when none is given. ```json { "event": "payment.disputed", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "paymentTransactionId": "txn_q7r8s9", "provider": "stripe", "paymentLinkId": null, "invoiceId": "inv_n4o5p6", "invoiceNumber": "INV-0043", "customerId": "user_123", "subscriptionId": "sub_1a2b3c4d", "disputeAmount": 9900, "currency": "usd", "disputeReason": "fraudulent" } } ``` ## When this fires When the cardholder's bank notifies the payment provider that a charge is being disputed (a chargeback). The disputed amount is frozen from your payout balance while the dispute is open. As the Merchant of Record, Commet handles the evidence and resolution process with the provider. `disputeAmount` is the contested amount in cents; `disputeReason` is the provider's reason code (for example `fraudulent` or `product_not_received`), or `null` when the provider gives none. The resolution fires `payment.dispute_resolved` with the outcome. Use this event to flag the account internally — repeated disputes are a strong fraud signal. # payment.failed (/docs/webhooks/payment-failed) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `paymentContext` (object | null) — Charge context captured for new payments. Null for historical payments with no captured context. - `invoiceId` (string) — The invoice ID, if available. - `invoiceNumber` (string) — The human-readable invoice number, if available. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `subscriptionId` (string | null) — The subscription ID, if the invoice is linked to a subscription. - `provider` ("stripe" | "commet" | "dlocal") — The payment provider the charge was routed to: stripe, commet, or dlocal. - `failureCode` (string) — The failure code from the payment processor. - `failureMessage` (string) — A human-readable failure message. - `recoveryUrl` (string | null) — A ready-to-use link the customer can follow to retry this payment, or null when no recovery path applies. For a first failed charge (pending\_payment) it is the checkout URL; for a failed renewal (past\_due) it is a signed recovery link — no separate createRecoveryLink call needed. ### `paymentContext` The original reason for a charge and how this attempt was initiated. Recovery never replaces the original reason. Context can be null when it was not captured, including retries of historical invoices. `reason` (string): The original reason, preserved across payment attempts. `paymentLinkId` (string | null): The public payment link ID, or null when the charge did not originate from a payment link. This is independent of the reason and recovery method. `recovery` (object | null): Null when the charge does not recover a subscription. payment\_recovery identifies a manually recovered overdue subscription, a return after cancellation for non-payment, or a retry of a failed subscription resume. Initial checkout and one-time payment retries are not recovery. dunning\_retry identifies an automatic charge retry, not a webhook delivery retry. `recovery.attempt` (integer): The charge retry position in the dunning schedule, starting at 1. The original failed charge is not a retry. Present only for dunning\_retry. `recovery.maxAttempts` (integer): The total automatic retries applicable to this charge when the attempt began. It is not the number of retries remaining. Present only for dunning\_retry. `first_subscription_payment`: The initial subscription payment. `trial_conversion`: The payment when a free trial ends. `recurring_billing`: A subscription renewal. `plan_change`: A charge caused by a plan change. `reactivation`: A charge to reactivate a canceled subscription. `subscription_resume`: A charge to resume a paused subscription. `one_time_payment`: A one-time payment, including payment links. `overage`: A charge for usage beyond the included allowance. `adjustment`: A charge from an adjustment invoice. ### Initial attempt The original charge attempt, before recovery. ```json { "event": "payment.failed", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "paymentContext": { "reason": "recurring_billing", "paymentLinkId": null, "recovery": null }, "invoiceId": "inv_n4o5p6", "invoiceNumber": "INV-0043", "customerId": "user_123", "subscriptionId": "sub_1a2b3c4d", "provider": "stripe", "failureCode": "card_declined", "failureMessage": "Your card was declined.", "recoveryUrl": "https://pay.commet.co/recover/tok_9f8e7d6c" } } ``` ### Manual recovery The customer recovers an overdue subscription. The original charge reason is preserved. ```json { "event": "payment.failed", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "paymentContext": { "reason": "recurring_billing", "paymentLinkId": null, "recovery": { "type": "payment_recovery" } }, "invoiceId": "inv_n4o5p6", "invoiceNumber": "INV-0043", "customerId": "user_123", "subscriptionId": "sub_1a2b3c4d", "provider": "stripe", "failureCode": "card_declined", "failureMessage": "Your card was declined.", "recoveryUrl": "https://pay.commet.co/recover/tok_9f8e7d6c" } } ``` ### Dunning retries The second automatic retry of a subscription resume charge, out of four allowed retries. The reason remains subscription\_resume. ```json { "event": "payment.failed", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "paymentContext": { "reason": "subscription_resume", "paymentLinkId": null, "recovery": { "type": "dunning_retry", "attempt": 2, "maxAttempts": 4 } }, "invoiceId": "inv_n4o5p6", "invoiceNumber": "INV-0043", "customerId": "user_123", "subscriptionId": "sub_1a2b3c4d", "provider": "stripe", "failureCode": "card_declined", "failureMessage": "Your card was declined.", "recoveryUrl": "https://pay.commet.co/recover/tok_9f8e7d6c" } } ``` ### No context The charge context was not captured. Do not infer the original reason from the subscription's current state. ```json { "event": "payment.failed", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "paymentContext": null, "invoiceId": "inv_n4o5p6", "invoiceNumber": "INV-0043", "customerId": "user_123", "subscriptionId": "sub_1a2b3c4d", "provider": "stripe", "failureCode": "card_declined", "failureMessage": "Your card was declined.", "recoveryUrl": "https://pay.commet.co/recover/tok_9f8e7d6c" } } ``` # payment_link.canceled (/docs/webhooks/payment-link-canceled) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `paymentId` (string) — The payment link ID. - `status` (string) — The link status. Always "canceled" for this event. - `amount` (number) — The total amount of the canceled link in cents (100 = $1.00). - `currency` (string) — The payment currency code. - `description` (string) — The payment description shown to the customer. - `customerId` (string | null) — The customer ID, or null when the link is not tied to a customer. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. ```json { "event": "payment_link.canceled", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "paymentId": "pay_l1m2n3", "status": "canceled", "amount": 5000, "currency": "usd", "description": "One-time onboarding fee", "customerId": "user_123" } } ``` ## When this fires When a pending [payment link](/docs/accept-one-time-payments) is canceled before the customer pays it. A canceled link can no longer be paid. Payment links never expire on their own — there is no expiry event. A link stays payable until it is paid or explicitly canceled. The event fires the same way regardless of which payment provider processes the charge. # payment_link.completed (/docs/webhooks/payment-link-completed) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `paymentContext` (object | null) — Charge context captured for new payments. Null for historical payments with no captured context. - `paymentId` (string) — The payment link ID. - `status` (string) — The link status. Always "succeeded" for this event. - `amount` (number) — The collected amount in cents (100 = $1.00). - `currency` (string) — The payment currency code. - `description` (string) — The payment description shown to the customer. - `customerId` (string | null) — The customer ID, or null when the link is not tied to a customer. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `invoiceId` (string) — The one-time invoice generated for this payment. - `invoiceNumber` (string) — The human-readable invoice number. - `paymentTransactionId` (string | null) — The payment transaction ID for the settled charge. ### `paymentContext` The original reason for a charge and how this attempt was initiated. Recovery never replaces the original reason. Context can be null when it was not captured, including retries of historical invoices. `reason` (string): The original reason, preserved across payment attempts. `paymentLinkId` (string | null): The public payment link ID, or null when the charge did not originate from a payment link. This is independent of the reason and recovery method. `recovery` (object | null): Null when the charge does not recover a subscription. payment\_recovery identifies a manually recovered overdue subscription, a return after cancellation for non-payment, or a retry of a failed subscription resume. Initial checkout and one-time payment retries are not recovery. dunning\_retry identifies an automatic charge retry, not a webhook delivery retry. `recovery.attempt` (integer): The charge retry position in the dunning schedule, starting at 1. The original failed charge is not a retry. Present only for dunning\_retry. `recovery.maxAttempts` (integer): The total automatic retries applicable to this charge when the attempt began. It is not the number of retries remaining. Present only for dunning\_retry. `first_subscription_payment`: The initial subscription payment. `trial_conversion`: The payment when a free trial ends. `recurring_billing`: A subscription renewal. `plan_change`: A charge caused by a plan change. `reactivation`: A charge to reactivate a canceled subscription. `subscription_resume`: A charge to resume a paused subscription. `one_time_payment`: A one-time payment, including payment links. `overage`: A charge for usage beyond the included allowance. `adjustment`: A charge from an adjustment invoice. ```json { "event": "payment_link.completed", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "paymentContext": { "reason": "one_time_payment", "paymentLinkId": "pay_l1m2n3", "recovery": null }, "paymentId": "pay_l1m2n3", "status": "succeeded", "amount": 5000, "currency": "usd", "description": "One-time onboarding fee", "customerId": "user_123", "invoiceId": "inv_n4o5p6", "invoiceNumber": "INV-0044", "paymentTransactionId": "txn_q7r8s9" } } ``` ## When this fires When a customer pays a [payment link](/docs/accept-one-time-payments) on the hosted pay page and the charge settles. Commet generates a one-time invoice (`invoiceType: "one_time_payment"`) and a payment transaction at the same time; the payload carries the `invoiceId` and `paymentTransactionId`. This is the event to fulfill the purchase on — the money has been collected. The event fires the same way regardless of which payment provider processes the charge. # payment_link.created (/docs/webhooks/payment-link-created) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `paymentId` (string) — The payment link ID. - `status` (string) — The link status. Always "pending" for this event. - `amount` (number) — The total amount to collect in cents (100 = $1.00). - `currency` (string) — The payment currency code. - `description` (string) — The payment description shown to the customer. - `customerId` (string | null) — The customer ID, or null when the link is not tied to a customer. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. ```json { "event": "payment_link.created", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "paymentId": "pay_l1m2n3", "status": "pending", "amount": 5000, "currency": "usd", "description": "One-time onboarding fee", "customerId": "user_123" } } ``` ## When this fires When a payment link is created with [Commet Pay](/docs/accept-one-time-payments). A payment link is a one-time, customer-present charge — there's no subscription and no plan behind it. The link is `pending`: the customer has not paid yet. Do not fulfill the purchase on this event. Wait for `payment_link.completed`. The event fires the same way regardless of which payment provider processes the charge. # payment_link.failed (/docs/webhooks/payment-link-failed) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `paymentContext` (object | null) — Charge context captured for new payments. Null for historical payments with no captured context. - `paymentId` (string) — The payment link ID. - `status` (string) — The link status. Always "failed" for this event. - `amount` (number) — The amount that was attempted in cents (100 = $1.00). - `currency` (string) — The payment currency code. - `description` (string) — The payment description shown to the customer. - `customerId` (string | null) — The customer ID, or null when the link is not tied to a customer. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `failureCode` (string) — The failure code from the payment processor. - `failureMessage` (string) — A human-readable failure message. ### `paymentContext` The original reason for a charge and how this attempt was initiated. Recovery never replaces the original reason. Context can be null when it was not captured, including retries of historical invoices. `reason` (string): The original reason, preserved across payment attempts. `paymentLinkId` (string | null): The public payment link ID, or null when the charge did not originate from a payment link. This is independent of the reason and recovery method. `recovery` (object | null): Null when the charge does not recover a subscription. payment\_recovery identifies a manually recovered overdue subscription, a return after cancellation for non-payment, or a retry of a failed subscription resume. Initial checkout and one-time payment retries are not recovery. dunning\_retry identifies an automatic charge retry, not a webhook delivery retry. `recovery.attempt` (integer): The charge retry position in the dunning schedule, starting at 1. The original failed charge is not a retry. Present only for dunning\_retry. `recovery.maxAttempts` (integer): The total automatic retries applicable to this charge when the attempt began. It is not the number of retries remaining. Present only for dunning\_retry. `first_subscription_payment`: The initial subscription payment. `trial_conversion`: The payment when a free trial ends. `recurring_billing`: A subscription renewal. `plan_change`: A charge caused by a plan change. `reactivation`: A charge to reactivate a canceled subscription. `subscription_resume`: A charge to resume a paused subscription. `one_time_payment`: A one-time payment, including payment links. `overage`: A charge for usage beyond the included allowance. `adjustment`: A charge from an adjustment invoice. ```json { "event": "payment_link.failed", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "paymentContext": { "reason": "one_time_payment", "paymentLinkId": "pay_l1m2n3", "recovery": null }, "paymentId": "pay_l1m2n3", "status": "failed", "amount": 5000, "currency": "usd", "description": "One-time onboarding fee", "customerId": "user_123", "failureCode": "card_declined", "failureMessage": "Your card was declined." } } ``` ## When this fires When a charge attempt on a [payment link](/docs/accept-one-time-payments) is declined. The link stays open — a failed link is retryable, and the customer can pay it again. A later successful attempt fires `payment_link.completed`. `failureCode` is the processor's code (for example `card_declined`) and `failureMessage` is the human-readable reason, or `null` when the provider gives none. The event fires the same way regardless of which payment provider processes the charge. # payment_method.attached (/docs/webhooks/payment-method-attached) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription the payment method was saved for. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `card` (WebhookCardInfo | null) — Card display metadata: brand, last4, expMonth, expYear. Null when the method is not a card or its details cannot be retrieved. ```json { "event": "payment_method.attached", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "card": { "brand": "visa", "last4": "4242", "expMonth": 12, "expYear": 2030 } } } ``` ## When this fires Fired when Commet records a payment method for a subscription: after a paid checkout, when a trial starts with a card on file, or when a zero-total checkout completes with a saved payment method. The `card` object carries display metadata only — brand, last 4 digits, and expiration. Full card numbers never leave the payment provider. When the saved method is not a card or its details cannot be retrieved, `card` is `null`. Use it to show the saved card in your own billing UI or confirm to the customer that their payment method is on file. # payment_method.updated (/docs/webhooks/payment-method-updated) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `card` (WebhookCardInfo | null) — Card display metadata for the new method: brand, last4, expMonth, expYear. Null when the method is not a card or its details cannot be retrieved. ```json { "event": "payment_method.updated", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "customerId": "user_123", "card": { "brand": "mastercard", "last4": "5100", "expMonth": 8, "expYear": 2031 } } } ``` ## When this fires Fired when a customer replaces their default payment method through the customer portal. The new method applies to all of the customer's subscriptions. The `card` object carries display metadata only — brand, last 4 digits, and expiration. Full card numbers never leave the payment provider. When the new method is not a card or its details cannot be retrieved, `card` is `null`. Use it to refresh the card shown in your billing UI. A payment method update is also a strong recovery signal for past-due subscriptions — the customer typically updates their card to fix a failed payment. # payment.received (/docs/webhooks/payment-received) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `paymentContext` (object | null) — Charge context captured for new payments. Null for historical payments with no captured context. - `invoiceId` (string) — The invoice ID. - `invoiceNumber` (string) — The human-readable invoice number. - `invoiceTotal` (number) — Invoice total in cents (100 = $1.00). - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `subscriptionId` (string | null) — The subscription ID. - `paymentTransactionId` (string | null) — The payment transaction ID. - `provider` ("stripe" | "commet" | "dlocal" | null) — The payment provider the charge was routed to: stripe, commet, or dlocal. Null for billing-only charges with no Commet ledger row. - `grossAmount` (number | null) — Gross amount in cents before fees. - `currency` (string | null) — The payment currency code. - `orgNetAmount` (number | null) — Net amount after fees in cents. - `customerEmail` (string | null) — The customer email used for this payment. - `paidAt` (string, optional) — ISO 8601 datetime when the payment was received. ### `paymentContext` The original reason for a charge and how this attempt was initiated. Recovery never replaces the original reason. Context can be null when it was not captured, including retries of historical invoices. `reason` (string): The original reason, preserved across payment attempts. `paymentLinkId` (string | null): The public payment link ID, or null when the charge did not originate from a payment link. This is independent of the reason and recovery method. `recovery` (object | null): Null when the charge does not recover a subscription. payment\_recovery identifies a manually recovered overdue subscription, a return after cancellation for non-payment, or a retry of a failed subscription resume. Initial checkout and one-time payment retries are not recovery. dunning\_retry identifies an automatic charge retry, not a webhook delivery retry. `recovery.attempt` (integer): The charge retry position in the dunning schedule, starting at 1. The original failed charge is not a retry. Present only for dunning\_retry. `recovery.maxAttempts` (integer): The total automatic retries applicable to this charge when the attempt began. It is not the number of retries remaining. Present only for dunning\_retry. `first_subscription_payment`: The initial subscription payment. `trial_conversion`: The payment when a free trial ends. `recurring_billing`: A subscription renewal. `plan_change`: A charge caused by a plan change. `reactivation`: A charge to reactivate a canceled subscription. `subscription_resume`: A charge to resume a paused subscription. `one_time_payment`: A one-time payment, including payment links. `overage`: A charge for usage beyond the included allowance. `adjustment`: A charge from an adjustment invoice. ### Initial attempt The original charge attempt, before recovery. ```json { "event": "payment.received", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "paymentContext": { "reason": "first_subscription_payment", "paymentLinkId": null, "recovery": null }, "invoiceId": "inv_n4o5p6", "invoiceNumber": "INV-0043", "invoiceTotal": 9900, "customerId": "user_123", "subscriptionId": "sub_1a2b3c4d", "paymentTransactionId": "txn_q7r8s9", "provider": "stripe", "grossAmount": 9900, "currency": "usd", "orgNetAmount": 9200, "customerEmail": "billing@acme.com", "paidAt": "2026-04-25T00:05:00.000Z" } } ``` ### Manual recovery The customer recovers an overdue subscription. The original charge reason is preserved. ```json { "event": "payment.received", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "paymentContext": { "reason": "recurring_billing", "paymentLinkId": null, "recovery": { "type": "payment_recovery" } }, "invoiceId": "inv_n4o5p6", "invoiceNumber": "INV-0043", "invoiceTotal": 9900, "customerId": "user_123", "subscriptionId": "sub_1a2b3c4d", "paymentTransactionId": "txn_q7r8s9", "provider": "stripe", "grossAmount": 9900, "currency": "usd", "orgNetAmount": 9200, "customerEmail": "billing@acme.com", "paidAt": "2026-04-25T00:05:00.000Z" } } ``` ### Dunning retries The second automatic retry of a subscription resume charge, out of four allowed retries. The reason remains subscription\_resume. ```json { "event": "payment.received", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "paymentContext": { "reason": "subscription_resume", "paymentLinkId": null, "recovery": { "type": "dunning_retry", "attempt": 2, "maxAttempts": 4 } }, "invoiceId": "inv_n4o5p6", "invoiceNumber": "INV-0043", "invoiceTotal": 9900, "customerId": "user_123", "subscriptionId": "sub_1a2b3c4d", "paymentTransactionId": "txn_q7r8s9", "provider": "stripe", "grossAmount": 9900, "currency": "usd", "orgNetAmount": 9200, "customerEmail": "billing@acme.com", "paidAt": "2026-04-25T00:05:00.000Z" } } ``` ### No context The charge context was not captured. Do not infer the original reason from the subscription's current state. ```json { "event": "payment.received", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "paymentContext": null, "invoiceId": "inv_n4o5p6", "invoiceNumber": "INV-0043", "invoiceTotal": 9900, "customerId": "user_123", "subscriptionId": "sub_1a2b3c4d", "paymentTransactionId": "txn_q7r8s9", "provider": "stripe", "grossAmount": 9900, "currency": "usd", "orgNetAmount": 9200, "customerEmail": "billing@acme.com", "paidAt": "2026-04-25T00:05:00.000Z" } } ``` # payment.recovered (/docs/webhooks/payment-recovered) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `invoiceId` (string) — The recovered invoice ID. - `invoiceNumber` (string) — The human-readable invoice number. - `invoiceTotal` (number) — Invoice total in cents (100 = $1.00). - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `subscriptionId` (string | null) — The subscription ID, if the invoice is linked to a subscription. - `provider` ("stripe" | "commet" | "dlocal" | null) — The payment provider that recovered the payment, or null when the invoice was recovered without a processor charge. ```json { "event": "payment.recovered", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "invoiceId": "inv_n4o5p6", "invoiceNumber": "INV-0043", "invoiceTotal": 9900, "customerId": "user_123", "subscriptionId": "sub_1a2b3c4d", "provider": "stripe" } } ``` ## When this fires After a payment failure put a subscription in `past_due`, a successful retry of the outstanding invoice fires this event — whether the charge succeeded automatically or the customer paid through the portal after updating their card. The subscription returns to `active` at the same time, so `customer.state_changed` also fires. Restore access on either event; use this one specifically to close dunning flows you opened on `payment.failed`. # payment.refunded (/docs/webhooks/payment-refunded) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `paymentTransactionId` (string) — The refunded payment transaction ID. - `provider` ("stripe" | "commet" | "dlocal") — The payment provider the charge was routed to: stripe, commet, or dlocal. - `paymentLinkId` (string | null) — The payment link the payment originated from, or null when the payment did not come from a payment link. - `invoiceId` (string | null) — The invoice the payment collected, or null for payments without an invoice. - `invoiceNumber` (string | null) — The human-readable invoice number, if available. - `customerId` (string | null) — The customer ID, when the payment is linked to an invoice. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `subscriptionId` (string | null) — The subscription ID, if the invoice is linked to a subscription. - `refundAmount` (number) — The refunded amount in cents (100 = $1.00). - `currency` (string) — The refund currency code. ```json { "event": "payment.refunded", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "paymentTransactionId": "txn_q7r8s9", "provider": "stripe", "paymentLinkId": null, "invoiceId": "inv_n4o5p6", "invoiceNumber": "INV-0043", "customerId": "user_123", "subscriptionId": "sub_1a2b3c4d", "refundAmount": 9900, "currency": "usd" } } ``` ## When this fires When a refund is issued for a payment — full or partial — and the payment provider confirms it. A full refund of a subscription invoice also cancels the subscription immediately (`subscription.canceled` fires with reason `refund`); a partial refund leaves the subscription untouched. `refundAmount` is the refunded amount in cents (100 = $1.00). The invoice fields are `null` for payments that were not tied to an invoice. Use it to mirror the refund in your own books or to notify the customer through your channels. # payment.retry_failed (/docs/webhooks/payment-retry-failed) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `invoiceId` (string) — The invoice whose retries were exhausted. - `invoiceNumber` (string) — The human-readable invoice number. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `subscriptionId` (string) — The subscription ID. - `provider` ("stripe" | "commet" | "dlocal") — The payment provider the charge was routed to: stripe, commet, or dlocal. - `reason` (string) — Terminal dunning reason, usually the last processor decline code or "dunning\_exhausted". ```json { "event": "payment.retry_failed", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "invoiceId": "inv_n4o5p6", "invoiceNumber": "INV-0043", "customerId": "user_123", "subscriptionId": "sub_1a2b3c4d", "provider": "stripe", "reason": "card_declined" } } ``` ## When this fires After a payment failure puts a subscription in `past_due`, Commet retries the outstanding invoice on the dunning schedule. When the final retry fails, the invoice is marked uncollectible and the subscription is canceled — this event marks that terminal outcome. It is the end of the dunning flow: `payment.recovered` will not follow, and `subscription.canceled` fires alongside it. Revoke access when you receive this event. # payout.available (/docs/webhooks/payout-available) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `availableAmount` (number) — Your full available payout balance in cents (100 = $1.00) at the time of the event — not just the newly released funds. - `currency` (string) — The payout balance currency. Always "usd". ```json { "event": "payout.available", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "availableAmount": 125000, "currency": "usd" } } ``` ## When this fires This is an organization-level event about YOUR money as the merchant, not about a customer. Payment funds start as pending while the provider holds them; a periodic check marks them available once the provider releases them, and this event fires when new funds become available. `availableAmount` is your full available payout balance in cents at that moment — not just the newly released funds. Use it to know when requesting a payout is worthwhile, or to drive your own treasury automation. # payout.created (/docs/webhooks/payout-created) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `payoutId` (string) — The payout ID. - `amount` (number) — Gross payout amount in cents (100 = $1.00). - `fee` (number) — Provider transfer fee in cents. - `netAmount` (number) — What reaches your bank in cents (amount minus fee). - `currency` (string) — The payout currency. Always "usd". - `status` (string) — The payout status. "pending" at creation. - `destinationBank` (WebhookBankRef | null) — Destination bank display metadata: bankName and last4. Full account numbers never appear in webhook payloads. - `createdAt` (string) — ISO 8601 datetime when the payout was created. ```json { "event": "payout.created", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "payoutId": "8b6f2a1c-4d3e-4f5a-9b8c-7d6e5f4a3b2c", "amount": 20000, "fee": 0, "netAmount": 20000, "currency": "usd", "status": "pending", "destinationBank": { "bankName": "CHASE", "last4": "6789" }, "createdAt": "2026-06-12T10:00:00.000Z" } } ``` ## When this fires When a payout of your available balance is requested and the transfer toward your bank is initiated. The payout starts in `pending` and moves through the transfer and bank settlement legs from there. `destinationBank` carries display metadata only (bank name and last4); full account numbers never appear in webhook payloads. The lifecycle continues with `payout.paid` when the money lands, or `payout.failed` if the bank rejects it. # payout.failed (/docs/webhooks/payout-failed) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `payoutId` (string) — The payout ID. - `amount` (number) — Gross payout amount in cents (100 = $1.00). - `fee` (number) — Provider transfer fee in cents. - `netAmount` (number) — What would have reached your bank in cents. - `currency` (string) — The payout currency. Always "usd". - `status` (string) — Always "failed" for this event. - `destinationBank` (WebhookBankRef | null) — Destination bank display metadata: bankName and last4. - `failedAt` (string | null) — ISO 8601 datetime when the failure was recorded. - `failureCode` (string | null) — The provider's failure code, when available. - `failureMessage` (string | null) — A human-readable failure message, when available. ```json { "event": "payout.failed", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "payoutId": "8b6f2a1c-4d3e-4f5a-9b8c-7d6e5f4a3b2c", "amount": 20000, "fee": 0, "netAmount": 20000, "currency": "usd", "status": "failed", "destinationBank": { "bankName": "CHASE", "last4": "6789" }, "failedAt": "2026-06-14T09:00:00.000Z", "failureCode": "account_closed", "failureMessage": "The bank account has been closed" } } ``` ## When this fires When the provider reports that a payout could not be completed — at either leg of the lifecycle, most commonly when the bank rejects the deposit (closed account, invalid details). The funds return to your available balance. `failureCode` and `failureMessage` carry the provider's reason when available. Use it to alert your finance contact and to fix the bank account before requesting the payout again. # payout.paid (/docs/webhooks/payout-paid) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `payoutId` (string) — The payout ID. - `amount` (number) — Gross payout amount in cents (100 = $1.00). - `fee` (number) — Provider transfer fee in cents. - `netAmount` (number) — What reached your bank in cents (amount minus fee). - `currency` (string) — The payout currency. Always "usd". - `status` (string) — Always "paid" for this event. - `destinationBank` (WebhookBankRef | null) — Destination bank display metadata: bankName and last4. - `paidAt` (string | null) — ISO 8601 datetime when the provider confirmed the deposit arrived. ```json { "event": "payout.paid", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "payoutId": "8b6f2a1c-4d3e-4f5a-9b8c-7d6e5f4a3b2c", "amount": 20000, "fee": 0, "netAmount": 20000, "currency": "usd", "status": "paid", "destinationBank": { "bankName": "CHASE", "last4": "6789" }, "paidAt": "2026-06-14T09:00:00.000Z" } } ``` ## When this fires When the bank settlement of a payout completes — the moment the money actually reaches your bank account, confirmed by the payment provider. This is the terminal success state of the payout lifecycle started by `payout.created`. `paidAt` is the provider-confirmed arrival time. Fires exactly once per payout. Use it to reconcile bank deposits against the payouts that produced them. # plan_grant.created (/docs/webhooks/plan-grant-created) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `id` (string) — The public plan grant ID. - `customerId` (string) — The public customer ID. - `subscriptionId` (string) — The public subscription ID. - `basePlanId` (string) — The public ID of the subscribed base plan. - `targetPlanId` (string) — The public ID of the plan whose access was granted. - `targetPlanReleaseId` (string) — The public ID of the immutable target plan release. - `status` ("active" | "expired" | "revoked") — The plan grant status at this transition. - `duration` ("cycles" | "until\_date" | "until\_revoked") — How the plan grant duration is defined at this transition. - `durationCycles` (integer | null) — The cycle count when duration is cycles. - `startsAt` (string) — When the plan grant started. - `expiresAt` (string | null) — The effective expiration deadline, if any. - `reason` (string) — The reason recorded for this transition. - `source` ("dashboard" | "api" | "system") — Where this transition originated. - `revokedAt` (string | null) — When the plan grant was revoked, otherwise null. - `createdAt` (string) — When the plan grant was created. - `updatedAt` (string) — When the represented transition occurred. - `events` (WebhookPlanGrantTimelineEvent\[]) — The grant timeline through the represented transition. ```json { "event": "plan_grant.created", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "id": "pgr_1a2b3c4d", "customerId": "user_123", "subscriptionId": "sub_1a2b3c4d", "basePlanId": "pln_starter", "targetPlanId": "pln_pro", "targetPlanReleaseId": "plr_1a2b3c4d", "status": "active", "duration": "cycles", "durationCycles": 2, "startsAt": "2026-08-01T00:00:00.000Z", "expiresAt": "2026-10-01T00:00:00.000Z", "reason": "Two-month product evaluation", "source": "api", "revokedAt": null, "createdAt": "2026-08-01T00:00:00.000Z", "updatedAt": "2026-08-01T00:00:00.000Z", "events": [ { "id": "gre_1a2b3c4d", "type": "created", "reason": "Two-month product evaluation", "source": "api", "previousExpiresAt": null, "expiresAt": "2026-10-01T00:00:00.000Z", "duration": "cycles", "durationCycles": 2, "requestedExpiresAt": null, "createdAt": "2026-08-01T00:00:00.000Z" } ] } } ``` ## When this fires After a Plan Grant is committed through the API or dashboard. The payload is the grant as it existed at creation, and its last timeline event is `created`. `customer.state_changed` also fires with trigger `plan_access_granted`. # plan_grant.expired (/docs/webhooks/plan-grant-expired) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `id` (string) — The public plan grant ID. - `customerId` (string) — The public customer ID. - `subscriptionId` (string) — The public subscription ID. - `basePlanId` (string) — The public ID of the subscribed base plan. - `targetPlanId` (string) — The public ID of the plan whose access was granted. - `targetPlanReleaseId` (string) — The public ID of the immutable target plan release. - `status` ("active" | "expired" | "revoked") — The plan grant status at this transition. - `duration` ("cycles" | "until\_date" | "until\_revoked") — How the plan grant duration is defined at this transition. - `durationCycles` (integer | null) — The cycle count when duration is cycles. - `startsAt` (string) — When the plan grant started. - `expiresAt` (string | null) — The effective expiration deadline, if any. - `reason` (string) — The reason recorded for this transition. - `source` ("dashboard" | "api" | "system") — Where this transition originated. - `revokedAt` (string | null) — When the plan grant was revoked, otherwise null. - `createdAt` (string) — When the plan grant was created. - `updatedAt` (string) — When the represented transition occurred. - `events` (WebhookPlanGrantTimelineEvent\[]) — The grant timeline through the represented transition. ```json { "event": "plan_grant.expired", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "id": "pgr_1a2b3c4d", "customerId": "user_123", "subscriptionId": "sub_1a2b3c4d", "basePlanId": "pln_starter", "targetPlanId": "pln_pro", "targetPlanReleaseId": "plr_1a2b3c4d", "status": "expired", "duration": "cycles", "durationCycles": 2, "startsAt": "2026-08-01T00:00:00.000Z", "expiresAt": "2026-10-01T00:00:00.000Z", "reason": "Two-month product evaluation", "source": "system", "revokedAt": null, "createdAt": "2026-08-01T00:00:00.000Z", "updatedAt": "2026-10-01T00:05:00.000Z", "events": [ { "id": "gre_1a2b3c4d", "type": "created", "reason": "Two-month product evaluation", "source": "api", "previousExpiresAt": null, "expiresAt": "2026-10-01T00:00:00.000Z", "duration": "cycles", "durationCycles": 2, "requestedExpiresAt": null, "createdAt": "2026-08-01T00:00:00.000Z" }, { "id": "gre_9i0j1k2l", "type": "expired", "reason": "Two-month product evaluation", "source": "system", "previousExpiresAt": null, "expiresAt": "2026-10-01T00:00:00.000Z", "duration": null, "durationCycles": null, "requestedExpiresAt": null, "createdAt": "2026-10-01T00:05:00.000Z" } ] } } ``` ## When this fires After an expired Plan Grant is durably closed by billing discovery or while creating its replacement. The payload is the old grant at expiration, and its last timeline event is `expired`. `customer.state_changed` also fires with trigger `plan_access_ended` when billing discovery expires the grant. # plan_grant.revoked (/docs/webhooks/plan-grant-revoked) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `id` (string) — The public plan grant ID. - `customerId` (string) — The public customer ID. - `subscriptionId` (string) — The public subscription ID. - `basePlanId` (string) — The public ID of the subscribed base plan. - `targetPlanId` (string) — The public ID of the plan whose access was granted. - `targetPlanReleaseId` (string) — The public ID of the immutable target plan release. - `status` ("active" | "expired" | "revoked") — The plan grant status at this transition. - `duration` ("cycles" | "until\_date" | "until\_revoked") — How the plan grant duration is defined at this transition. - `durationCycles` (integer | null) — The cycle count when duration is cycles. - `startsAt` (string) — When the plan grant started. - `expiresAt` (string | null) — The effective expiration deadline, if any. - `reason` (string) — The reason recorded for this transition. - `source` ("dashboard" | "api" | "system") — Where this transition originated. - `revokedAt` (string | null) — When the plan grant was revoked, otherwise null. - `createdAt` (string) — When the plan grant was created. - `updatedAt` (string) — When the represented transition occurred. - `events` (WebhookPlanGrantTimelineEvent\[]) — The grant timeline through the represented transition. ```json { "event": "plan_grant.revoked", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "id": "pgr_1a2b3c4d", "customerId": "user_123", "subscriptionId": "sub_1a2b3c4d", "basePlanId": "pln_starter", "targetPlanId": "pln_pro", "targetPlanReleaseId": "plr_1a2b3c4d", "status": "revoked", "duration": "until_revoked", "durationCycles": null, "startsAt": "2026-08-01T00:00:00.000Z", "expiresAt": null, "reason": "Evaluation completed", "source": "api", "revokedAt": "2026-08-20T14:00:00.000Z", "createdAt": "2026-08-01T00:00:00.000Z", "updatedAt": "2026-08-20T14:00:00.000Z", "events": [ { "id": "gre_1a2b3c4d", "type": "created", "reason": "Open-ended evaluation", "source": "api", "previousExpiresAt": null, "expiresAt": null, "duration": "until_revoked", "durationCycles": null, "requestedExpiresAt": null, "createdAt": "2026-08-01T00:00:00.000Z" }, { "id": "gre_m3n4o5p6", "type": "revoked", "reason": "Evaluation completed", "source": "api", "previousExpiresAt": null, "expiresAt": null, "duration": null, "durationCycles": null, "requestedExpiresAt": null, "createdAt": "2026-08-20T14:00:00.000Z" } ] } } ``` ## When this fires After an active Plan Grant is manually revoked through the API or dashboard. The payload is the grant at revocation, and its last timeline event is `revoked`. `customer.state_changed` also fires with trigger `plan_access_ended`. # plan_grant.updated (/docs/webhooks/plan-grant-updated) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `id` (string) — The public plan grant ID. - `customerId` (string) — The public customer ID. - `subscriptionId` (string) — The public subscription ID. - `basePlanId` (string) — The public ID of the subscribed base plan. - `targetPlanId` (string) — The public ID of the plan whose access was granted. - `targetPlanReleaseId` (string) — The public ID of the immutable target plan release. - `status` ("active" | "expired" | "revoked") — The plan grant status at this transition. - `duration` ("cycles" | "until\_date" | "until\_revoked") — How the plan grant duration is defined at this transition. - `durationCycles` (integer | null) — The cycle count when duration is cycles. - `startsAt` (string) — When the plan grant started. - `expiresAt` (string | null) — The effective expiration deadline, if any. - `reason` (string) — The reason recorded for this transition. - `source` ("dashboard" | "api" | "system") — Where this transition originated. - `revokedAt` (string | null) — When the plan grant was revoked, otherwise null. - `createdAt` (string) — When the plan grant was created. - `updatedAt` (string) — When the represented transition occurred. - `events` (WebhookPlanGrantTimelineEvent\[]) — The grant timeline through the represented transition. ```json { "event": "plan_grant.updated", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "id": "pgr_1a2b3c4d", "customerId": "user_123", "subscriptionId": "sub_1a2b3c4d", "basePlanId": "pln_starter", "targetPlanId": "pln_pro", "targetPlanReleaseId": "plr_1a2b3c4d", "status": "active", "duration": "until_date", "durationCycles": null, "startsAt": "2026-08-01T00:00:00.000Z", "expiresAt": "2026-11-15T00:00:00.000Z", "reason": "Extended evaluation", "source": "dashboard", "revokedAt": null, "createdAt": "2026-08-01T00:00:00.000Z", "updatedAt": "2026-08-15T12:00:00.000Z", "events": [ { "id": "gre_1a2b3c4d", "type": "created", "reason": "Two-month product evaluation", "source": "api", "previousExpiresAt": null, "expiresAt": "2026-10-01T00:00:00.000Z", "duration": "cycles", "durationCycles": 2, "requestedExpiresAt": null, "createdAt": "2026-08-01T00:00:00.000Z" }, { "id": "gre_5e6f7g8h", "type": "updated", "reason": "Extended evaluation", "source": "dashboard", "previousExpiresAt": "2026-10-01T00:00:00.000Z", "expiresAt": "2026-11-15T00:00:00.000Z", "duration": "until_date", "durationCycles": null, "requestedExpiresAt": "2026-11-15T00:00:00.000Z", "createdAt": "2026-08-15T12:00:00.000Z" } ] } } ``` ## When this fires After an active Plan Grant's duration or deadline is committed. The payload is the grant at that update, and its last timeline event is `updated`. This event does not fire for any other field change. # quota.exceeded (/docs/webhooks/quota-exceeded) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `featureCode` (string) — The metered feature code. - `currentUsage` (number) — Total usage in the current period. - `includedAmount` (number) — The included quantity for the period. - `overageEnabled` (boolean) — True when overage billing began; false when the hard limit was hit and usage is now blocked. - `periodStart` (string) — ISO 8601 start of the usage period. ```json { "event": "quota.exceeded", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "featureCode": "api_calls", "currentUsage": 1080, "includedAmount": 1000, "overageEnabled": true, "periodStart": "2026-06-01T00:00:00.000Z" } } ``` ## When this fires Once per feature per billing period, when period usage passes the included quantity: - **Overage enabled** (`overageEnabled: true`): overage billing began — every unit past the included amount will be charged at renewal. - **Overage disabled** (`overageEnabled: false`): the hard limit was hit — further usage requests for this feature are rejected. This case also fires `customer.state_changed` with trigger `quota_exceeded`, since the customer's access changed. Use it to surface overage charges in your UI, or to block the feature when the limit is hard. # quota.threshold_reached (/docs/webhooks/quota-threshold-reached) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `featureCode` (string) — The metered feature code. - `currentUsage` (number) — Total usage in the current period after the crossing. - `includedAmount` (number) — The included quantity for the period. - `periodStart` (string) — ISO 8601 start of the usage period. ```json { "event": "quota.threshold_reached", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "featureCode": "api_calls", "currentUsage": 850, "includedAmount": 1000, "periodStart": "2026-06-01T00:00:00.000Z" } } ``` ## When this fires For metered plans, the async usage processor compares each feature's period usage against its included quantity. When usage crosses 80% of the included amount, this fires once for that feature and billing period. Unlimited features never fire it. If a single burst jumps straight past the included amount, only `quota.exceeded` fires. Use it to warn the customer they are approaching their limit. # seats.limit_reached (/docs/webhooks/seats-limit-reached) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `subscriptionId` (string) — The subscription ID. - `featureCode` (string) — The seats feature code. - `currentSeats` (number) — The seat count after the change. - `includedSeats` (number) — The included seat limit of the plan. ```json { "event": "seats.limit_reached", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "customerId": "user_123", "subscriptionId": "sub_1a2b3c4d", "featureCode": "editors", "currentSeats": 5, "includedSeats": 5 } } ``` ## When this fires When a seat change moves the count from below the plan's included seat limit to at or above it. It fires once per crossing — removing seats and re-adding past the limit fires it again. Plans with unlimited seats never fire it. Seats are not blocked at the limit: counts above the included amount are allowed and billed as overage when the plan enables it. Use it to prompt an upgrade to a higher tier before overage kicks in. # seats.updated (/docs/webhooks/seats-updated) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `subscriptionId` (string | null) — The live subscription ID, or null when the customer has no live subscription. - `featureCode` (string) — The seats feature code. - `previousSeats` (number) — The seat count before the change. - `currentSeats` (number) — The seat count after the change. ```json { "event": "seats.updated", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "customerId": "user_123", "subscriptionId": "sub_1a2b3c4d", "featureCode": "editors", "previousSeats": 3, "currentSeats": 5 } } ``` ## When this fires Every seat mutation fires it: the SDK seat endpoints (add, set, remove, bulk) and manual seat events created from the dashboard. The payload carries the previous and the new absolute count for the feature. `customer.state_changed` fires alongside it with trigger `seats_updated`, carrying the full entitlement state including the seats summary. Use it to keep your own member-management UI in sync with billed seats. # subscription.activated (/docs/webhooks/subscription-activated) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `status` (string) — Current status. One of: draft, pending\_payment, trialing, active, past\_due, canceled. Access is granted while trialing, active, or past\_due — past\_due is a permissive grace window during dunning, where you decide whether to keep serving the customer or block them. - `currentPeriodStart` (string, optional) — ISO 8601 start of the current billing period. - `currentPeriodEnd` (string, optional) — ISO 8601 end of the current billing period. - `name` (string | null) — Optional custom name for the subscription. - `invoiceId` (string) — The invoice ID for this payment. - `invoiceNumber` (string) — The human-readable invoice number. - `invoiceTotal` (number) — Invoice total in cents (100 = $1.00). - `invoiceCurrency` (string) — The invoice currency code. - `provider` ("stripe" | "commet" | "dlocal" | null) — The payment provider that processed the activating charge: stripe, commet, or dlocal. Null when the subscription activated without a charge (zero-total or setup-based activation). ```json { "event": "subscription.activated", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "status": "active", "currentPeriodStart": "2026-03-25T00:00:00.000Z", "currentPeriodEnd": "2026-04-25T00:00:00.000Z", "name": "Acme Corp", "invoiceId": "inv_k1l2m3", "invoiceNumber": "INV-0042", "invoiceTotal": 9900, "invoiceCurrency": "usd", "provider": "stripe" } } ``` # subscription.canceled (/docs/webhooks/subscription-canceled) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `status` (string) — Always "canceled" for this event. Revoke access when you receive this. - `canceledAt` (string, optional) — ISO 8601 datetime when the cancellation was requested or triggered. - `cancelReason` (string | null) — The reason for cancellation, if provided. Set by Commet on system-initiated terminations: "refund" (full refund of a subscription invoice) or "dunning\_exhausted" (all payment retries failed). - `endDate` (string, optional) — ISO 8601 datetime when the subscription ended. Matches the billing period end for scheduled cancellations; for immediate terminations it is the moment of termination. ```json { "event": "subscription.canceled", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "status": "canceled", "canceledAt": "2026-04-20T10:15:00.000Z", "cancelReason": "Too expensive", "endDate": "2026-04-25T00:00:00.000Z" } } ``` ## Cancellation lifecycle A regular cancellation is scheduled for the end of the current billing period. Two events fire at different moments: | Moment | Event | status | What to do | | ------------------------------ | ----------------------- | ---------- | ------------------------------------------------------------ | | Customer requests cancellation | `subscription.updated` | `active` | Show "ending on {endDate}" in your UI. Do NOT revoke access. | | Billing period ends | `subscription.canceled` | `canceled` | Revoke access. | ``` POST /subscriptions/{id}/cancel └→ subscription.updated (status: "active", canceledAt: set, endDate: set) ... time passes until billing period ends ... └→ subscription.canceled (status: "canceled") ``` ## Immediate terminations Some terminations skip the schedule: `subscription.canceled` arrives right away, with no prior `subscription.updated`, and `endDate` is the moment of termination — not a billing period boundary. - `POST /subscriptions/{id}/cancel` with `immediate: true`. Cancellations of free plans and of subscriptions in `pending_payment` or `past_due` also settle immediately — canceling inside the dunning grace window voids the unpaid renewal. - A full refund of a subscription invoice: fires with `cancelReason: "refund"`, alongside `payment.refunded`. - Exhausted dunning retries: fires with `cancelReason: "dunning_exhausted"`, alongside `payment.retry_failed`. # subscription.cancellation_revoked (/docs/webhooks/subscription-cancellation-revoked) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `status` (string) — Current status — typically "active". The scheduled cancellation no longer applies. - `currentPeriodEnd` (string, optional) — ISO 8601 end of the current billing period, which continues normally. ```json { "event": "subscription.cancellation_revoked", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "status": "active", "currentPeriodEnd": "2026-04-25T00:00:00.000Z" } } ``` ## When this fires A customer (or your team, from the dashboard) reverts a cancellation that was scheduled for the end of the billing period. The scheduled cancel is removed and the subscription renews normally at `currentPeriodEnd`. If your UI shows an "ending on" notice from `subscription.cancellation_scheduled`, remove it when you receive this event. `subscription.updated` also fires at this moment for backward compatibility. This event never fires after `subscription.canceled` — once a cancellation has executed it cannot be reverted. # subscription.cancellation_scheduled (/docs/webhooks/subscription-cancellation-scheduled) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `status` (string) — Still "active" — the subscription remains usable until effectiveAt. - `canceledAt` (string, optional) — ISO 8601 datetime when the cancellation was requested. - `cancelReason` (string | null) — The reason for cancellation, if provided. - `effectiveAt` (string) — ISO 8601 datetime when the cancellation will execute (the billing period end). subscription.canceled fires at this moment. ```json { "event": "subscription.cancellation_scheduled", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "status": "active", "canceledAt": "2026-04-20T10:15:00.000Z", "cancelReason": "Too expensive", "effectiveAt": "2026-04-25T00:00:00.000Z" } } ``` ## Cancellation lifecycle This event marks the start of the cancellation lifecycle. The subscription stays fully usable until `effectiveAt`: | Moment | Event | status | What to do | | --------------------------------- | ------------------------------------- | ---------- | ---------------------------------------------------------------- | | Customer requests cancellation | `subscription.cancellation_scheduled` | `active` | Show "ending on {effectiveAt}" in your UI. Do NOT revoke access. | | Customer reverts the cancellation | `subscription.cancellation_revoked` | `active` | Remove the "ending on" notice. | | Billing period ends | `subscription.canceled` | `canceled` | Revoke access. | `subscription.updated` also fires at the scheduling moment for backward compatibility — if you already handle the scheduled-cancel state through `subscription.updated`, you can keep doing so. This event carries the same intent with an explicit name and the exact `effectiveAt`. # subscription.created (/docs/webhooks/subscription-created) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `planId` (string) — The plan ID. - `planName` (string) — The plan name. - `status` (string) — Current status. One of: draft, pending\_payment, trialing, active, past\_due, canceled. Access is granted while trialing, active, or past\_due — past\_due is a permissive grace window during dunning, where you decide whether to keep serving the customer or block them. - `startDate` (string) — ISO 8601 datetime when the subscription starts. - `name` (string | null) — Optional custom name for the subscription. ```json { "event": "subscription.created", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "planId": "pln_pro_monthly", "planName": "Pro", "status": "pending_payment", "startDate": "2026-03-25T14:30:00.000Z", "name": "Acme Corp" } } ``` # subscription.past_due (/docs/webhooks/subscription-past-due) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `status` (string) — Always "past\_due" for this event. - `invoiceId` (string) — The invoice whose payment failure triggered the status. - `invoiceNumber` (string) — The human-readable invoice number. ```json { "event": "subscription.past_due", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "status": "past_due", "invoiceId": "inv_n4o5p6", "invoiceNumber": "INV-0043" } } ``` ## When this fires A recurring charge failed on a subscription that has been paid before. Commet sets the status to `past_due` and enters dunning. `past_due` is a permissive grace window — features, usage, and seats keep working. Only purchases and plan changes are blocked. Usage accrues as debt during this window. Commet retries the charge on day 1, day 3, day 5, and day 7 after the failure (4 retries). If a retry succeeds the subscription returns to `active`. If all 4 retries fail the subscription is canceled. `payment.failed` fires alongside this event with the charge failure details (`failureCode`, `failureMessage`). Use `subscription.past_due` to drive your access state and `payment.failed` to drive recovery messaging. First-checkout card declines do NOT trigger this event — a subscription that was never paid moves back to `pending_payment` instead. # subscription.pause_revoked (/docs/webhooks/subscription-pause-revoked) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The paused subscription ID. - `customerId` (string) — The customer ID for the paused subscription. - `status` ("active" | "trialing") — Current subscription status. ```json { "event": "subscription.pause_revoked", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "status": "active" } } ``` # subscription.pause_scheduled (/docs/webhooks/subscription-pause-scheduled) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The paused subscription ID. - `customerId` (string) — The customer ID for the paused subscription. - `status` ("active" | "trialing") — Current subscription status. - `mode` ("period\_end") — The scheduled pause mode. - `effectiveAt` (string) — When the pause becomes or became effective. - `resumeAt` (string | null) — When automatic resume is scheduled, or null when indefinite. ```json { "event": "subscription.pause_scheduled", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "status": "active", "mode": "period_end", "effectiveAt": "2026-09-01T00:00:00.000Z", "resumeAt": "2026-09-15T00:00:00.000Z" } } ``` Keep access enabled until `effectiveAt`. # subscription.pause_updated (/docs/webhooks/subscription-pause-updated) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The paused subscription ID. - `customerId` (string) — The customer ID for the paused subscription. - `status` ("active" | "trialing" | "paused") — Current subscription status. - `effectiveAt` (string) — When the pause becomes or became effective. - `resumeAt` (string | null) — When automatic resume is scheduled, or null when indefinite. ```json { "event": "subscription.pause_updated", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "status": "active", "effectiveAt": "2026-09-01T00:00:00.000Z", "resumeAt": null } } ``` # subscription.paused (/docs/webhooks/subscription-paused) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The paused subscription ID. - `customerId` (string) — The customer ID for the paused subscription. - `status` ("paused") — The subscription status after pausing. - `mode` ("immediate" | "period\_end") — How the pause became effective. - `effectiveAt` (string) — When the pause becomes or became effective. - `resumeAt` (string | null) — When automatic resume is scheduled, or null when indefinite. ```json { "event": "subscription.paused", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "status": "paused", "mode": "period_end", "effectiveAt": "2026-09-01T00:00:00.000Z", "resumeAt": "2026-09-15T00:00:00.000Z" } } ``` Revoke access when this event arrives. # subscription.plan_change_revoked (/docs/webhooks/subscription-plan-change-revoked) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `status` (string) — Current status — the subscription stays usable. - `currentPlan` (WebhookPlanRef) — The plan currently in effect (id and name). - `revokedPlan` (WebhookPlanRef) — The previously scheduled plan that will no longer take effect (id and name). - `billingInterval` (string | null) — The current billing interval. - `revokedBillingInterval` (string | null) — The previously scheduled billing interval, if the revoked change included one. ```json { "event": "subscription.plan_change_revoked", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "status": "active", "currentPlan": { "id": "pln_pro", "name": "Pro" }, "revokedPlan": { "id": "pln_starter", "name": "Starter" }, "billingInterval": "monthly", "revokedBillingInterval": null } } ``` ## When this fires A subscription can only have one pending scheduled change. When a new downgrade or interval change is scheduled while another one is still pending, the old one is replaced: 1. `subscription.plan_change_revoked` fires with the plan that will no longer take effect (`revokedPlan`). 2. `subscription.plan_change_scheduled` fires with the new target plan. Scheduling the exact same change again is a no-op and does not fire this event. The subscription's current plan and access are unaffected — only the pending change moves. # subscription.plan_change_scheduled (/docs/webhooks/subscription-plan-change-scheduled) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `status` (string) — Current status — the subscription stays usable. - `currentPlan` (WebhookPlanRef) — The plan currently in effect (id and name). - `scheduledPlan` (WebhookPlanRef) — The plan that takes effect at effectiveAt (id and name). - `billingInterval` (string | null) — The current billing interval. - `scheduledBillingInterval` (string | null) — The new billing interval, if the change includes one. Null when only the plan changes. - `effectiveAt` (string) — ISO 8601 datetime when the change executes (the billing period end). ```json { "event": "subscription.plan_change_scheduled", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "status": "active", "currentPlan": { "id": "pln_pro", "name": "Pro" }, "scheduledPlan": { "id": "pln_starter", "name": "Starter" }, "billingInterval": "monthly", "scheduledBillingInterval": null, "effectiveAt": "2026-04-25T00:00:00.000Z" } } ``` ## Scheduled plan change lifecycle Interval direction takes precedence: a shorter interval is scheduled for the end of the paid period and a longer interval changes immediately. When the interval is unchanged, Plan Groups use `sortOrder`: lower-ordered plans are scheduled and higher-ordered plans change immediately. Paid-to-free changes are always scheduled. | Moment | Event | What to do | | ------------------------------ | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | Downgrade requested | `subscription.plan_change_scheduled` | Show "changing to {scheduledPlan.name} on {effectiveAt}". Keep current plan access. | | A different change replaces it | `subscription.plan_change_revoked` + `subscription.plan_change_scheduled` | Update the notice to the new target plan. | | Billing period ends | `subscription.plan_changed` | Apply the new plan's entitlements. | Immediate upgrades skip this event entirely — they fire `subscription.plan_changed` right away. # subscription.plan_changed (/docs/webhooks/subscription-plan-changed) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `previousPlan` (WebhookPlanRef) — The previous plan (id and name). - `currentPlan` (WebhookPlanRef) — The new plan (id and name). - `billingInterval` (string | null) — The billing interval (monthly, yearly). - `credit` (number) — Prorated credit in cents from the previous plan. - `charge` (number) — Prorated charge in cents for the new plan. - `totalCharged` (number) — Total amount charged in cents. ```json { "event": "subscription.plan_changed", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "previousPlan": { "id": "pln_starter", "name": "Starter" }, "currentPlan": { "id": "pln_pro", "name": "Pro" }, "billingInterval": "monthly", "credit": 1500, "charge": 4900, "totalCharged": 3400 } } ``` # subscription.reactivated (/docs/webhooks/subscription-reactivated) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `status` (string) — Always "active" for this event. Restore access here. - `currentPeriodStart` (string, optional) — ISO 8601 start of the new billing period, anchored to the reactivation date. - `currentPeriodEnd` (string, optional) — ISO 8601 end of the new billing period. - `name` (string | null) — Optional custom name for the subscription. - `invoiceId` (string) — The fresh reactivation invoice ID. - `invoiceNumber` (string) — The human-readable invoice number. - `invoiceTotal` (number) — Invoice total in cents (100 = $1.00). - `invoiceCurrency` (string) — The invoice currency code. - `provider` ("stripe" | "commet" | "dlocal") — The payment provider that processed the reactivation charge: stripe, commet, or dlocal. ```json { "event": "subscription.reactivated", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "status": "active", "currentPeriodStart": "2026-05-10T00:00:00.000Z", "currentPeriodEnd": "2026-06-10T00:00:00.000Z", "name": "Acme Corp", "invoiceId": "inv_q7r8s9", "invoiceNumber": "INV-0051", "invoiceTotal": 9900, "invoiceCurrency": "usd", "provider": "stripe" } } ``` ## When this fires A canceled subscription was reactivated through `reactivate` and the reactivation charge succeeded. Commet generates a fresh invoice, charges the saved payment method, and sets the status back to `active` with a billing period anchored to the reactivation date. The subscription returns to `active` at the same time, so `customer.state_changed` also fires. Restore access on either event. This is distinct from two similar events. `subscription.activated` fires on a subscription's first activation. `payment.recovered` fires when a `past_due` subscription is recovered — that path keeps the original invoice and billing anchor, while reactivation issues a new invoice and resets the anchor to now. Reactivation requires the plan to still be available in the subscription's currency. If the reactivation charge declines, no event fires and the API returns a `recoveryUrl` the customer can use to add a new card and pay. # subscription.resume_failed (/docs/webhooks/subscription-resume-failed) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The paused subscription ID. - `customerId` (string) — The customer ID for the paused subscription. - `status` ("paused") — The unchanged subscription status. - `invoiceId` (string) — The outstanding resume invoice ID. - `failedAt` (string) — When the resume charge failed. ```json { "event": "subscription.resume_failed", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "status": "paused", "invoiceId": "inv_q7r8s9", "failedAt": "2026-09-15T12:30:00.000Z" } } ``` The subscription remains paused and without access. # subscription.resumed (/docs/webhooks/subscription-resumed) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The paused subscription ID. - `customerId` (string) — The customer ID for the paused subscription. - `status` ("active" | "trialing") — The restored subscription status. - `mode` ("immediate" | "period\_end") — The completed pause mode. - `resumedAt` (string) — When access was restored. - `invoiceId` (string | null) — The resume invoice ID, or null when no charge was required. ```json { "event": "subscription.resumed", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "status": "active", "mode": "period_end", "resumedAt": "2026-09-15T12:30:00.000Z", "invoiceId": "inv_q7r8s9" } } ``` Restore access only after this event. # subscription.updated (/docs/webhooks/subscription-updated) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `status` (string) — Current status. When cancellation is scheduled, this is still "active" — the subscription remains usable until endDate. - `canceledAt` (string, optional) — ISO 8601 datetime when cancellation was requested. Present when cancellation is scheduled, null otherwise. - `cancelReason` (string | null) — The reason for cancellation, if provided. - `endDate` (string, optional) — ISO 8601 datetime when the subscription will end. Present when cancellation is scheduled — this is the date access should be revoked (via subscription.canceled). ```json { "event": "subscription.updated", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "status": "active", "canceledAt": "2026-04-20T10:15:00.000Z", "cancelReason": "Too expensive", "endDate": "2026-04-25T00:00:00.000Z" } } ``` ## Detecting scheduled cancellation When a customer cancels, `subscription.updated` fires with `status: "active"` but `canceledAt` and `endDate` set. This tells you the subscription is still usable but ending soon. Show a notice like "your subscription will end on {endDate}" in your UI. Access should only be revoked when you receive `subscription.canceled` (status: `"canceled"`). # trial.checkout_ready (/docs/webhooks/trial-checkout-ready) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `planName` (string) — The plan name. - `trialDays` (number) — The length of the trial in days. - `checkoutUrl` (string) — The hosted checkout URL to share with the customer. ```json { "event": "trial.checkout_ready", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "planName": "Pro", "trialDays": 14, "checkoutUrl": "https://pay.commet.co/checkout/tok_9f8e7d6c" } } ``` ## When this fires When a subscription with a trial is created and the customer still needs to add a payment method, Commet generates a checkout link that starts the trial once completed and fires this event as soon as the link is ready. Completing this checkout saves the card and fires `trial.started`; the customer is not charged until the trial ends. Paid (non-trial) checkouts fire `checkout.ready` instead. Use the `checkoutUrl` and `trialDays` to drive your own "start your X-day trial" messaging. # trial.converted (/docs/webhooks/trial-converted) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `status` (string) — Always "active" for this event. - `planId` (string) — The plan ID the customer converted to. - `planName` (string) — The plan name. ```json { "event": "trial.converted", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "status": "active", "planId": "pln_pro_monthly", "planName": "Pro" } } ``` ## When this fires A customer on a trial changes plan before the trial runs out. The trial ends immediately, the full new plan price is charged (no proration credit — trials are free), and the subscription becomes `active`. `subscription.plan_changed` fires alongside this event with the charge details. Use `trial.converted` for conversion analytics and lifecycle messaging; use `subscription.plan_changed` for entitlement updates. Trials that simply run out fire `trial.expired` instead — that is the natural trial-to-paid transition. # trial.expired (/docs/webhooks/trial-expired) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `status` (string) — Current status — "active" once the billing cycle has activated the subscription. - `planId` (string) — The plan ID. - `planName` (string) — The plan name. - `trialEndsAt` (string) — ISO 8601 datetime when the trial ended. ```json { "event": "trial.expired", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "status": "active", "planId": "pln_pro_monthly", "planName": "Pro", "trialEndsAt": "2026-04-08T00:00:00.000Z" } } ``` ## When this fires The trial reached `trialEndsAt`. The billing cycle activates the subscription (`status: "active"`), starts the first regular billing period, and generates the first invoice — `invoice.created` and payment events follow as the charge is processed. Access does not change at this moment: the customer already had full access while trialing. A retryable first-charge failure moves the subscription to `past_due` recovery; a missing payment method or a customer-action-required outcome leaves it in `pending_payment` until checkout is completed. # trial.started (/docs/webhooks/trial-started) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `status` (string) — Always "trialing" for this event. - `planId` (string) — The plan ID. - `planName` (string) — The plan name. - `trialEndsAt` (string) — ISO 8601 datetime when the trial ends. ```json { "event": "trial.started", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "status": "trialing", "planId": "pln_pro_monthly", "planName": "Pro", "trialEndsAt": "2026-04-08T00:00:00.000Z" } } ``` ## Trial lifecycle Trials in Commet collect a payment method at checkout, so every trial has a clear path to revenue: | Moment | Event | What to do | | ---------------------------------- | ----------------- | ---------------------------------------- | | Checkout completes with trial days | `trial.started` | Grant full access. | | 3 days before the trial ends | `trial.will_end` | Remind the customer billing starts soon. | | Customer upgrades during the trial | `trial.converted` | Trial ends early, paid plan starts now. | | Trial runs out | `trial.expired` | Regular billing begins automatically. | Subscriptions with `status: "trialing"` have full access — treat them like `active` in your entitlement checks. # trial.will_end (/docs/webhooks/trial-will-end) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `status` (string) — Always "trialing" for this event. - `planId` (string) — The plan ID. - `planName` (string) — The plan name. - `trialEndsAt` (string) — ISO 8601 datetime when the trial will end. ```json { "event": "trial.will_end", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "status": "trialing", "planId": "pln_pro_monthly", "planName": "Pro", "trialEndsAt": "2026-04-08T00:00:00.000Z" } } ``` ## When this fires A daily scan finds trials ending within the next 3 days and emits this event once per trial. The idempotency key is derived from the subscription and the trial end date, so re-running the scan never sends a duplicate. Use it to remind the customer that billing starts on `trialEndsAt` — the single most effective moment to prevent involuntary churn and surprise charges. If the trial end date changes after this event fired (for example, the customer converts early), `trial.converted` or `trial.expired` reflects the final outcome. # usage.recorded (/docs/webhooks/usage-recorded) All webhook payloads follow a consistent top-level structure with event-specific data nested within the `data` object. - `subscriptionId` (string) — The subscription ID. - `customerId` (string) — The customer ID. Returns your externalId if you provided one when creating the customer, otherwise returns the Commet publicId. - `usageEventId` (string) — The usage event ID. - `featureCode` (string) — The feature code the usage was tracked against. - `value` (number) — The recorded quantity. For AI model events this is the total token count. - `ts` (string) — ISO 8601 timestamp of the usage event. ```json { "event": "usage.recorded", "timestamp": "2026-06-23T14:30:00.000Z", "organizationId": "8f14e45f-ceea-4e7a-9c3d-1c2b3a4d5e6f", "mode": "live", "apiVersion": "2026-08-27", "data": { "usageEventId": "evt_9f8e7d6c", "subscriptionId": "sub_1a2b3c4d", "customerId": "user_123", "featureCode": "api_calls", "value": 25, "ts": "2026-06-18T09:12:00.000Z" } } ``` ## When this fires Once per processed usage event, after the async processor persists it. This is the only **high-volume** webhook: it fires at your own ingest rate, so it is excluded from the family select-all in the dashboard and must be subscribed to explicitly. Delivery is asynchronous — events are batched through the usage pipeline, so expect seconds of delay relative to the original `POST /usage/events` call. The idempotency key is the usage event ID, so retries never duplicate. Use it to mirror usage into your own analytics store without polling. # Customer Credits (/docs/customer-credits) Customer credits are monetary adjustments for a specific customer and currency. Commet applies them FIFO before tax to eligible recurring invoices. Use customer credits for service recovery, a negotiated commercial credit, or an account adjustment. They are not plan credits and cannot be consumed by usage events. ## Grant credit Open a customer in **Customers** to review their billing context. Grant credit through the API when the adjustment originates in your application or support workflow: ```typescript const credit = await commet.customers.createCredit( { id: 'cus_01J...', amount: 2500, currency: 'usd', reason: 'Service recovery credit', }, { idempotencyKey: 'credit-acme-2026-08-16' }, ) ``` `amount` uses the currency's smallest unit. A USD amount of `2500` is USD 25.00. Always send a reason; it becomes part of the credit's audit trail. ## Inspect remaining credit ```typescript const credits = await commet.customers.listCredits({ id: 'cus_01J...' }) ``` Credits only apply to invoices in the same currency. If a customer has USD and BRL subscriptions over time, each currency keeps an independent balance. ## Revoke unused credit ```typescript const revocation = await commet.customers.revokeCredit( { id: 'cus_01J...', creditId: 'crd_01J...' }, { idempotencyKey: 'revoke-credit-acme-2026-08-16' }, ) ``` Revocation removes only the unallocated remainder. It does not rewrite invoices that already consumed part of the grant. For product allowances, use [Credits and Credit Packs](/docs/credit-packs). For access without billing, use [Plan Grants](/docs/plan-grants). # Customer Portal (/docs/customer-portal) Self-service page where customers manage their subscription, view usage, change plans, and purchase credits. Automatically created with every subscription. ## What customers can do - View current subscription and billing cycle - See usage across all features - Upgrade or downgrade plans (requires a [Plan Group](/docs/plan-groups)) - Purchase [Credit Packs](/docs/credit-packs), balance top-ups, and [Add-ons](/docs/add-ons) - Update payment method and billing details - View invoice history - Cancel subscription Purchases are available on active, trialing, and free plan subscriptions. Free plan customers are prompted to add a payment method on their first purchase. ## Generate a portal URL Portal URLs are not single-use — the customer can revisit the link while the session is alive. A session expires after **30 minutes** of inactivity, auto-refreshes while the customer is active, and is capped at **2 hours** from creation. Generate a fresh URL on each visit. ### TypeScript ```typescript const portal = await commet.portal.getUrl({ customerId: 'user_123' }) redirect(portal.portalUrl) ``` ### Python ```python portal = commet.portal.get_url(customer_id='user_123') redirect(portal.portal_url) ``` ### Go ```go portal, err := client.Portal.GetURL(ctx, &commet.GetPortalURLParams{ CustomerID: "user_123", }) // redirect(portal.PortalURL) ``` ### Java ```java var portal = commet.portal().getUrl(RequestPortalAccessParams.builder().customerId("user_123").build()); // redirect(portal.portalUrl()) ``` ### PHP ```php $portal = $commet->portal->getUrl(customerId: 'user_123'); redirect($portal->portalUrl); ``` ### cURL ```bash curl -X POST https://commet.co/api/v1/portal/request-access \ -H "x-api-key: $COMMET_API_KEY" \ -H "Content-Type: application/json" \ -d '{"customerId": "user_123"}' ``` You can identify customers by `customerId` (Commet ID or your external ID) or `email`. ## Next.js helper `@commet/next` (Node.js only) provides a one-line route handler that generates portal URLs and redirects automatically. ```typescript title="app/api/commet/portal/route.ts" import { CustomerPortal } from '@commet/next' export const GET = CustomerPortal({ apiKey: process.env.COMMET_API_KEY!, getCustomerId: async (req) => { return 'user_123' }, }) ``` ```tsx Manage Billing ``` ## Custom domains Serve the portal from your own domain (e.g., `billing.acme.com`) instead of `commet.co`. Go to **Settings** → **Custom Domains**, add the domain, and point the DNS records shown — portal links use your domain once it verifies. Each custom domain serves one surface: **portal** or **checkout**. Checkout pages support custom domains the same way. ## Related - [Manage Subscriptions](/docs/manage-subscriptions) - [Plan Groups](/docs/plan-groups) - [Credit Packs](/docs/credit-packs) # Manage Customers (/docs/manage-customers) Install the Commet Skill so your coding agent can integrate customers using the current API contract. ```bash npx skills add commet-labs/skills --skill commet ``` A customer represents the business or person you bill. Each customer can have one active subscription relationship at a time. ## Dashboard Navigate to **Customers** to search customers, inspect billing state, assign a plan, and manage the subscription. ## Create a customer **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const customer = await commet.customers.create({ email: "user@example.com" }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") customer = commet.customers.create(email="user@example.com") ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() customer, err := client.Customers.Create(ctx, &commet.CreateCustomerParams{ Email: "user@example.com", }) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.CreateCustomerParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var customer = commet.customers().create( CreateCustomerParams.builder("user@example.com").build() ); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $customer = $commet->customers->create(email: 'user@example.com'); ``` `create` is idempotent when you supply your own `id`: a retry with the same ID returns the existing customer. Use: - `id` for the stable identifier from your application. - `email` for billing communication. - `address` and `taxDocument` for billing and tax context. - `metadata` for application-specific values that do not control billing behavior. ## Create customers in batch **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const customerBatch = await commet.customers.createBatch({ customers: [{ email: "user@example.com" }] }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") customer_batch = commet.customers.create_batch(customers=[{"email": "user@example.com"}]) ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() customerBatch, err := client.Customers.CreateBatch(ctx, &commet.BatchCreateCustomersParams{ Customers: []commet.BatchCreateCustomersParamsCustomersItem{{ Email: "user@example.com", }}, }) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.models.BatchCreateCustomersParamsCustomersItem; import co.commet.params.BatchCreateCustomersParams; import java.util.List; var commet = Commet.builder().apiKey("ck_xxx").build(); var customerBatch = commet.customers().createBatch( BatchCreateCustomersParams.builder(List.of(new BatchCreateCustomersParamsCustomersItem("user@example.com", null, null, null, null, null, null, null))).build() ); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $customerBatch = $commet->customers->createBatch( customers: [['email' => 'user@example.com']], ); ``` A batch accepts up to 100 customers and returns successful and failed items independently. ## Retrieve a customer **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const customer = await commet.customers.get({ id: "cus_xxx" }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") customer = commet.customers.get("cus_xxx") ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() customer, err := client.Customers.Get(ctx, "cus_xxx") if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; var commet = Commet.builder().apiKey("ck_xxx").build(); var customer = commet.customers().get("cus_xxx"); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $customer = $commet->customers->get(id: 'cus_xxx'); ``` ## Update a customer **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const customer = await commet.customers.update({ id: "cus_xxx" }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") customer = commet.customers.update("cus_xxx") ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() customer, err := client.Customers.Update(ctx, "cus_xxx", nil) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.UpdateCustomerParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var customer = commet.customers().update( "cus_xxx", UpdateCustomerParams.builder().build() ); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $customer = $commet->customers->update(id: 'cus_xxx'); ``` Updates use `PATCH` semantics: send only the fields that should change. ## List customers **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const response = await commet.customers.list(); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") customers_list_result = commet.customers.list() ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() customersListResult, err := client.Customers.List(ctx, nil) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.ListCustomersParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var customersListResult = commet.customers().list(ListCustomersParams.builder().build()); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $customersListResult = $commet->customers->list(); ``` Lists return `{ object, data, hasMore, nextCursor }`. Pass `nextCursor` back as `cursor` to request the next page. Singular customer operations return the customer directly. See the [Customers API reference](/docs/api-reference/customers/list-customers) for the exact generated request and response fields. ## Related - [Customer Portal](/docs/customer-portal) - [Manage Subscriptions](/docs/manage-subscriptions) - [Offers & Pricing changelog and migration checklist](/changelog/offers-and-pricing) # Acceptable Use Policy (/docs/acceptable-use) Commet acts as Merchant of Record for transactions processed through Commet-managed payment connections. This policy defines which products we can support on that payment rail. Customer-owned payment connections remain subject to their provider's own rules and do not make a product eligible for Commet-managed processing. > **Note** > > **Have questions about your specific use case?** [Contact our support team](mailto:help@commet.co) before starting your integration. ## Basic eligibility requirements Every product sold through Commet-managed processing must: 1. Be lawful, transparent, and consistent with payment-provider requirements 2. Be sold directly by the business that owns or holds the rights required to distribute it 3. Be a working digital product delivered automatically or with minimal human involvement 4. Match the product, pricing, ownership, and fulfillment described in the merchant's submission and website Ordinary customer support does not make an automated digital product a human-delivered service. ## Allowed categories ### Software, SaaS, and automated digital services Mobile apps, desktop software, SaaS, AIaaS, developer tools, hosting, analytics, dashboards, website information services, and other electronically supplied services are allowed when the product is standardized and automatically delivered. This includes SEO and marketing software for analytics, diagnostics, content planning, approval, or scheduled publishing. Serving a financial, medical, or other regulated industry does not by itself turn software into the regulated activity. The software must not perform any prohibited activity listed below. ### Digital content and downloads Code repositories, templates, eBooks, PDFs, magazines, newsletters, icons, fonts, design resources, photos, videos, audio files, video games, and owned digital directories or information databases are allowed when the seller holds the necessary rights. ### Premium access and self-service education Subscription content, gated digital experiences, private repositories, community access, and self-service online courses are allowed when access and fulfillment are automated. Live one-to-one coaching and instructor-delivered services are not included. ## Categories requiring manual review These categories are not confirmed policy violations. They require a person to verify the identified fact before approval. ### Pre-orders and paid waitlists Pre-orders and paid waitlists require evidence of a defined digital product, delivery schedule, refund terms, and a credible history or plan for fulfillment. ### Unclear or incomplete business evidence A business requires review when its submission and available website evidence do not establish what the customer buys, who owns it, whether fulfillment is automated, or whether the website describes the same offering. A collection problem alone is not a reason to deny the business. ## Products and business models Commet does not support The categories below cannot use Commet-managed processing. Some are lawful businesses that fall outside our Merchant of Record product; others are prohibited by payment-provider, network, legal, or risk requirements. ### Physical goods, professional services, and live events - Physical goods or anything requiring shipping or physical fulfillment - Human-delivered professional services, including consulting, accounting, tax preparation, marketing, design, development, managed services, or tech support - Live one-to-one coaching, instructor-delivered services, staffing, or recruitment services - Tickets or access to live in-person events Software used by professionals remains allowed when the customer buys the software rather than the professional's work. ### Marketplaces, third-party sales, and advertising inventory - Marketplaces, job boards, or creator platforms that collect payment for third-party sellers, workers, or creators - Unauthorized resale or distribution, including software licenses and third-party game items or virtual currency - Advertising placements sold in websites, email, or social media - Payment facilitation, aggregation, or receiving settlement proceeds for products or services supplied by another merchant ### Travel, donations, fundraising, dating, and file sharing - Travel reservations, travel clubs, timeshares, charter travel, airlines, or cruises - Donations, charitable contributions, fundraising, crowdfunding, raffles, or sweepstakes - Dating and matchmaking platforms - Cyberlockers and file-sharing services ### Financial products, cryptocurrency, and stored value - Investment strategies, trading signals, wealth management, tax advice sold as a service, brokerage, funded prop trading, or trading services - Lending, bank-account funding, money transmission, remittances, currency exchange, escrow, neobanks, custody, or transaction facilitation - Cryptocurrency exchanges, wallets, mining, staking, ICOs, NFT products, or secondary NFT sales - Third-party gift cards, prepaid cards, monetary credits, stored value, or game currency Software that only analyzes information or provides operational tools does not become a financial service unless it executes, controls, intermediates, or advises on the regulated activity. Credits usable only for the seller's own software are not third-party stored value. ### Regulated and age-restricted products or services - Gambling, betting, lotteries, or wagering - Weapons, firearms, or regulated weapon components - Alcohol, tobacco, vaping products, cannabis, CBD, or illegal drugs - Pharmaceuticals, online pharmacies, prescription products, regulated medical devices, telemedicine, unsafe nutraceuticals, medical advice, or weight-loss products making medical claims - Any product or service requiring a license or regulatory approval that the business cannot document or that our provider does not support ### Unsolicited marketing, deceptive engagement, and data abuse - Automated unsolicited outreach or telemarketing - Bulk SMS, WhatsApp, email, or other messaging that risks spam - Automated mass submission, fake engagement, fake reviews, or traffic sales - Selling, distributing, or scraping personal contact data without authorization - Services that undermine user privacy or data security Generating content, analyzing marketing data, or scheduling approved posts is not prohibited by itself. ### Deceptive, abusive, or nonfunctional products - Pyramid schemes, multi-level marketing, or get-rich-quick schemes - Misleading identity, pricing, trial, testimonial, fulfillment, or earnings claims - High-pressure or predatory sales practices - Products that are nonfunctional, cannot deliver what is sold, or provide no meaningful value - Document falsification, card testing, chargeback evasion, or transactions without a real product or service ### Illegal activity and intellectual-property violations - Products or services that violate laws in the business's jurisdiction or target markets - Counterfeit goods or infringement of copyright, trademarks, patents, privacy, or other third-party rights - Products the seller does not own or have permission to distribute - Services designed to breach or bypass another platform's rules - Products or services that exploit, endanger, or unlawfully target minors ### Adult content and sexually oriented services - Pornography or sexually explicit media designed for sexual gratification, including AI-generated content meeting those criteria - Prostitution, escorts, sexual live chat, fetish services, or platforms primarily selling that content or those services Viewer ratings, mature themes, romance, adult situations, or genre labels do not establish prohibited adult content by themselves. ### Malware, evasion tools, and unauthorized media - Malware, spyware, viruses, suspicious remote-support tools, or privacy-invasive monitoring software - Cheating tools, hacks, ban-bypass tools, IP or API cloaking, or services designed to evade enforcement - IPTV or unauthorized streaming services - eSIM or telecommunication services ## Provider requirements This policy incorporates applicable payment-provider requirements, including [Stripe's prohibited and restricted businesses](https://stripe.com/legal/restricted-businesses) and [dLocal's restricted and prohibited activities](https://www.dlocal.com/legal/terms-and-conditions/). Provider approval for a restricted category must be documented in writing; an internal Commet approval cannot replace it. The lists above are representative rather than exhaustive. Commet may review, suspend, or stop processing when new evidence shows illegal, misleading, unsupported, or materially different activity. ## Related - [Merchant of Record](/docs/merchant-of-record) — How Commet handles taxes and compliance as your MoR - [Finance Overview](/docs/finance-overview) — Balances, payouts, and transaction history # Finance Overview (/docs/finance-overview) The finance module tracks how money flows from customer payments to your bank account — balance tracking, payouts, and a complete transaction history. ## Balance states The Payouts page shows your money in three states. | State | Description | Example | | ----------------- | ------------------------------- | --------------------------- | | **Available** | Ready to withdraw | $3,400 available for payout | | **Pending** | Transactions still processing | $1,200 clearing | | **Total payouts** | Total sent to your bank account | $10,000 paid out to date | ## Payouts A [payout](/docs/payouts) sends your available balance to your bank account. Account verification is required before your first one. Once verified, request payouts of $10 or more. Money reaches your bank account in up to 7 business days. ## Transaction history Every payment, payout, and status change is recorded as a transaction. Use the finance dashboard to view incoming payments from subscriptions and credit purchases, outgoing payouts, and real-time status updates. ## Related - [Account Verification](/docs/payouts-verification) — Get verified to withdraw money - [Merchant of Record](/docs/merchant-of-record) — How Commet handles taxes and compliance # Local Currency Taxes (/docs/local-currency-taxes) When customers pay in their local currency, their bank or card issuer may add country-specific taxes or surcharges on top of the amount Commet charges. These charges are collected by the customer's bank on behalf of local tax authorities — Commet does not apply them, does not receive them, and cannot refund them. ## Why this happens Commet acts as the [Merchant of Record](/docs/merchant-of-record), so even when a customer pays in their own currency, the transaction may be processed as a cross-border purchase from a foreign merchant. Some countries require card issuers to collect taxes or tax perceptions on these transactions at the moment of payment. The result: the total on the customer's bank statement can be higher than the price shown at checkout. The tax appears as a separate line item charged by the bank, not as part of the Commet charge. ## Where it applies | Country | What customers may see | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | **Argentina** | Tax perceptions on cross-border card payments, historically up to 30% (e.g. "impuesto PAÍS" and income tax perceptions), applied by the card issuer | | **Other countries** | Cross-border transaction fees or local tax withholdings, depending on the customer's bank and local regulation | Rates and rules change frequently and vary by bank, card type, and local regulation. The customer's bank determines the final amount. > **Note** > > These charges apply per transaction and are independent of the currency configured in your plan. Paying in local currency does not by itself exempt the customer from cross-border taxes. ## What this means for your customers - **It is not a duplicate charge.** The Commet charge and the bank's tax perception are separate line items on the statement. - **Commet invoices show only the price you configured.** Bank-applied taxes never appear on the Commet invoice or receipt. - **Refunds don't include bank taxes.** If you refund a payment, Commet returns the amount it charged. Tax perceptions are refunded (or credited) by the customer's bank or tax authority under local rules. If a customer asks about an unexpected extra charge, direct them to their card issuer — the bank can confirm which tax was applied and how to claim it back where local rules allow it. ## Related - [Regional Prices](/docs/regional-prices) — Let customers pay in their own currency - [Merchant of Record](/docs/merchant-of-record) — How Commet handles taxes and compliance on your behalf - [Supported Countries](/docs/supported-countries) — Where Commet operates # Merchant of Record (/docs/merchant-of-record) A Merchant of Record (MoR) is the legal entity that sells your product to the end customer. Commet acts as the MoR, taking responsibility for global sales taxes, refunds, disputes, and compliance so you can focus on building your product. ## PSP vs MoR | Aspect | PSP (e.g. Stripe) | MoR (e.g. Commet) | | ------------------ | ------------------------------ | ----------------------- | | Tax handling | You handle it | Platform handles it | | Refunds & disputes | You handle it | Platform handles it | | API complexity | Low-level, flexible | High-level, opinionated | | Fees | Lower per transaction | Higher per transaction | | Control | Full control over payment flow | Managed payment flow | ## What should you choose **Choose a PSP if** you're already integrated with Stripe, comfortable handling international taxes yourself, or want full control over your payment flow. **Choose Commet if** you want to go live today without worrying about tax registrations, need a billing tool your whole team can use, or want subscription and pricing management built in. ## When Commet is the MoR Commet is the Merchant of Record when you run on Commet's payment rail — the default. If you connect your own Stripe or dLocal account instead, you remain the merchant and Commet acts as your billing layer on top. ## Use Commet alongside your PSPs You can use both rails in the same organization. Commet is the MoR for countries routed to the Commet provider. Countries routed to your connected Stripe or dLocal account are charged through that provider, under your own merchant identity. Commet decides which provider to use from the country route configured in **Settings → Payments**. If a checkout does not include a country, Commet uses the organization's default provider. Once a customer has a saved payment method or a subscription has been stamped with a connection, recurring charges continue through that provider so the payment method is not moved silently between accounts. See [Payment Providers](/docs/payment-providers) to connect your accounts and [Payment Orchestration](/docs/payment-orchestration) to configure routing rules and defaults. ## Our vision We believe the best companies of the future will be small teams of 5 to 30 people. Technology enables small teams to build incredible products, but monetization complexity shouldn't be a barrier. Commet exists to be the simplest, developer-preferred tool for monetization. We don't aim to solve every use case — we want to be the tool small teams choose when they want to monetize simply, globally, and without hassle. ## Related - [Finance Overview](/docs/finance-overview) — Balances, payouts, and transaction history - [Acceptable Use Policy](/docs/acceptable-use) — What products and services can be sold through Commet - [Supported Countries](/docs/supported-countries) — 112 countries where Commet operates - [Payment Providers](/docs/payment-providers) — Connect Commet, Stripe, and dLocal payment rails # Account Verification (/docs/payouts-verification) Organizations using their own payment connection can start accepting payments immediately. Commet-managed payment processing remains unavailable until merchant review and identity verification are complete. > **Note: Live organizations only** > > Payout verification is unavailable in sandbox. Switch to your live organization before submitting business, identity, document, or bank information. ## How it works Click **Verify Account** in the finance section to start the one-time KYC process — a multi-step form in the Commet dashboard: business info → personal info → identity document → bank account. Commet submits it to the payment provider for review, then wait for approval — usually a few days. Your country is set in the first step and locks once the verification is submitted to the provider. Contact support to correct it after that. ## What you'll need - **Business information**: Company name, address, registration number - **Personal information**: For business owners and directors - **Identity document**: Government-issued ID for the account holder - **Bank account details**: Where you want payouts sent ## Security Verification data is collected in the Commet dashboard and submitted to the payment provider for KYC review. Full bank account numbers are never returned by the API — only the last 4 digits. > **Note** > > Approval can take several days. You'll be notified when your account is ready. ## Manage payouts with the SDK After completing verification in the Commet dashboard, you can add destination bank accounts and withdraw your balance programmatically. ### Verification API `POST /api/v1/payouts/verification` is deprecated and no longer accepts or processes KYC data. Authenticated requests return `410 Gone` with the `endpoint_deprecated` error code. > **Warning: Complete verification in the dashboard** > > Do not send business, identity, document, or bank information to this endpoint. Use the one-time verification flow in your live organization's finance section. ```json { "error": { "code": "endpoint_deprecated", "message": "The payout verification API is deprecated. Complete KYC in the Commet dashboard." } } ``` ### Add a bank account Add an additional destination bank account to an existing payout account. Country and currency are resolved from your organization. The full account number is never returned — only `last4`. ### TypeScript ```typescript const account = await commet.payouts.addBankAccount({ accountNumber: '000123456789', accountHolderName: 'Jane Doe', routingNumber: '110000000', accountType: 'checking', setDefault: true, }) ``` ### Python ```python account = commet.payouts.add_bank_account( account_number="000123456789", account_holder_name="Jane Doe", routing_number="110000000", account_type="checking", set_default=True, ) ``` ### Go ```go account, err := client.Payouts.AddBankAccount(ctx, &commet.AddPayoutBankAccountParams{ AccountNumber: "000123456789", AccountHolderName: "Jane Doe", }) ``` ### Java ```java var account = commet.payouts().addBankAccount( AddPayoutBankAccountParams.builder("000123456789", "Jane Doe") .routingNumber("110000000") .accountType("checking") .setDefault(true) .build() ); ``` ### PHP ```php $account = $commet->payouts->addBankAccount( accountNumber: '000123456789', accountHolderName: 'Jane Doe', routingNumber: '110000000', accountType: 'checking', setDefault: true, ); ``` ### cURL ```bash curl -X POST https://commet.co/api/v1/payouts/bank-accounts \ -H "x-api-key: $COMMET_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountNumber": "000123456789", "accountHolderName": "Jane Doe", "routingNumber": "110000000", "accountType": "checking", "setDefault": true }' ``` ### Request a payout Withdraw available balance to your verified payout account. `amount` is in cents (USD, minimum `1000` = $10). The payout is created in `pending` and settles to `paid` asynchronously as provider webhooks arrive. ### TypeScript ```typescript const payout = await commet.payouts.request({ amount: 50000, description: 'March payout', }) ``` ### Python ```python payout = commet.payouts.request(amount=50000, description="March payout") ``` ### Go ```go description := "March payout" payout, err := client.Payouts.Request(ctx, &commet.RequestPayoutParams{ Amount: 50000, Description: &description, }) ``` ### Java ```java var payout = commet.payouts().request( RequestPayoutParams.builder(50000).description("March payout").build() ); ``` ### PHP ```php $payout = $commet->payouts->request(amount: 50000, description: 'March payout'); ``` ### cURL ```bash curl -X POST https://commet.co/api/v1/payouts \ -H "x-api-key: $COMMET_API_KEY" \ -H "Content-Type: application/json" \ -d '{"amount": 50000, "description": "March payout"}' ``` ## Related - [Finance Overview](/docs/finance-overview) — Balances, payouts, and transaction history - [Merchant of Record](/docs/merchant-of-record) — How Commet handles taxes and compliance # Request a Payout to Your Bank (/docs/payouts) A payout is when Commet sends your available balance to your bank account. > Payouts are available only in live organizations. Sandbox organizations can test charges and billing, but cannot verify an account or move funds out. > Withdraw your available balance to your bank account in up to 7 business days. ## How it works Verify your account, then request a payout of $10 or more from your available balance. Money reaches your bank account in up to 7 business days. ## The facts - Minimum payout: **$10**. - Money reaches your bank account in **up to 7 business days**. - **No payout fee.** - Account verification is required before your first payout. Only your **Available** balance can be paid out. See the balance states in [Finance Overview](/docs/finance-overview). ## View in Dashboard Navigate to **Payouts**. Add your bank accounts, then click **Withdraw funds** to request a payout from your available balance. ## Limitations - Minimum payout is **$10**. - Only the **Available** balance can be withdrawn — Pending funds cannot. - Account verification is required before the first payout. - Money reaches your bank account in **up to 7 business days**. ## Related - [Account Verification](/docs/payouts-verification) — Get verified to withdraw money - [Finance Overview](/docs/finance-overview) — Balances, payouts, and transaction history - [Merchant of Record](/docs/merchant-of-record) — How Commet handles taxes and compliance # Supported Countries (/docs/supported-countries) Commet supports businesses in 112 countries across all major regions. Your country is set during onboarding and determines your payout currency and compliance requirements. ## Americas Argentina, Bolivia, Brazil, Canada, Chile, Colombia, Costa Rica, Dominican Republic, Ecuador, El Salvador, Guatemala, Guyana, Jamaica, Mexico, Panama, Paraguay, Peru, Trinidad & Tobago, United States, Uruguay ## Europe Albania, Austria, Belgium, Bosnia & Herzegovina, Bulgaria, Croatia, Cyprus, Czechia, Denmark, Estonia, Finland, France, Germany, Greece, Hungary, Iceland, Ireland, Italy, Latvia, Liechtenstein, Lithuania, Luxembourg, Malta, Moldova, Montenegro, Netherlands, North Macedonia, Norway, Poland, Portugal, Romania, San Marino, Serbia, Slovakia, Slovenia, Spain, Sweden, Switzerland, United Kingdom ## Asia & Pacific Armenia, Australia, Azerbaijan, Bahrain, Bangladesh, Bhutan, Brunei, Cambodia, Hong Kong, India, Indonesia, Israel, Japan, Jordan, Kazakhstan, South Korea, Kuwait, Laos, Malaysia, Mongolia, New Zealand, Oman, Pakistan, Philippines, Qatar, Singapore, Sri Lanka, Taiwan, Thailand, United Arab Emirates, Vietnam ## Africa Algeria, Angola, Benin, Botswana, Côte d’Ivoire, Egypt, Ethiopia, Gabon, Gambia, Ghana, Kenya, Mauritius, Morocco, Mozambique, Namibia, Niger, Nigeria, Rwanda, Senegal, South Africa, Tanzania, Tunisia ## Related - [Finance Overview](/docs/finance-overview) — How money flows through Commet - [Account Verification](/docs/payouts-verification) — Get verified to withdraw money # Integrate with Encore (/docs/integrate-with-encore) Install the Commet Skill so your coding agent can integrate the current SDK and verify its work against the live API contract. ```bash npx skills add commet-labs/skills --skill commet ``` 1. ## Install ```bash encore app create --example=hello-world myapp cd myapp go get github.com/commet-labs/commet-go/v9 ``` 2. ## Configure Store secrets using Encore's secret manager instead of environment variables: ```bash encore secret set --type dev,local,pr,prod CommetAPIKey encore secret set --type dev,local,pr,prod CommetWebhookSecret ``` ```go title="billing/billing.go" package billing import ( commet "github.com/commet-labs/commet-go/v9" ) var secrets struct { CommetAPIKey string CommetWebhookSecret string } var client *commet.Client func initClient() error { var err error client, err = commet.New(secrets.CommetAPIKey) return err } ``` There is no environment option on the client: sandbox vs live is decided by the organization the API key belongs to. 3. ## Subscribe ```go title="billing/billing.go" import "context" type SubscribeParams struct { Email string `json:"email"` CustomerID string `json:"customer_id"` } type SubscribeResponse struct { CheckoutURL string `json:"checkout_url"` } //encore:api public method=POST path=/billing/subscribe func Subscribe(ctx context.Context, req *SubscribeParams) (*SubscribeResponse, error) { if err := initClient(); err != nil { return nil, err } _, err := client.Customers.Create(ctx, &commet.CreateCustomerParams{ Email: req.Email, ID: &req.CustomerID, }) if err != nil { return nil, err } planCode := "pro" subscription, err := client.Subscriptions.Create(ctx, &commet.CreateSubscriptionParams{ CustomerID: req.CustomerID, PlanCode: &planCode, }) if err != nil { return nil, err } checkoutURL := "" if subscription.CheckoutURL != nil { checkoutURL = *subscription.CheckoutURL } return &SubscribeResponse{ CheckoutURL: checkoutURL, }, nil } ``` 4. ## Check Access ```go title="billing/billing.go" type SubscriptionResponse struct { Status string `json:"status"` } //encore:api public method=GET path=/billing/subscription/:customerID func GetSubscription(ctx context.Context, customerID string) (*SubscriptionResponse, error) { if err := initClient(); err != nil { return nil, err } sub, err := client.Subscriptions.GetActive(ctx, &commet.GetActiveSubscriptionParams{ CustomerID: customerID, }) if err != nil { return nil, err } if sub == nil { return &SubscriptionResponse{Status: "none"}, nil } return &SubscriptionResponse{ Status: string(sub.Status), }, nil } type FeatureResponse struct { Allowed bool `json:"allowed"` } //encore:api public method=GET path=/billing/features/:feature/:customerID func CheckFeature(ctx context.Context, feature string, customerID string) (*FeatureResponse, error) { if err := initClient(); err != nil { return nil, err } result, err := client.FeatureAccess.Get(ctx, feature, &commet.GetFeatureAccessParams{ CustomerID: customerID, }) if err != nil { return nil, err } return &FeatureResponse{ Allowed: result.Allowed, }, nil } ``` 5. ## Track Usage ```go title="billing/billing.go" func float64Ptr(value float64) *float64 { return &value } type UsageParams struct { CustomerID string `json:"customer_id"` } type UsageResponse struct { Tracked bool `json:"tracked"` } //encore:api public method=POST path=/billing/usage func TrackUsage(ctx context.Context, req *UsageParams) (*UsageResponse, error) { if err := initClient(); err != nil { return nil, err } _, err := client.Usage.Track(ctx, &commet.TrackUsageParams{ CustomerID: req.CustomerID, FeatureCode: "api_calls", Value: float64Ptr(1), }) if err != nil { return nil, err } return &UsageResponse{Tracked: true}, nil } ``` Usage is aggregated and billed at end of period. 6. ## Webhooks ```go title="billing/webhooks.go" package billing import ( "io" "net/http" commet "github.com/commet-labs/commet-go/v9" ) //encore:api public raw method=POST path=/webhooks/commet func HandleWebhook(w http.ResponseWriter, r *http.Request) { rawBody, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Failed to read body", http.StatusBadRequest) return } webhooks := &commet.WebhooksResource{} payload, err := webhooks.VerifyAndParse( string(rawBody), r.Header.Get("x-commet-signature"), secrets.CommetWebhookSecret, ) if err != nil { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } switch payload["event"] { case "subscription.activated": // handle activation } w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"ok":true}`)) } ``` 7. ## Run ```bash encore run ``` ## Related - [Subscriptions](/docs/manage-subscriptions) - [Track Usage](/docs/track-usage) - [Customer Portal](/docs/customer-portal) - [SDK Reference](/docs/sdk-reference) # Integrate with Go (/docs/integrate-with-go) Install the Commet Skill so your coding agent can integrate the current SDK and verify its work against the live API contract. ```bash npx skills add commet-labs/skills --skill commet ``` 1. ## Install ```bash go get github.com/commet-labs/commet-go/v9 ``` 2. ## Configure ```bash title=".env" COMMET_API_KEY=ck_sandbox_xxx COMMET_WEBHOOK_SECRET=whsec_xxx ``` ```go title="billing/client.go" package billing import ( "log" "os" commet "github.com/commet-labs/commet-go/v9" ) var Client *commet.Client func Init() { var err error Client, err = commet.New(os.Getenv("COMMET_API_KEY")) if err != nil { log.Fatal(err) } } ``` There is no environment option on the client: sandbox vs live is decided by the organization the API key belongs to. 3. ## Subscribe ```go title="billing/handlers.go" package billing import ( "encoding/json" "net/http" commet "github.com/commet-labs/commet-go/v9" ) type subscribeRequest struct { Email string `json:"email"` CustomerID string `json:"customer_id"` } func Subscribe(w http.ResponseWriter, r *http.Request) { var req subscribeRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } _, err := Client.Customers.Create(r.Context(), &commet.CreateCustomerParams{ Email: req.Email, ID: &req.CustomerID, }) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } planCode := "pro" subscription, err := Client.Subscriptions.Create(r.Context(), &commet.CreateSubscriptionParams{ CustomerID: req.CustomerID, PlanCode: &planCode, }) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } checkoutURL := "" if subscription.CheckoutURL != nil { checkoutURL = *subscription.CheckoutURL } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"checkout_url": checkoutURL}) } ``` 4. ## Check Access ```go title="billing/handlers.go" func GetSubscription(w http.ResponseWriter, r *http.Request) { customerID := r.PathValue("customerID") sub, err := Client.Subscriptions.GetActive(r.Context(), &commet.GetActiveSubscriptionParams{ CustomerID: customerID, }) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if sub == nil { http.Error(w, "No active subscription", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"status": sub.Status}) } func CheckFeature(w http.ResponseWriter, r *http.Request) { feature := r.PathValue("feature") customerID := r.PathValue("customerID") result, err := Client.FeatureAccess.Get(r.Context(), feature, &commet.GetFeatureAccessParams{ CustomerID: customerID, }) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"allowed": result.Allowed}) } ``` 5. ## Track Usage ```go title="billing/handlers.go" func float64Ptr(value float64) *float64 { return &value } type usageRequest struct { CustomerID string `json:"customer_id"` } func TrackUsage(w http.ResponseWriter, r *http.Request) { var req usageRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } _, err := Client.Usage.Track(r.Context(), &commet.TrackUsageParams{ CustomerID: req.CustomerID, FeatureCode: "api_calls", Value: float64Ptr(1), }) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"tracked": true}) } ``` Usage is aggregated and billed at end of period. 6. ## Customer Portal ```go title="billing/handlers.go" func Portal(w http.ResponseWriter, r *http.Request) { customerID := "user_123" result, err := Client.Portal.GetURL(r.Context(), &commet.RequestPortalAccessParams{ CustomerID: &customerID, }) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, result.PortalURL, http.StatusTemporaryRedirect) } ``` 7. ## Webhooks ```go title="billing/webhooks.go" package billing import ( "io" "net/http" "os" commet "github.com/commet-labs/commet-go/v9" ) func HandleWebhook(w http.ResponseWriter, r *http.Request) { rawBody, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Failed to read body", http.StatusBadRequest) return } webhooks := &commet.WebhooksResource{} payload, err := webhooks.VerifyAndParse( string(rawBody), r.Header.Get("x-commet-signature"), os.Getenv("COMMET_WEBHOOK_SECRET"), ) if err != nil { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } switch payload["event"] { case "subscription.activated": // handle activation } w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"ok":true}`)) } ``` 8. ## Start Server ```go title="main.go" package main import ( "log" "net/http" "myapp/billing" ) func main() { billing.Init() defer billing.Client.Close() mux := http.NewServeMux() mux.HandleFunc("POST /billing/subscribe", billing.Subscribe) mux.HandleFunc("GET /billing/subscription/{customerID}", billing.GetSubscription) mux.HandleFunc("GET /billing/features/{feature}/{customerID}", billing.CheckFeature) mux.HandleFunc("POST /billing/usage", billing.TrackUsage) mux.HandleFunc("GET /billing/portal", billing.Portal) mux.HandleFunc("POST /webhooks/commet", billing.HandleWebhook) log.Println("Listening on :3000") log.Fatal(http.ListenAndServe(":3000", mux)) } ``` ## Related - [Subscriptions](/docs/manage-subscriptions) - [Track Usage](/docs/track-usage) - [Customer Portal](/docs/customer-portal) - [SDK Reference](/docs/sdk-reference) # Handle Failed Payments (/docs/handle-failed-payments) When a renewal payment fails, the subscription moves to `past_due` and enters dunning. The customer keeps service during this grace window while Commet retries the charge. Customers can reactivate sooner from the Customer Portal by retrying payment or updating their card. ## What happens when a payment fails 1. The subscription status changes to `past_due` 2. The failed invoice is marked as `outstanding` 3. The customer keeps service: usage events and seat events still work (usage accrues as debt) 4. The customer receives an email notification 5. Commet retries the charge on a fixed schedule (dunning) If a retry succeeds, the subscription returns to `active`. If all retries fail, the subscription is canceled and the invoice is marked as `uncollectible`. ## Provider error mapping Commet translates provider responses into a common payment outcome and keeps the provider detail alongside it. For recurring failures, the [`payment.failed`](/docs/webhooks/payment-failed) webhook exposes `failureCode`, `failureMessage`, and a `recoveryUrl` when a recovery path is available. The exact `failureCode` depends on the provider, so use the normalized outcome and the recovery URL for customer handling instead of matching only one provider's raw codes. | Payment outcome | What it means | What to do | | ------------------------------------ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `requires_action` | The provider needs an additional customer step, such as 3D Secure | Keep the customer in checkout and complete the authentication flow | | `PAYMENT_FAILED` with a decline code | The provider rejected the charge | Show a retry or alternate payment method and record the provider code for support | | `payment.failed` | A recurring charge failed and the invoice entered dunning | Keep service during the grace window, communicate the recovery path, and wait for a retry or recovery | | `payment.retry_failed` | All scheduled dunning retries were exhausted | Revoke access according to your product policy and ask the customer to start a new subscription or contact support | Initial checkout card declines do not emit `payment.failed`; the checkout response carries the failure state directly. Recurring failures use the dunning flow below. ## Automatic retries Retries run on day 1, day 3, day 5, and day 7 after the original failure (4 retries). The calendar is anchored to the failure and never moves. After the last failed retry the subscription is canceled. Manual retries — from the Customer Portal or [`reactivate`](#retry-the-charge-server-to-server) — count against the same calendar: a declined manual retry consumes the next scheduled slot. Four declined retries cancel the subscription even before day 7. Retries use the payment connection already associated with the subscription. Changing country routing does not move the retry to another provider, and Commet does not silently switch a saved payment method to a different account. ## Dunning communications Commet sends a payment-failure notification when a recurring charge enters dunning. For product-specific messaging, subscribe to these webhooks: - [`payment.failed`](/docs/webhooks/payment-failed) — a recurring charge failed; use `failureCode`, `failureMessage`, and `recoveryUrl` to explain the next step. - [`payment.recovered`](/docs/webhooks/payment-recovered) — the outstanding invoice was paid and the subscription returned to `active`. - [`payment.retry_failed`](/docs/webhooks/payment-retry-failed) — all retries were exhausted and the subscription was canceled. Send your own email, SMS, or in-app message when you need product-specific copy. Do not create a second retry schedule in your app; use Commet's events to close the communication loop. ## Check subscription status ### TypeScript ```typescript const subscription = await commet.subscriptions.getActive({ customerId: 'user_123' }) if (subscription?.status === 'past_due') { showRecoveryPrompt() } ``` ### Python ```python subscription = commet.subscriptions.get_active(customer_id='user_123') if subscription is not None and subscription.status == 'past_due': show_recovery_prompt() ``` ### Go ```go subscription, err := client.Subscriptions.GetActive(ctx, &commet.GetActiveSubscriptionParams{CustomerID: "user_123"}) if err != nil { log.Fatal(err) } if subscription != nil && subscription.Status == "past_due" { showRecoveryPrompt() } ``` ### Java ```java var subscription = commet.subscriptions().getActive(GetActiveSubscriptionParams.builder("user_123").build()); if (subscription != null && subscription.status() == SubscriptionStatus.PAST_DUE) { showRecoveryPrompt(); } ``` ### PHP ```php $result = $commet->subscriptions->getActive('user_123'); if ($result !== null && $result->status->value === 'past_due') { showRecoveryPrompt(); } ``` ### cURL ```bash curl "https://commet.co/api/v1/subscriptions/active?customerId=user_123" \ -H "x-api-key: $COMMET_API_KEY" ``` ## Gate access based on status Commet keeps serving `past_due` customers during the dunning window — usage and seat events still work. You decide whether to gate your own product on `past_due`. To grant access only while billing is healthy, treat `active` and `trialing` as the access states: ### TypeScript ```typescript const subscription = await commet.subscriptions.getActive({ customerId: 'user_123' }) const hasAccess = subscription !== null && (subscription.status === 'active' || subscription.status === 'trialing') ``` ### Python ```python subscription = commet.subscriptions.get_active(customer_id='user_123') has_access = subscription is not None and subscription.status in ('active', 'trialing') ``` ### Go ```go subscription, err := client.Subscriptions.GetActive(ctx, &commet.GetActiveSubscriptionParams{CustomerID: "user_123"}) if err != nil { log.Fatal(err) } hasAccess := subscription != nil && (subscription.Status == "active" || subscription.Status == "trialing") ``` ### Java ```java var subscription = commet.subscriptions().getActive(GetActiveSubscriptionParams.builder("user_123").build()); boolean hasAccess = subscription != null && (subscription.status() == SubscriptionStatus.ACTIVE || subscription.status() == SubscriptionStatus.TRIALING); ``` ### PHP ```php $result = $commet->subscriptions->getActive('user_123'); $hasAccess = $result !== null && in_array($result->status->value, ['active', 'trialing'], true); ``` ### cURL ```bash curl "https://commet.co/api/v1/subscriptions/active?customerId=user_123" \ -H "x-api-key: $COMMET_API_KEY" ``` ## Recover a subscription programmatically The SDK exposes three server-side recovery primitives. For `past_due` subscriptions they all operate on the same `outstanding` renewal invoice — none of them void it. `reactivate` also reactivates `canceled` subscriptions. ### Retry the charge server-to-server `reactivate` charges the subscription's saved payment method. It works on both `past_due` and `canceled` subscriptions, with different effects: - `past_due`: retries the same outstanding renewal invoice. The billing anchor stays fixed. On success the subscription returns to `active` and `payment.recovered` fires. - `canceled`: generates a fresh invoice, resets the billing period anchor to now, and charges the saved card. On success the subscription returns to `active` and `subscription.reactivated` fires. Requires the plan to still be available in the subscription's currency, otherwise it returns `PLAN_UNAVAILABLE` (422). ```typescript const result = await commet.subscriptions.reactivate({ id: 'sub_123' }) // result.retryInitiated === true ``` On a declined charge or no saved card, the response returns a `recoveryUrl` in the error details — a hosted page where the customer adds a new card and pays. This matters for subscriptions canceled by dunning: they reached `canceled` precisely because the saved card kept failing. ### Send the customer a recovery link `createRecoveryLink` returns a hosted, signed link so the customer pays the outstanding renewal themselves. Deliver it through your own email, SMS, or dashboard. The link stays valid until the charge is paid or the subscription is no longer `past_due`. The [`payment.failed` webhook](/docs/webhooks/payment-failed) already carries a `recoveryUrl`: the checkout URL for a first failed charge, a signed recovery link for a failed renewal. If you consume webhooks, no separate `createRecoveryLink` call is needed. ```typescript const recovery = await commet.subscriptions.createRecoveryLink({ id: 'sub_123' }) // recovery.url → hosted payment page // recovery.token → signed token embedded in the URL ``` ### Update the payment method `updatePaymentMethod` returns a hosted checkout where the customer updates the subscription's default payment method. ```typescript const paymentMethodUpdate = await commet.subscriptions.updatePaymentMethod({ id: 'sub_123', successUrl: 'https://yourapp.com/billing', }) // redirect(paymentMethodUpdate.checkoutUrl) ``` ## Self-serve recovery Customers in `past_due` see their subscription in the [Customer Portal](/docs/customer-portal) with a **Reactivate Subscription** button. They can choose: - **Retry with their current card** — useful when the failure was temporary (insufficient funds that are now available, a bank hold that cleared). - **Update their payment method** — enter a new card through the subscription's payment provider and retry in the same step. A successful retry settles the outstanding invoice, moves the subscription back to `active`, and emits a `payment.recovered` event. A declined retry consumes the next slot on the dunning calendar. Retry attempts are rate-limited to 3 per day per customer. ## Prompt payment update Redirect customers to the Customer Portal to reactivate: ### TypeScript ```typescript const portal = await commet.portal.getUrl({ customerId: 'user_123' }) redirect(portal.portalUrl) ``` ### Python ```python portal = commet.portal.get_url(customer_id='user_123') redirect(portal.portal_url) ``` ### Go ```go portal, err := client.Portal.GetURL(ctx, &commet.GetPortalURLParams{ CustomerID: "user_123", }) // redirect(portal.PortalURL) ``` ### Java ```java var portal = commet.portal().getUrl(RequestPortalAccessParams.builder().customerId("user_123").build()); // redirect(portal.portalUrl()) ``` ### PHP ```php $portal = $commet->portal->getUrl(customerId: 'user_123'); redirect($portal->portalUrl); ``` ### cURL ```bash curl -X POST https://commet.co/api/v1/portal/request-access \ -H "x-api-key: $COMMET_API_KEY" \ -H "Content-Type: application/json" \ -d '{"customerId": "user_123"}' ``` ## Related - [Invoices and Billing Cycles](/docs/invoices-and-billing-cycles) — Invoice types and charge timing - [Manage Subscriptions](/docs/manage-subscriptions) — Create and manage customer subscriptions - [Customer Portal](/docs/customer-portal) — Self-service billing portal for customers - [Payment Providers](/docs/payment-providers) — How Commet routes payments through Commet, Stripe, or dLocal - [Payment Orchestration](/docs/payment-orchestration) — Country routing, defaults, and provider-bound payment methods # Invoices and Billing Cycles (/docs/invoices-and-billing-cycles) Invoices are the financial records Commet generates automatically whenever a subscription event or billing cycle occurs. ## Invoice types | Type | When Generated | Example | | --------------------- | ------------------------------------------------------- | ------------------------------------------ | | **Recurring** | Every billing cycle | $99 plan base + $12.50 overage | | **Overage** | Between cycles (quarterly/yearly only) | 15k extra API calls billed monthly | | **Plan change** | Customer upgrades mid-cycle | Starter to Pro, prorated $45 | | **Credit purchase** | Customer buys a credit pack | 500 credits for $40 | | **Balance top-up** | Customer adds funds | $50 balance top-up | | **Add-on activation** | Customer activates an add-on mid-cycle | SMS add-on, prorated $32.26 | | **Adjustment** | Manual correction issued | $10 refund for service issue | | **One-time payment** | Standalone charge (`payments.charge` or a payment link) | $250 one-off invoice, no subscription | | **Reactivation** | Canceled subscription is reactivated | Fresh invoice, billing anchor reset to now | The **One-time payment** type backs the [payments resource](/docs/accept-one-time-payments) — charges and payment links with no subscription. Its single line uses the `one_time` line type. ## Invoice line types Each invoice is made of typed lines. The line type tells you what the charge is for. | Line type | What it is | | --------------------- | --------------------------------------------------------------------------- | | `plan_base` | The plan's recurring base price | | `feature_overage` | Metered usage beyond the included allowance | | `feature_seats` | Seat charges — advance for excess seats plus mid-cycle true-up | | `feature_quota` | Quota charges — advance plus true-up | | `discount` | Introductory offer discount | | `promo_code_discount` | Promo code discount | | `credit` | Credit application | | `balance_overage` | Balance model overage when the balance is exhausted and usage isn't blocked | | `addon_base` | An add-on's base price | | `one_time` | A standalone one-time payment, with no subscription | ## When charges happen | Component | When Charged | Example | | -------------------- | ---------------------------------------------------------- | ----------------------------------------------------------- | | **Plan base price** | Advance (period start) | $99 on Jan 1 | | **Boolean features** | Included in plan base | SSO, Custom Branding | | **Metered overage** | True-up (period end) | 2,500 extra API calls | | **Included seats** | Advance (with base) | 5 editor seats | | **Additional seats** | Hybrid: true-up for the past period + advance for the next | 3 extra seats prorated for the days used, then billed ahead | ## Billing intervals | Interval | Base Invoice | Overage Invoice | Consumption Reset | Example | | ------------- | ------------------------- | --------------------------------- | ----------------- | ------------------------------------ | | **Weekly** | Every 7 days | Included in recurring invoice | Every 7 days | $25/week every Monday | | **Monthly** | Every month | Included in recurring invoice | Every month | $99/month on the 1st | | **Quarterly** | Every 3 months | Monthly | Every month | $297 base quarterly, overage monthly | | **Yearly** | Every 12 months | Monthly | Every month | $899 base yearly, overage monthly | | **One-time** | Single charge at checkout | At period end (if overage exists) | Never | $299 lifetime, no recurring charges | For quarterly and yearly plans, overage is calculated and invoiced monthly even though the base charge is less frequent. For weekly plans, everything runs on a 7-day cycle. For one-time plans, the customer pays once at checkout and never receives a recurring invoice — only overage charges if applicable. See [One-Time Payments](/docs/one-time-payments) for details. ## Learn more - [What Invoices Do Customers Receive and When](/docs/what-invoices-do-customers-receive-and-when) - [How Do Monthly, Quarterly, and Yearly Billing Work](/docs/how-do-monthly-quarterly-and-yearly-billing-work) ## Related - [Handle Failed Payments](/docs/handle-failed-payments) — Retry flow and grace periods for failed charges - [Consumption Models](/docs/consumption-models) — Metered, Credits, and Balance explained - [Finance Overview](/docs/finance-overview) — How money flows through Commet # Manage Invoices (/docs/manage-invoices) Use **Invoices** as the accounting view of what a customer owes or paid. Use **Transactions** to inspect each payment attempt that tried to settle an invoice. ## Review invoice state **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const response = await commet.invoices.list(); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") invoices_list_result = commet.invoices.list() ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() invoicesListResult, err := client.Invoices.List(ctx, nil) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.ListInvoicesParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var invoicesListResult = commet.invoices().list(ListInvoicesParams.builder().build()); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $invoicesListResult = $commet->invoices->list(); ``` Open an invoice in the dashboard to inspect line items, tax, subscription context, payment attempts, and customer details. ## Create an adjustment invoice **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const invoice = await commet.invoices.createAdjustment({ customerId: "user_123", amount: 5000, description: "Customer requested", }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") invoice = commet.invoices.create_adjustment( customer_id="user_123", amount=5000, description="Customer requested", ) ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() invoice, err := client.Invoices.CreateAdjustment(ctx, &commet.CreateAdjustmentInvoiceParams{ CustomerID: "user_123", Amount: 5000, Description: "Customer requested", }) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.CreateAdjustmentInvoiceParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var invoice = commet.invoices().createAdjustment( CreateAdjustmentInvoiceParams.builder("user_123", 5000L, "Customer requested").build() ); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $invoice = $commet->invoices->createAdjustment( customerId: 'user_123', amount: 5000, description: 'Customer requested', ); ``` Use a positive amount for an extra charge and a negative amount for a credit. An adjustment invoice is a one-off accounting document; it does not change the plan or future renewals. ## Mark an outstanding invoice **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const invoice = await commet.invoices.updateStatus({ id: "inv_xxx", status: "paid", }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") invoice = commet.invoices.update_status( "inv_xxx", status="paid", ) ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() invoice, err := client.Invoices.UpdateStatus(ctx, "inv_xxx", &commet.UpdateInvoiceStatusParams{ Status: "paid", }) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.UpdateInvoiceStatusParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var invoice = commet.invoices().updateStatus( "inv_xxx", UpdateInvoiceStatusParams.builder("paid").build() ); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $invoice = $commet->invoices->updateStatus( id: 'inv_xxx', status: 'paid', ); ``` Only outstanding invoices can be marked `paid` or `void`. This records an external settlement or a deliberate write-off; it does not create a provider transaction. ## Send or download **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const sentInvoice = await commet.invoices.send({ id: "inv_xxx" }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") sent_invoice = commet.invoices.send("inv_xxx") ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() sentInvoice, err := client.Invoices.Send(ctx, "inv_xxx", nil) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.SendInvoiceParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var sentInvoice = commet.invoices().send("inv_xxx", SendInvoiceParams.builder().build()); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $sentInvoice = $commet->invoices->send(id: 'inv_xxx'); ``` **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const invoiceDownload = await commet.invoices.getDownloadUrl({ id: "inv_xxx" }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") invoice_download = commet.invoices.get_download_url("inv_xxx") ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() invoiceDownload, err := client.Invoices.GetDownloadURL(ctx, "inv_xxx", nil) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.DownloadInvoiceParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var invoiceDownload = commet.invoices().getDownloadUrl( "inv_xxx", DownloadInvoiceParams.builder().build() ); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $invoiceDownload = $commet->invoices->getDownloadUrl(id: 'inv_xxx'); ``` The PDF link is signed and expires after seven days. Generate it when the customer requests the document instead of storing a long-lived public URL. For automatic renewal and overage timing, read [Invoices and Billing Cycles](/docs/invoices-and-billing-cycles). # Integrate with Java (/docs/integrate-with-java) Install the Commet Skill so your coding agent can integrate the current SDK and verify its work against the live API contract. ```bash npx skills add commet-labs/skills --skill commet ``` 1. ## Install ### Maven ```xml title="pom.xml" co.commet commet-java 8.0.0 ``` ### Gradle ```kotlin title="build.gradle.kts" implementation("co.commet:commet-java:9.0.0") ``` 2. ## Configure ```bash title=".env" COMMET_API_KEY=ck_sandbox_xxx ``` ```java title="CommetClient.java" import co.commet.Commet; public class CommetClient { public static final Commet commet = Commet.builder() .apiKey(System.getenv("COMMET_API_KEY")) .build(); } ``` There is no environment option on the client: sandbox vs live is decided by the organization the API key belongs to. 3. ## Create Customer and Subscribe `customers().create` is idempotent — if a customer with the same `id` already exists, it returns the existing record. ```java import co.commet.params.CreateCustomerParams; import co.commet.params.CreateSubscriptionParams; commet.customers().create( CreateCustomerParams.builder("user@example.com") .id("user_123") .build() ); var subscription = commet.subscriptions().create( CreateSubscriptionParams.builder("user_123") .planCode("pro") .build() ); String checkoutUrl = subscription.checkoutUrl(); ``` The customer is redirected to checkout to complete payment. 4. ## Check Access ```java import co.commet.models.FeatureAccess; import co.commet.models.Subscription; import co.commet.models.SubscriptionStatus; import co.commet.params.GetActiveSubscriptionParams; import co.commet.params.GetFeatureAccessParams; Subscription sub = commet.subscriptions().getActive( GetActiveSubscriptionParams.builder("user_123").build() ); SubscriptionStatus status = sub != null ? sub.status() : null; FeatureAccess access = commet.featureAccess() .get("custom_branding", GetFeatureAccessParams.builder("user_123").build()); boolean allowed = access.allowed(); ``` 5. ## Track Usage ```java import co.commet.params.TrackUsageParams; commet.usage().track( TrackUsageParams.builder("api_calls", "user_123") .value(1.0) .build() ); ``` Usage is aggregated and billed at end of period. 6. ## Webhooks ```java import co.commet.resources.Webhooks; import java.util.Map; Webhooks webhooks = new Webhooks(); Map payload = webhooks.verifyAndParse( rawBody, signature, webhookSecret ); if (payload == null) { // return 401 } if ("subscription.activated".equals(payload.get("event"))) { // handle activation } ``` ## Related - [SDK Reference](/docs/sdk-reference) # Integrate with Astro (/docs/integrate-with-astro) Install the Commet Skill so your coding agent can integrate the current SDK and verify its work against the live API contract. ```bash npx skills add commet-labs/skills --skill commet ``` > **Warning** > > Astro requires an SSR adapter for API routes. Set `output: 'server'` or `output: 'hybrid'` in your `astro.config.mjs`. 1. ## Install ### pnpm ```bash pnpm add @commet/node ``` ### npm ```bash npm install @commet/node ``` ### yarn ```bash yarn add @commet/node ``` ### bun ```bash bun add @commet/node ``` 2. ## Configure ```bash title=".env" COMMET_API_KEY=ck_sandbox_xxx ``` ```typescript title="src/lib/commet.ts" import { Commet } from '@commet/node' export const commet = new Commet({ apiKey: import.meta.env.COMMET_API_KEY, }) ``` 3. ## Subscribe `customers.create` is idempotent — if a customer with the same `id` already exists, it returns the existing record. ```typescript title="src/pages/api/billing/subscribe.ts" import type { APIRoute } from 'astro' import { commet } from '../../../lib/commet' export const POST: APIRoute = async ({ request }) => { const { customerId, email } = await request.json() await commet.customers.create({ email, id: customerId }) const subscription = await commet.subscriptions.create({ customerId, planCode: 'pro', }) return Response.json({ checkoutUrl: subscription.checkoutUrl ?? null }) } ``` 4. ## Check Access ```typescript title="src/pages/api/billing/access/[customerId].ts" import type { APIRoute } from 'astro' import { commet } from '../../../../lib/commet' export const GET: APIRoute = async ({ params }) => { const subscription = await commet.subscriptions.getActive({ customerId: params.customerId! }) if (!subscription) { return Response.json({ error: 'no_active_subscription' }, { status: 404 }) } const feature = await commet.featureAccess.get({ code: 'api_calls', customerId: params.customerId!, }) return Response.json({ status: subscription.status, allowed: feature.allowed, }) } ``` 5. ## Track Usage ```typescript title="src/pages/api/billing/usage.ts" import type { APIRoute } from 'astro' import { commet } from '../../../lib/commet' export const POST: APIRoute = async ({ request }) => { const { customerId } = await request.json() await commet.usage.track({ customerId, featureCode: 'api_calls', value: 1, }) return Response.json({ tracked: true }) } ``` Usage is aggregated and billed at end of period. 6. ## Customer Portal ```typescript title="src/pages/api/billing/portal.ts" import type { APIRoute } from 'astro' import { commet } from '../../../lib/commet' export const GET: APIRoute = async ({ redirect }) => { const customerId = 'user_123' const portal = await commet.portal.getUrl({ customerId }) return redirect(portal.portalUrl) } ``` ## Related - [Subscriptions](/docs/manage-subscriptions) - [Track Usage](/docs/track-usage) - [Customer Portal](/docs/customer-portal) - [SDK Reference](/docs/sdk-reference) # Integrate with Bun (/docs/integrate-with-bun) Install the Commet Skill so your coding agent can integrate the current SDK and verify its work against the live API contract. ```bash npx skills add commet-labs/skills --skill commet ``` 1. ## Install ```bash bun add @commet/node ``` 2. ## Configure ```bash title=".env" COMMET_API_KEY=ck_sandbox_xxx ``` ```typescript title="src/commet.ts" import { Commet } from '@commet/node' export const commet = new Commet({ apiKey: Bun.env.COMMET_API_KEY!, }) ``` 3. ## Subscribe ```typescript title="src/index.ts" import { commet } from './commet' Bun.serve({ port: 3000, async fetch(req) { const url = new URL(req.url) if (url.pathname === '/subscribe' && req.method === 'POST') { const { customerId, email } = await req.json() await commet.customers.create({ email, id: customerId }) const subscription = await commet.subscriptions.create({ customerId, planCode: 'pro', }) return Response.json({ checkoutUrl: subscription.checkoutUrl ?? null }) } return new Response('Not Found', { status: 404 }) }, }) ``` 4. ## Check Access Add these routes to the `fetch` handler. ```typescript title="src/index.ts" if (url.pathname.startsWith('/subscription/') && req.method === 'GET') { const customerId = url.pathname.split('/')[2] const subscription = await commet.subscriptions.getActive({ customerId }) if (!subscription) { return Response.json({ error: 'no_active_subscription' }, { status: 404 }) } return Response.json({ status: subscription.status }) } if (url.pathname.startsWith('/features/') && req.method === 'GET') { const [, , feature, customerId] = url.pathname.split('/') const access = await commet.featureAccess.get({ code: feature, customerId }) return Response.json({ allowed: access.allowed }) } ``` 5. ## Track Usage ```typescript title="src/index.ts" if (url.pathname === '/usage' && req.method === 'POST') { const { customerId } = await req.json() await commet.usage.track({ customerId, featureCode: 'api_calls', value: 1, }) return Response.json({ tracked: true }) } ``` Usage is aggregated and billed at end of period. 6. ## Customer Portal ```typescript title="src/index.ts" if (url.pathname === '/portal' && req.method === 'GET') { const customerId = url.searchParams.get('customerId')! const portal = await commet.portal.getUrl({ customerId }) return Response.redirect(portal.portalUrl) } ``` ## Related - [Subscriptions](/docs/manage-subscriptions) - [Track Usage](/docs/track-usage) - [Customer Portal](/docs/customer-portal) - [SDK Reference](/docs/sdk-reference) # Integrate with Express (/docs/integrate-with-express) Install the Commet Skill so your coding agent can integrate the current SDK and verify its work against the live API contract. ```bash npx skills add commet-labs/skills --skill commet ``` 1. ## Install ### pnpm ```bash pnpm add @commet/node express ``` ### npm ```bash npm install @commet/node express ``` ### yarn ```bash yarn add @commet/node express ``` 2. ## Configure ```bash title=".env" COMMET_API_KEY=ck_sandbox_xxx ``` ```typescript title="src/commet.ts" import { Commet } from '@commet/node' export const commet = new Commet({ apiKey: process.env.COMMET_API_KEY!, }) ``` 3. ## Subscribe ```typescript title="src/routes/billing.ts" import { Router } from 'express' import { commet } from '../commet' const router = Router() router.post('/subscribe', async (req, res) => { const { customerId, email } = req.body await commet.customers.create({ email, id: customerId }) const subscription = await commet.subscriptions.create({ customerId, planCode: 'pro', }) res.json({ checkoutUrl: subscription.checkoutUrl ?? null }) }) export default router ``` 4. ## Check Access ```typescript title="src/routes/billing.ts" router.get('/subscription/:customerId', async (req, res) => { const subscription = await commet.subscriptions.getActive({ customerId: req.params.customerId }) if (!subscription) { return res.status(404).json({ error: 'no_active_subscription' }) } res.json({ status: subscription.status }) }) router.get('/features/:feature/:customerId', async (req, res) => { const access = await commet.featureAccess.get({ code: req.params.feature, customerId: req.params.customerId, }) res.json({ allowed: access.allowed }) }) ``` 5. ## Track Usage ```typescript title="src/routes/billing.ts" router.post('/usage', async (req, res) => { await commet.usage.track({ customerId: req.body.customerId, featureCode: 'api_calls', value: 1, }) res.json({ tracked: true }) }) ``` Usage is aggregated and billed at end of period. 6. ## Customer Portal ```typescript title="src/routes/billing.ts" router.get('/portal', async (req, res) => { const portal = await commet.portal.getUrl({ customerId: req.user.customerId, }) res.redirect(portal.portalUrl) }) ``` 7. ## Start Server ```typescript title="src/index.ts" import express from 'express' import billingRoutes from './routes/billing' const app = express() app.use(express.json()) app.use('/billing', billingRoutes) app.listen(3000) ``` ## Related - [Subscriptions](/docs/manage-subscriptions) - [Track Usage](/docs/track-usage) - [Customer Portal](/docs/customer-portal) - [SDK Reference](/docs/sdk-reference) # Integrate with Hono (/docs/integrate-with-hono) Install the Commet Skill so your coding agent can integrate the current SDK and verify its work against the live API contract. ```bash npx skills add commet-labs/skills --skill commet ``` 1. ## Install ### pnpm ```bash pnpm add @commet/node hono ``` ### npm ```bash npm install @commet/node hono ``` ### bun ```bash bun add @commet/node hono ``` 2. ## Configure ```bash title=".env" COMMET_API_KEY=ck_sandbox_xxx ``` ```typescript title="src/commet.ts" import { Commet } from '@commet/node' export const commet = new Commet({ apiKey: process.env.COMMET_API_KEY!, }) ``` 3. ## Subscribe ```typescript title="src/routes/billing.ts" import { Hono } from 'hono' import { commet } from '../commet' const billing = new Hono() billing.post('/subscribe', async (c) => { const { customerId, email } = await c.req.json() await commet.customers.create({ email, id: customerId }) const subscription = await commet.subscriptions.create({ customerId, planCode: 'pro', }) return c.json({ checkoutUrl: subscription.checkoutUrl ?? null }) }) export default billing ``` 4. ## Check Access ```typescript title="src/routes/billing.ts" billing.get('/subscription/:customerId', async (c) => { const subscription = await commet.subscriptions.getActive({ customerId: c.req.param('customerId') }) if (!subscription) { return c.json({ error: 'no_active_subscription' }, 404) } return c.json({ status: subscription.status }) }) billing.get('/features/:feature/:customerId', async (c) => { const access = await commet.featureAccess.get({ code: c.req.param('feature'), customerId: c.req.param('customerId'), }) return c.json({ allowed: access.allowed }) }) ``` 5. ## Track Usage ```typescript title="src/routes/billing.ts" billing.post('/usage', async (c) => { const { customerId } = await c.req.json() await commet.usage.track({ customerId, featureCode: 'api_calls', value: 1, }) return c.json({ tracked: true }) }) ``` Usage is aggregated and billed at end of period. 6. ## Customer Portal ```typescript title="src/routes/billing.ts" billing.get('/portal', async (c) => { const customerId = c.get('customerId') const portal = await commet.portal.getUrl({ customerId }) return c.redirect(portal.portalUrl) }) ``` 7. ## Start Server ### Node.js ```typescript title="src/index.ts" import { serve } from '@hono/node-server' import { Hono } from 'hono' import billing from './routes/billing' const app = new Hono() app.route('/billing', billing) serve({ fetch: app.fetch, port: 3000 }) ``` ### Bun ```typescript title="src/index.ts" import { Hono } from 'hono' import billing from './routes/billing' const app = new Hono() app.route('/billing', billing) export default app ``` ## Related - [Subscriptions](/docs/manage-subscriptions) - [Track Usage](/docs/track-usage) - [Customer Portal](/docs/customer-portal) - [SDK Reference](/docs/sdk-reference) # Integrate with Next.js (/docs/integrate-with-nextjs) Install the Commet Skill so your coding agent can integrate the current SDK and verify its work against the live API contract. ```bash npx skills add commet-labs/skills --skill commet ``` 1. ## Install ### pnpm ```bash pnpm add @commet/node @commet/next ``` ### npm ```bash npm install @commet/node @commet/next ``` ### yarn ```bash yarn add @commet/node @commet/next ``` ### bun ```bash bun add @commet/node @commet/next ``` 2. ## Configure ```bash title=".env.local" COMMET_API_KEY=ck_sandbox_xxx ``` ```typescript title="lib/commet.ts" import { Commet } from '@commet/node' export const commet = new Commet({ apiKey: process.env.COMMET_API_KEY!, }) ``` 3. ## Create Customer and Subscribe `customers.create` is idempotent — if a customer with the same `id` already exists, it returns the existing record. ```typescript title="app/actions/billing.ts" 'use server' import { redirect } from 'next/navigation' import { commet } from '@/lib/commet' export async function subscribe(customerId: string) { await commet.customers.create({ email: 'user@example.com', id: customerId, }) const subscription = await commet.subscriptions.create({ customerId, planCode: 'pro', }) const checkoutUrl = subscription.checkoutUrl if (checkoutUrl) { redirect(checkoutUrl) } } ``` The customer is redirected to checkout to complete payment. When `checkoutUrl` is `null` no payment is needed and the subscription is already active. 4. ## Check Access ```typescript title="app/actions/billing.ts" export async function getSubscription(customerId: string) { const subscription = await commet.subscriptions.getActive({ customerId }) return subscription } ``` ```typescript title="app/actions/features.ts" export async function canUseFeature(customerId: string, feature: string) { const access = await commet.featureAccess.get({ code: feature, customerId }) return access.allowed } ``` 5. ## Track Usage ```typescript title="app/actions/usage.ts" export async function trackApiCall(customerId: string) { await commet.usage.track({ customerId, featureCode: 'api_calls', value: 1, }) } ``` Usage is aggregated and billed at end of period. 6. ## Customer Portal ```typescript title="app/api/commet/portal/route.ts" import { CustomerPortal } from '@commet/next' export const GET = CustomerPortal({ apiKey: process.env.COMMET_API_KEY!, getCustomerId: async (req) => { return 'user_123' }, }) ``` ```tsx Manage Billing ``` ## Related - [Subscriptions](/docs/manage-subscriptions) - [Track Usage](/docs/track-usage) - [Customer Portal](/docs/customer-portal) - [SDK Reference](/docs/sdk-reference) # Integrate with Nuxt (/docs/integrate-with-nuxt) Install the Commet Skill so your coding agent can integrate the current SDK and verify its work against the live API contract. ```bash npx skills add commet-labs/skills --skill commet ``` 1. ## Install ### pnpm ```bash pnpm add @commet/node ``` ### npm ```bash npm install @commet/node ``` ### yarn ```bash yarn add @commet/node ``` ### bun ```bash bun add @commet/node ``` 2. ## Configure ```bash title=".env" COMMET_API_KEY=ck_sandbox_xxx ``` ```typescript title="nuxt.config.ts" export default defineNuxtConfig({ runtimeConfig: { commetApiKey: process.env.COMMET_API_KEY, }, }) ``` Nuxt auto-imports from `server/utils/`, so the client is available in all server routes. ```typescript title="server/utils/commet.ts" import { Commet } from '@commet/node' const config = useRuntimeConfig() export const commet = new Commet({ apiKey: config.commetApiKey, }) ``` 3. ## Subscribe `customers.create` is idempotent — if a customer with the same `id` already exists, it returns the existing record. ```typescript title="server/api/billing/subscribe.post.ts" export default defineEventHandler(async (event) => { const { customerId, email } = await readBody(event) await commet.customers.create({ email, id: customerId }) const subscription = await commet.subscriptions.create({ customerId, planCode: 'pro', }) return { checkoutUrl: subscription.checkoutUrl ?? null } }) ``` 4. ## Check Access ```typescript title="server/api/billing/access/[customerId].get.ts" export default defineEventHandler(async (event) => { const customerId = getRouterParam(event, 'customerId')! const subscription = await commet.subscriptions.getActive({ customerId }) if (!subscription) { throw createError({ statusCode: 404, statusMessage: 'No active subscription' }) } const feature = await commet.featureAccess.get({ code: 'api_calls', customerId, }) return { status: subscription.status, allowed: feature.allowed, } }) ``` 5. ## Track Usage ```typescript title="server/api/billing/usage.post.ts" export default defineEventHandler(async (event) => { const { customerId } = await readBody(event) await commet.usage.track({ customerId, featureCode: 'api_calls', value: 1, }) return { tracked: true } }) ``` Usage is aggregated and billed at end of period. 6. ## Customer Portal ```typescript title="server/api/billing/portal.get.ts" export default defineEventHandler(async (event) => { const customerId = 'user_123' const portal = await commet.portal.getUrl({ customerId }) return sendRedirect(event, portal.portalUrl) }) ``` ## Related - [Subscriptions](/docs/manage-subscriptions) - [Track Usage](/docs/track-usage) - [Customer Portal](/docs/customer-portal) - [SDK Reference](/docs/sdk-reference) # Integrate with Remix (/docs/integrate-with-remix) Install the Commet Skill so your coding agent can integrate the current SDK and verify its work against the live API contract. ```bash npx skills add commet-labs/skills --skill commet ``` 1. ## Install ### pnpm ```bash pnpm add @commet/node @remix-run/node ``` ### npm ```bash npm install @commet/node @remix-run/node ``` ### yarn ```bash yarn add @commet/node @remix-run/node ``` ### bun ```bash bun add @commet/node @remix-run/node ``` 2. ## Configure ```bash title=".env" COMMET_API_KEY=ck_sandbox_xxx ``` The `.server.ts` suffix ensures this module is never bundled into client code. ```typescript title="app/lib/commet.server.ts" import { Commet } from '@commet/node' export const commet = new Commet({ apiKey: process.env.COMMET_API_KEY!, }) ``` 3. ## Subscribe `customers.create` is idempotent — if a customer with the same `id` already exists, it returns the existing record. ```typescript title="app/routes/billing.subscribe.ts" import { json, redirect, type ActionFunctionArgs } from '@remix-run/node' import { commet } from '~/lib/commet.server' export async function action({ request }: ActionFunctionArgs) { const formData = await request.formData() const customerId = String(formData.get('customerId')) const email = String(formData.get('email')) await commet.customers.create({ email, id: customerId }) const subscription = await commet.subscriptions.create({ customerId, planCode: 'pro', }) const checkoutUrl = subscription.checkoutUrl if (checkoutUrl) { return redirect(checkoutUrl) } return json({ subscribed: true }) } ``` 4. ## Check Access ```typescript title="app/routes/billing.status.ts" import { json, type LoaderFunctionArgs } from '@remix-run/node' import { commet } from '~/lib/commet.server' export async function loader({ request }: LoaderFunctionArgs) { const url = new URL(request.url) const customerId = url.searchParams.get('customerId')! const subscription = await commet.subscriptions.getActive({ customerId }) if (!subscription) { return json({ error: 'no_active_subscription' }, { status: 404 }) } const feature = await commet.featureAccess.get({ code: 'api_calls', customerId, }) return json({ status: subscription.status, allowed: feature.allowed, }) } ``` 5. ## Track Usage ```typescript title="app/routes/billing.usage.ts" import { json, type ActionFunctionArgs } from '@remix-run/node' import { commet } from '~/lib/commet.server' export async function action({ request }: ActionFunctionArgs) { const { customerId } = await request.json() await commet.usage.track({ customerId, featureCode: 'api_calls', value: 1, }) return json({ tracked: true }) } ``` Usage is aggregated and billed at end of period. 6. ## Customer Portal ```typescript title="app/routes/billing.portal.ts" import { redirect, type LoaderFunctionArgs } from '@remix-run/node' import { commet } from '~/lib/commet.server' export async function loader({ request }: LoaderFunctionArgs) { const customerId = 'user_123' const portal = await commet.portal.getUrl({ customerId }) return redirect(portal.portalUrl) } ``` ## Related - [Subscriptions](/docs/manage-subscriptions) - [Track Usage](/docs/track-usage) - [Customer Portal](/docs/customer-portal) - [SDK Reference](/docs/sdk-reference) # Integrate with SvelteKit (/docs/integrate-with-sveltekit) Install the Commet Skill so your coding agent can integrate the current SDK and verify its work against the live API contract. ```bash npx skills add commet-labs/skills --skill commet ``` 1. ## Install ### pnpm ```bash pnpm add @commet/node ``` ### npm ```bash npm install @commet/node ``` ### yarn ```bash yarn add @commet/node ``` ### bun ```bash bun add @commet/node ``` 2. ## Configure ```bash title=".env" COMMET_API_KEY=ck_sandbox_xxx ``` ```typescript title="src/lib/server/commet.ts" import { Commet } from '@commet/node' import { COMMET_API_KEY } from '$env/static/private' export const commet = new Commet({ apiKey: COMMET_API_KEY, }) ``` 3. ## Subscribe `customers.create` is idempotent — if a customer with the same `id` already exists, it returns the existing record. ```typescript title="src/routes/api/billing/subscribe/+server.ts" import { json } from '@sveltejs/kit' import { commet } from '$lib/server/commet' import type { RequestHandler } from './$types' export const POST: RequestHandler = async ({ request }) => { const { customerId, email } = await request.json() await commet.customers.create({ email, id: customerId }) const subscription = await commet.subscriptions.create({ customerId, planCode: 'pro', }) return json({ checkoutUrl: subscription.checkoutUrl ?? null }) } ``` 4. ## Check Access ```typescript title="src/routes/api/billing/access/[customerId]/+server.ts" import { json } from '@sveltejs/kit' import { commet } from '$lib/server/commet' import type { RequestHandler } from './$types' export const GET: RequestHandler = async ({ params }) => { const subscription = await commet.subscriptions.getActive({ customerId: params.customerId }) if (!subscription) { return json({ error: 'no_active_subscription' }, { status: 404 }) } const feature = await commet.featureAccess.get({ code: 'api_calls', customerId: params.customerId, }) return json({ status: subscription.status, allowed: feature.allowed }) } ``` 5. ## Track Usage ```typescript title="src/routes/api/billing/usage/+server.ts" import { json } from '@sveltejs/kit' import { commet } from '$lib/server/commet' import type { RequestHandler } from './$types' export const POST: RequestHandler = async ({ request }) => { const { customerId } = await request.json() await commet.usage.track({ customerId, featureCode: 'api_calls', value: 1, }) return json({ tracked: true }) } ``` Usage is aggregated and billed at end of period. 6. ## Customer Portal ```typescript title="src/routes/api/billing/portal/+server.ts" import { redirect } from '@sveltejs/kit' import { commet } from '$lib/server/commet' import type { RequestHandler } from './$types' export const GET: RequestHandler = async ({ locals }) => { const portal = await commet.portal.getUrl({ customerId: locals.user.customerId, }) redirect(303, portal.portalUrl) } ``` ## Related - [Subscriptions](/docs/manage-subscriptions) - [Track Usage](/docs/track-usage) - [Customer Portal](/docs/customer-portal) - [SDK Reference](/docs/sdk-reference) # Accept One-Time Payments (/docs/accept-one-time-payments) A payment is a standalone one-time charge with no subscription or plan attached. Commet calculates tax, generates an invoice, and sends a receipt on every payment automatically. > **Note** > > For lifetime deals and one-off purchases billed as a **plan** (trials, intro offers, add-ons, consumption models), use [One-Time Plans](/docs/one-time-payments) instead. ## Link vs. charge There are two ways to take a one-time payment. - **Link** (`payments.create`): builds a hosted payment link. The customer opens the `url` and pays with any card. Vaults the payment method on confirmation. - **Charge** (`payments.charge`): bills a customer's already-vaulted payment method off-session. No customer interaction, no `url`. ## Create a payment link Returns a `Payment` with a `url`. Redirect the customer to it to collect payment. ### TypeScript ```typescript const payment = await commet.payments.create({ amount: 25000, currency: 'usd', description: 'Annual report', customerId: 'user_123', successUrl: 'https://yourapp.com/thanks', }) redirect(payment.url) ``` ### Python ```python payment = commet.payments.create( amount=25000, currency='usd', description='Annual report', customer_id='user_123', success_url='https://yourapp.com/thanks', ) redirect(payment.url) ``` ### Go ```go payment, err := client.Payments.Create(ctx, &commet.CreatePaymentParams{ Amount: 25000, Currency: "usd", Description: "Annual report", CustomerID: "user_123", SuccessURL: "https://yourapp.com/thanks", }) // redirect(payment.URL) ``` ### Java ```java CreatePaymentParams params = CreatePaymentParams.builder() .amount(25000) .currency("usd") .description("Annual report") .customerId("user_123") .successUrl("https://yourapp.com/thanks") .build(); var payment = commet.payments().create(params); // redirect(payment.url()) ``` ### PHP ```php $result = $commet->payments->create( amount: 25000, currency: 'usd', description: 'Annual report', customerId: 'user_123', successUrl: 'https://yourapp.com/thanks', ); redirect($result->url); ``` ### cURL ```bash curl -X POST https://commet.co/api/v1/payments \ -H "x-api-key: $COMMET_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount": 25000, "currency": "usd", "description": "Annual report", "customerId": "user_123", "successUrl": "https://yourapp.com/thanks" }' ``` ### CLI ```bash commet payments create \ --amount 25000 \ --currency usd \ --description "Annual report" \ --customer-id user_123 \ --success-url https://yourapp.com/thanks ``` `amount` is in cents. `25000` is $250.00. ### Parameters | Parameter | Type | Required | Description | | ------------- | -------- | -------- | --------------------------------------------------- | | `amount` | `number` | Yes | Charge amount in cents | | `currency` | `string` | Yes | ISO 4217 currency code (`usd`, `eur`, `brl`) | | `description` | `string` | Yes | Shown on the payment link, invoice, and receipt | | `customerId` | `string` | No | Commet customer ID (`cus_xxx`) or your external ID | | `successUrl` | `string` | No | Where the customer lands after paying | | `metadata` | `object` | No | Key-value pairs. Shape: `{ [key: string]: string }` | ## Charge a saved payment method Bills a customer's vaulted payment method off-session. The customer must have a payment method on file. ### TypeScript ```typescript const payment = await commet.payments.charge({ customerId: 'user_123', amount: 25000, currency: 'usd', description: 'Annual report', }) ``` ### Python ```python response = commet.payments.charge( customer_id='user_123', amount=25000, currency='usd', description='Annual report', ) ``` ### Go ```go result, err := client.Payments.Charge(ctx, &commet.ChargePaymentParams{ CustomerID: "user_123", Amount: 25000, Currency: "usd", Description: "Annual report", }) ``` ### Java ```java ChargePaymentParams params = ChargePaymentParams.builder() .customerId("user_123") .amount(25000) .currency("usd") .description("Annual report") .build(); var payment = commet.payments().charge(params); ``` ### PHP ```php $result = $commet->payments->charge( customerId: 'user_123', amount: 25000, currency: 'usd', description: 'Annual report', ); ``` ### cURL ```bash curl -X POST https://commet.co/api/v1/payments/charge \ -H "x-api-key: $COMMET_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "customerId": "user_123", "amount": 25000, "currency": "usd", "description": "Annual report" }' ``` ### CLI ```bash commet payments charge \ --customer-id user_123 \ --amount 25000 \ --currency usd \ --description "Annual report" ``` ### Parameters | Parameter | Type | Required | Description | | ------------- | -------- | -------- | --------------------------------------------------- | | `customerId` | `string` | Yes | Commet customer ID (`cus_xxx`) or your external ID | | `amount` | `number` | Yes | Charge amount in cents | | `currency` | `string` | Yes | ISO 4217 currency code (`usd`, `eur`, `brl`) | | `description` | `string` | Yes | Shown on the invoice and receipt | | `metadata` | `object` | No | Key-value pairs. Shape: `{ [key: string]: string }` | ## Retrieve a payment ```typescript const payment = await commet.payments.get({ id: 'pay_123' }) ``` ## List payments ```typescript const { data } = await commet.payments.list({ customerId: 'user_123', limit: 20, }) ``` | Parameter | Type | Required | Description | | ------------ | -------- | -------- | ---------------------------------------- | | `customerId` | `string` | No | Filter to one customer | | `limit` | `number` | No | Page size | | `cursor` | `string` | No | Pagination cursor from the previous page | ## Cancel a payment link Cancels a pending link so it can no longer be paid. ```typescript await commet.payments.cancel({ id: 'pay_123' }) ``` > **Warning** > > Only a link that has not been paid or started processing can be canceled. Charges cannot be canceled. ## The Payment object | Field | Type | Description | | ---------------- | ---------------------------------- | --------------------------------------- | | `id` | `string` | Payment ID (`pay_xxx`) | | `customerId` | `string \| null` | Customer the payment belongs to | | `kind` | `"link" \| "charge"` | How the payment was taken | | `status` | `string` | Current state (see below) | | `provider` | `"stripe" \| "commet" \| "dlocal"` | Payment provider that processed it | | `amountSubtotal` | `number` | Pre-tax amount in cents | | `taxAmount` | `number` | Tax in cents | | `amountTotal` | `number` | Subtotal + tax in cents | | `currency` | `string` | ISO 4217 currency code | | `description` | `string` | Description set at creation | | `metadata` | `object \| null` | Key-value pairs set at creation | | `url` | `string \| null` | Hosted payment link. `null` for charges | | `expiresAt` | `string \| null` | When the link expires | | `createdAt` | `string` | ISO 8601 datetime | | `updatedAt` | `string` | ISO 8601 datetime | | `object` | `"payment"` | Object type | | `livemode` | `boolean` | `false` for sandbox payments | ### Statuses - `pending`: The link was created and is waiting for the customer to pay. - `processing`: The payment is being confirmed with the provider. - `succeeded`: The payment completed and an invoice was generated. - `requires_action`: The customer must complete an extra step such as 3D Secure. - `failed`: The payment did not go through. - `canceled`: The link was canceled before payment. `failed` and `requires_action` are not terminal for links: the customer can reopen the same payment link and pay with another card, as long as the link hasn't expired. ## View in the dashboard Navigate to **Payments**. Each payment shows its status, amount, tax, customer, and the generated invoice. ## Limitations - `amount` is in cents — minor units of the currency - Tax is calculated automatically and added on top of `amount` - `payments.charge` requires a vaulted payment method on the customer - Charges cannot be canceled - A link can only be canceled while `pending` ## Related - [One-Time Plans](/docs/one-time-payments) — One-time charges billed as a plan - [Invoices and Billing Cycles](/docs/invoices-and-billing-cycles) — How invoices are generated - [Manage Customers](/docs/manage-customers) — Create customers and vault payment methods - [SDK Reference](/docs/sdk-reference) — Node.js SDK methods - [CLI](/docs/cli#payments) — Create payment links from the terminal # Payment Orchestration (/docs/payment-orchestration) Payment orchestration is the layer that chooses which connected provider handles a payment. Commet supports its own payment rail and your connected Stripe or dLocal accounts through the same billing integration. ## Payment method management To manage provider connections and routing, open **Settings → Payments** in the Commet dashboard. The page includes the payment routing map, the list of connected providers, country assignments, the default provider, and the **Add payment provider** action. To inspect a customer's saved method, open **Customers**, select the customer, and view the **Billing** section. Commet only displays non-sensitive details such as the card brand and last four digits. The full payment method stays with the provider that vaulted it. Customers can update their method through the [Customer Portal](/docs/customer-portal). The new method is collected through the connection used by that customer's subscription. Do not assume that changing an organization's country routing will move an existing method. ## Routing rules In **Settings → Payments**, use **Payment routing** to assign countries to connected providers. You can select individual countries or region presets, then save the assignment. The map and routing list show which provider will handle each country. For a new checkout or other new activity, Commet resolves the customer's billing country as an ISO country code and looks up that country's route. For example, a customer whose billing address has `BR` uses the provider assigned to Brazil. The route is based on the billing address supplied to Commet, not on the customer's IP address. If a subscription already has a saved payment connection, recurring charges continue through that connection. This preserves the location of the vaulted payment method. When there is no saved connection, Commet uses the country route; when no country is available, it uses the organization's default provider. ## Fallback providers The **default provider** is the fallback for a checkout that has no country code. Choose it from the provider list in **Settings → Payments**. Commet starts every organization with the Commet provider as the default, so there is a working route until you configure another one. This default is a routing fallback, not an automatic outage failover. If a provider declines a payment or becomes unavailable, Commet records the failure and exposes the provider's failure code. Automatic retries and recovery links retry the affected billing flow; they do not silently move a saved payment method to another provider. If you need new traffic to use another provider, connect it and reassign the relevant countries or make it the default for countryless checkouts. Existing subscriptions remain on their stamped connection until the customer completes a new payment-method setup. ## Reuse payment methods **Coming soon:** reuse a saved payment method across different providers. Today, a saved payment method belongs to the provider connection that collected it. Commet does not copy card credentials between Stripe, dLocal, and Commet, and a routing change cannot make the same vaulted method chargeable by another provider. Customers must complete a new setup or checkout on the new connection. ## Related - [Payment Providers](/docs/payment-providers) — Connect and configure Stripe, dLocal, and Commet - [Use Commet alongside your PSPs](/docs/merchant-of-record#use-commet-alongside-your-psps) — Understand the MoR boundary for each rail - [Handle Failed Payments](/docs/handle-failed-payments) — Provider errors, retries, and self-serve recovery # Payment Providers (/docs/payment-providers) Commet can process payments through its own rail or through a payment service provider (PSP) account that you connect. The billing API and checkout stay the same; the connection determines where the payment is processed and which merchant identity applies. ## Connect a provider from the dashboard Open **Settings → Payments** in your Commet dashboard. The page shows the providers connected to your organization, the countries assigned to each one, and the current default provider. To connect your own account: 1. Select **Add payment provider**. 2. Choose **Stripe** or **dLocal**. 3. Enter the credentials for the organization's current mode. 4. Save the connection. Commet validates the credentials and stores them encrypted. 5. Add the provider's invoice issuer details before assigning it countries or making it the default. Commet registers and consumes the provider webhooks required to keep payment, refund, dispute, and setup events synchronized. You do not need to create a second billing integration for the connected account. ### Stripe credentials Provide the Stripe **secret key** and **publishable key** for the account you own. In sandbox mode, use `sk_test_...` and `pk_test_...` keys. In live mode, use `sk_live_...` and `pk_live_...` keys. Commet rejects keys from the wrong mode. ### dLocal credentials Provide the credentials issued for the matching dLocal environment: **X-Login**, **X-Trans-Key**, **Secret Key**, and the **API key (dlocal.js)**. Commet validates the account before saving the connection. ## Sandbox and live credentials Sandbox and live credentials belong to different payment environments. Keep test credentials in the Commet sandbox organization and live credentials in the live organization. Never copy a live secret into sandbox or a test key into live. The provider account is also part of the connection. A sandbox Stripe account and a live Stripe account are separate payment destinations even when they belong to the same team. Verify the provider dashboard and the key mode before testing a checkout. ## One transaction model across providers Commet gives every payment the same billing and transaction model regardless of the provider that processes it. In the dashboard, **Transactions** is the central view for payment status, amount, customer, invoice, and lifecycle events. The API and webhooks expose the provider that processed a payment as `stripe`, `commet`, or `dlocal`. Commet continues to manage the billing lifecycle around the provider: invoices, subscription state, retries, recovery links, refunds, and normalized webhook events. Provider-specific errors are preserved as failure codes while the customer-facing payment state remains consistent. The transaction record is centralized in Commet, but funds are not pooled across providers. The connected Stripe or dLocal account owns its own charges, fees, disputes, and payouts. The Commet rail owns the corresponding provider-side records for Commet charges. ## Add or replace a provider without migrating billing Adding a provider does not migrate customers, invoices, subscriptions, or payment methods. Connect the provider, configure its invoice issuer details, and assign countries to it for new activity. Routing changes apply to new charges. A subscription with a saved payment method keeps using the connection where that method was saved, and Commet does not copy the method to another provider. This avoids charging an old customer from an unexpected account. If a connection already has payment history, Commet does not hard-delete it. Keep it connected for its existing billing history and route new countries or new customers to another provider. To move an existing customer, use a new checkout or the Customer Portal to collect a payment method on the new connection; this is a new payment-method setup, not a database migration. See [Payment Orchestration](/docs/payment-orchestration) for country routing, defaults, and provider-bound payment methods. ## Related - [Use Commet alongside your PSPs](/docs/merchant-of-record#use-commet-alongside-your-psps) — Understand when Commet is the MoR and when your PSP is the MoR - [Handle Failed Payments](/docs/handle-failed-payments) — Retries, provider errors, and recovery paths - [Testing in the Sandbox](/docs/testing-sandbox) — Test billing without live charges # Transactions, Refunds, and Retries (/docs/transactions-refunds-and-retries) A transaction is one payment attempt. It records the provider, amount, customer, invoice, status, and lifecycle events without exposing provider credentials. ## Inspect payment attempts Open **Transactions** to investigate a payment from the dashboard, or list them from your application: **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const response = await commet.transactions.list(); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") transactions_list_result = commet.transactions.list() ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() transactionsListResult, err := client.Transactions.List(ctx, nil) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.ListTransactionsParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var transactionsListResult = commet.transactions().list(ListTransactionsParams.builder().build()); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $transactionsListResult = $commet->transactions->list(); ``` One invoice can have multiple transactions when a renewal is retried. The original failed transaction remains immutable; a retry creates a new attempt. ## Refund a successful transaction **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const refund = await commet.transactions.refund({ id: "transaction_xxx" }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") refund = commet.transactions.refund("transaction_xxx") ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() refund, err := client.Transactions.Refund(ctx, "transaction_xxx", nil) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.RefundTransactionParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var refund = commet.transactions().refund( "transaction_xxx", RefundTransactionParams.builder().build() ); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $refund = $commet->transactions->refund(id: 'transaction_xxx'); ``` The operation requests a full refund and returns its actual provider-neutral status. Do not grant the refund in your product before the result is confirmed. Handle [`payment.refunded`](/docs/webhooks/payment-refunded) idempotently for downstream access or balance changes. ## Retry a failed renewal **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const transactionRetry = await commet.transactions.retry({ id: "transaction_xxx" }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") transaction_retry = commet.transactions.retry("transaction_xxx") ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() transactionRetry, err := client.Transactions.Retry(ctx, "transaction_xxx", nil) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.RetryTransactionParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var transactionRetry = commet.transactions().retry( "transaction_xxx", RetryTransactionParams.builder().build() ); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $transactionRetry = $commet->transactions->retry(id: 'transaction_xxx'); ``` Retry is for failed subscription renewals, not arbitrary one-time charges. It keeps the failed attempt for audit and returns the new attempt honestly. For a past-due subscription, prefer the lifecycle actions in [Handle Failed Payments](/docs/handle-failed-payments) when the customer needs a recovery link or a new payment method. Provider routing changes do not move a saved payment method. A retry uses the connection already bound to the subscription. # Integrate with Laravel (/docs/integrate-with-laravel) Install the Commet Skill so your coding agent can integrate the current SDK and verify its work against the live API contract. ```bash npx skills add commet-labs/skills --skill commet ``` 1. ## Install ```bash composer require commet/commet-php ``` 2. ## Configure ```bash title=".env" COMMET_API_KEY=ck_sandbox_xxx COMMET_WEBHOOK_SECRET=whsec_xxx ``` ```php title="app/Providers/AppServiceProvider.php" app->singleton(Commet::class, function () { return new Commet( apiKey: config('services.commet.api_key'), ); }); } } ``` ```php title="config/services.php" 'commet' => [ 'api_key' => env('COMMET_API_KEY'), 'webhook_secret' => env('COMMET_WEBHOOK_SECRET'), ], ``` 3. ## Subscribe ```php title="app/Http/Controllers/BillingController.php" validate([ 'email' => 'required|email', 'customer_id' => 'required|string', ]); $this->commet->customers->create( email: $request->input('email'), id: $request->input('customer_id'), ); $subscription = $this->commet->subscriptions->create( customerId: $request->input('customer_id'), planCode: 'pro', ); return response()->json([ 'checkout_url' => $subscription->checkoutUrl, ]); } } ``` 4. ## Check Access ```php title="app/Http/Controllers/BillingController.php" public function getSubscription(string $customerId): JsonResponse { $subscription = $this->commet->subscriptions->getActive($customerId); if ($subscription === null) { return response()->json(['error' => 'no_active_subscription'], 404); } return response()->json([ 'status' => $subscription->status->value, ]); } public function checkFeature(string $feature, string $customerId): JsonResponse { $result = $this->commet->featureAccess->get(code: $feature, customerId: $customerId); return response()->json([ 'allowed' => $result->allowed, ]); } ``` 5. ## Track Usage ```php title="app/Http/Controllers/BillingController.php" public function trackUsage(Request $request): JsonResponse { $request->validate([ 'customer_id' => 'required|string', ]); $this->commet->usage->track( customerId: $request->input('customer_id'), featureCode: 'api_calls', value: 1, ); return response()->json(['tracked' => true]); } ``` Usage is aggregated and billed at end of period. 6. ## Customer Portal ```php title="app/Http/Controllers/BillingController.php" use Illuminate\Http\RedirectResponse; public function portal(): RedirectResponse { $result = $this->commet->portal->getUrl(customerId: 'user_123'); return redirect($result->portalUrl); } ``` 7. ## Webhooks ```php title="app/Http/Controllers/WebhookController.php" verifyAndParse( rawBody: $request->getContent(), signature: $request->header('x-commet-signature'), secret: config('services.commet.webhook_secret'), ); if ($payload === null) { return response()->json(['error' => 'Invalid signature'], 401); } match ($payload['event']) { 'subscription.activated' => $this->handleActivated($payload), 'subscription.canceled' => $this->handleCanceled($payload), default => null, }; return response()->json(['ok' => true]); } private function handleActivated(array $payload): void { // handle activation } private function handleCanceled(array $payload): void { // handle cancellation } } ``` Exclude the webhook route from CSRF verification: ```php title="bootstrap/app.php" ->withMiddleware(function (Middleware $middleware) { $middleware->validateCsrfTokens(except: [ 'webhooks/commet', ]); }) ``` 8. ## Routes ```php title="routes/api.php" create` is idempotent — if a customer with the same `id` already exists, it returns the existing record. ```php $commet->customers->create( email: 'user@example.com', id: 'user_123', ); $subscription = $commet->subscriptions->create( customerId: 'user_123', planCode: 'pro', ); $checkoutUrl = $subscription->checkoutUrl; ``` The customer is redirected to checkout to complete payment. 4. ## Check Access ```php $sub = $commet->subscriptions->getActive('user_123'); $status = $sub?->status->value; $access = $commet->featureAccess->get(code: 'custom_branding', customerId: 'user_123'); $allowed = $access->allowed; ``` 5. ## Track Usage ```php $commet->usage->track( customerId: 'user_123', featureCode: 'api_calls', value: 1, ); ``` Usage is aggregated and billed at end of period. 6. ## Webhooks ```php verifyAndParse( rawBody: file_get_contents('php://input'), signature: $_SERVER['HTTP_X_COMMET_SIGNATURE'] ?? '', secret: $_ENV['COMMET_WEBHOOK_SECRET'], ); if ($payload === null) { http_response_code(401); echo json_encode(['error' => 'Invalid signature']); exit; } match ($payload['event']) { 'subscription.activated' => handleActivated($payload), 'subscription.canceled' => handleCanceled($payload), default => null, }; echo json_encode(['ok' => true]); ``` ## Related - [Laravel](/docs/integrate-with-laravel) - [Symfony](/docs/integrate-with-symfony) - [SDK Reference](/docs/sdk-reference) # Integrate with Symfony (/docs/integrate-with-symfony) Install the Commet Skill so your coding agent can integrate the current SDK and verify its work against the live API contract. ```bash npx skills add commet-labs/skills --skill commet ``` 1. ## Install ```bash composer require commet/commet-php ``` 2. ## Configure ```bash title=".env" COMMET_API_KEY=ck_sandbox_xxx COMMET_WEBHOOK_SECRET=whsec_xxx ``` ```yaml title="config/services.yaml" services: Commet\Commet: factory: ['@App\Factory\CommetFactory', 'create'] App\Factory\CommetFactory: arguments: $apiKey: '%env(COMMET_API_KEY)%' ``` ```php title="src/Factory/CommetFactory.php" apiKey, ); } } ``` 3. ## Subscribe ```php title="src/Controller/BillingController.php" toArray(); $this->commet->customers->create( email: $data['email'], id: $data['customer_id'], ); $subscription = $this->commet->subscriptions->create( customerId: $data['customer_id'], planCode: 'pro', ); return $this->json([ 'checkout_url' => $subscription->checkoutUrl, ]); } } ``` 4. ## Check Access ```php title="src/Controller/BillingController.php" #[Route('/billing/subscription/{customerId}', methods: ['GET'])] public function getSubscription(string $customerId): JsonResponse { $subscription = $this->commet->subscriptions->getActive($customerId); if ($subscription === null) { return $this->json(['error' => 'no_active_subscription'], 404); } return $this->json([ 'status' => $subscription->status->value, ]); } #[Route('/billing/features/{feature}/{customerId}', methods: ['GET'])] public function checkFeature(string $feature, string $customerId): JsonResponse { $result = $this->commet->featureAccess->get(code: $feature, customerId: $customerId); return $this->json([ 'allowed' => $result->allowed, ]); } ``` 5. ## Track Usage ```php title="src/Controller/BillingController.php" #[Route('/billing/usage', methods: ['POST'])] public function trackUsage(Request $request): JsonResponse { $data = $request->toArray(); $this->commet->usage->track( customerId: $data['customer_id'], featureCode: 'api_calls', value: 1, ); return $this->json(['tracked' => true]); } ``` Usage is aggregated and billed at end of period. 6. ## Customer Portal ```php title="src/Controller/BillingController.php" use Symfony\Component\HttpFoundation\RedirectResponse; #[Route('/billing/portal', methods: ['GET'])] public function portal(): RedirectResponse { $result = $this->commet->portal->getUrl(customerId: 'user_123'); return $this->redirect($result->portalUrl); } ``` 7. ## Webhooks ```php title="src/Controller/WebhookController.php" verifyAndParse( rawBody: $request->getContent(), signature: $request->headers->get('x-commet-signature'), secret: $this->webhookSecret, ); if ($payload === null) { return new Response('Invalid signature', 401); } match ($payload['event']) { 'subscription.activated' => $this->handleActivated($payload), 'subscription.canceled' => $this->handleCanceled($payload), default => null, }; return $this->json(['ok' => true]); } private function handleActivated(array $payload): void { // handle activation } private function handleCanceled(array $payload): void { // handle cancellation } } ``` ## Related - [Subscriptions](/docs/manage-subscriptions) - [Track Usage](/docs/track-usage) - [Customer Portal](/docs/customer-portal) - [SDK Reference](/docs/sdk-reference) # Add-ons (/docs/add-ons) Add-ons are optional features with their own price and consumption model that customers activate on their subscriptions. They extend a subscription without modifying the base plan — think SSO, SMS channels, or premium support. ## How add-ons work | Aspect | Description | | ------------------------ | ----------------------------------------------------------- | | **Pricing** | Fixed base price per billing period, prorated on activation | | **Charge on activation** | Immediate charge for remaining days in current period | | **Recurring billing** | Base price added to the plan's invoice each cycle | | **Deactivation** | No refund — the feature stops immediately | | **Feature access** | The add-on's feature appears alongside plan features | Each add-on maps to exactly one feature. When activated, that feature becomes available through `featureAccess.get`, `usage.check`, and `featureAccess.list` — no different from a plan feature. ## Consumption models Add-ons declare their own consumption model. Boolean add-ons are compatible with any plan. All other models require matching the plan's model. | Model | Description | Compatible Plans | Example | | ----------- | ------------------------------------------ | ---------------- | ------------------------------- | | **Boolean** | Unlocks access, no usage tracking | All plans | SSO, HIPAA compliance | | **Metered** | Included units + overage at period end | Metered plans | SMS: 1000 included, $0.03/extra | | **Credits** | Usage consumes from the plan's credit pool | Credits plans | AI summaries: 5 credits/use | | **Balance** | Usage deducts from the plan's balance pool | Balance plans | Image processing: $0.015/unit | > **Note** > > Credits and balance add-ons consume from the plan's **shared pool** — there's no separate pool for the add-on. If the pool runs out, the add-on's feature is blocked too. ## Create add-ons in the dashboard Go to **Add-ons** and click **Create Add-on**. Configure the name, base price, feature, and consumption model. For metered add-ons, set included units and overage rate. For credits, set the credit cost per unit. The feature dropdown only shows features not already assigned to another add-on. Once created, the add-on is available to any customer whose plan is compatible. ## Availability by subscription status Add-ons can be activated on any subscription with a payment method: | Status | Can activate add-ons | | ------------- | ------------------------------------------------- | | **Active** | Yes | | **Trialing** | Yes — card was captured during trial checkout | | **Free plan** | Yes — first purchase prompts for a payment method | ## Manage add-ons Add-ons are managed through the dashboard, the customer portal, or the API. | Action | Where | | ------------------------------------- | ------------------------------------------------------------ | | **Create / update / archive add-ons** | Dashboard → Add-ons, or the `addons` API resource | | **Activate / deactivate** | Dashboard (subscription detail), Customer Portal, or the API | | **List active add-ons** | API, Dashboard, or Customer Portal | Activate or deactivate an add-on on a subscription via the SDK. Activation charges the prorated amount for the current period; deactivation stops the feature immediately with no refund. ```typescript await commet.subscriptions.activateAddon({ id: 'sub_abc123', addonId: 'adn_xyz789', }) await commet.subscriptions.deactivateAddon({ id: 'sub_abc123', addonId: 'adn_xyz789', }) ``` REST equivalents: `POST /api/v1/subscriptions/{id}/addons` and `DELETE /api/v1/subscriptions/{id}/addons/{addonId}`. Add-ons themselves support full CRUD through the `addons` resource: `commet.addons.list`, `get`, `create`, `update`, and `delete`. ## Feature access Add-on features work exactly like plan features — no special handling needed: ### TypeScript ```typescript // Check boolean add-on const sso = await commet.featureAccess.get({ customerId: 'user_123', code: 'sso', }) // { allowed: true, type: 'boolean', enabled: true } // Track metered add-on usage await commet.usage.track({ customerId: 'user_123', featureCode: 'sms_messages', value: 50, }) // List all features (plan + add-ons combined) const features = await commet.featureAccess.list({ customerId: 'user_123', }) ``` ### Python ```python # Check boolean add-on sso = commet.feature_access.get('sso', customer_id='user_123') # sso.allowed == True, sso.type == 'boolean' # Track metered add-on usage commet.usage.track(customer_id='user_123', feature_code='sms_messages', value=50) # List all features (plan + add-ons combined) features = commet.feature_access.list(customer_id='user_123') ``` ### Go ```go // Check boolean add-on sso, _ := client.FeatureAccess.Get(ctx, "sso", &commet.GetFeatureAccessParams{ CustomerID: "user_123", }) // sso.Allowed == true, *sso.Type == "boolean" // Track metered add-on usage value := 50 client.Usage.Track(ctx, &commet.TrackUsageParams{ CustomerID: "user_123", FeatureCode: "sms_messages", Value: &value, }) // List all features (plan + add-ons combined) features, _ := client.FeatureAccess.List(ctx, &commet.ListFeatureAccessParams{ CustomerID: "user_123", }) ``` ### Java ```java // Check boolean add-on var sso = commet.featureAccess() .get("sso", GetFeatureAccessParams.builder("user_123").build()); // sso.allowed() == true // Track metered add-on usage commet.usage().track( TrackUsageParams.builder("sms_messages", "user_123").value(50.0).build() ); // List all features (plan + add-ons combined) var features = commet.featureAccess() .list(ListFeatureAccessParams.builder("user_123").build()); ``` ### PHP ```php // Check boolean add-on $sso = $commet->featureAccess->get('sso', 'user_123'); // $sso->allowed === true, $sso->enabled === true // Track metered add-on usage $commet->usage->track( featureCode: 'sms_messages', customerId: 'user_123', value: 50, ); // List all features (plan + add-ons combined) $features = $commet->featureAccess->list('user_123'); ``` ### cURL ```bash # Check boolean add-on curl "https://commet.co/api/v1/feature-access/sso?customerId=user_123" \ -H "x-api-key: $COMMET_API_KEY" # Track metered add-on usage curl -X POST https://commet.co/api/v1/usage/events \ -H "x-api-key: $COMMET_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "featureCode": "sms_messages", "customerId": "user_123", "value": 50 }' # List all features (plan + add-ons combined) curl "https://commet.co/api/v1/feature-access?customerId=user_123" \ -H "x-api-key: $COMMET_API_KEY" ``` ## Billing behavior ### Activation charge When a customer activates a $50/month add-on on day 11 of a 31-day period (20 days remaining): | | Value | | ---------------- | -------------------------- | | **Full price** | $50.00 | | **Prorated** | $50 × (20/31) = **$32.26** | | **Invoice type** | `addon_activation` | The charge goes through the subscription's payment provider immediately with its own invoice. ### Recurring invoices Starting from the next full billing cycle, the add-on base price appears as a separate line in the plan's invoice: ``` Plan Pro (base) $99.00 API Calls: 12,500 (2,500 overage × $0.01) $25.00 Add-ons SMS Channel (base) $15.00 SMS: 1,800 (800 overage × $0.03) $24.00 SSO $50.00 Subtotal $213.00 ``` ### Multi-currency Add-on prices are defined in USD. For non-USD subscriptions, the price is converted using the plan's exchange rate — the same mechanism used for plan base prices. ## Customer portal Customers can self-service add-ons from the portal: - **Available add-ons** — see compatible add-ons with pricing - **Activate** — confirmation dialog with prorated charge preview - **Active add-ons** — manage active add-ons - **Deactivate** — instant, no refund > **Note** > > Add-ons whose feature already exists in the customer's plan are hidden from the portal automatically. ## Learn more - [How Does Billing Work](/docs/how-does-billing-work) ## Related - [Consumption Models](/docs/consumption-models) — Metered, Credits, and Balance explained - [Configure Features](/docs/configure-features) — Define features that add-ons can unlock - [Credit Packs](/docs/credit-packs) — Another way to extend plan capabilities - [Manage Subscriptions](/docs/manage-subscriptions) — Subscription lifecycle and management - [Customer Portal](/docs/customer-portal) — Where customers activate add-ons # Balance and Top-Ups (/docs/balance-and-top-ups) The Balance consumption model gives a subscription a monetary allowance. Billable usage draws down that balance using each feature's configured price. ## Configure a balance plan In **Plans**, choose **Balance**, set the amount included at each reset, and add billable features. Use [AI Token Billing](/docs/ai-token-billing) when model token cost and margin determine the charge. Plan balance resets monthly for monthly, quarterly, yearly, free, and one-time plans. Weekly plans reset every seven days. Purchased top-ups do not survive the next reset. ## Adjust balance without charging Use an adjustment for a support correction, migration, or administrative grant: **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const balanceAdjustment = await commet.subscriptions.adjustBalance({ id: "sub_xxx", amount: 5000, }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") balance_adjustment = commet.subscriptions.adjust_balance( "sub_xxx", amount=5000, ) ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() balanceAdjustment, err := client.Subscriptions.AdjustBalance(ctx, "sub_xxx", &commet.AdjustBalanceParams{ Amount: 5000, }) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.AdjustBalanceParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var balanceAdjustment = commet.subscriptions().adjustBalance( "sub_xxx", AdjustBalanceParams.builder(5000L).build() ); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $balanceAdjustment = $commet->subscriptions->adjustBalance( id: 'sub_xxx', amount: 5000, ); ``` A positive amount adds balance; a negative amount removes it. Adjustments do not charge the customer's payment method, so keep the reason specific. ## Charge for a top-up **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const balanceTopup = await commet.subscriptions.topupBalance({ id: "sub_xxx", amount: 5000, }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") balance_topup = commet.subscriptions.topup_balance( "sub_xxx", amount=5000, ) ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() balanceTopup, err := client.Subscriptions.TopupBalance(ctx, "sub_xxx", &commet.TopupBalanceParams{ Amount: 5000, }) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.TopupBalanceParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var balanceTopup = commet.subscriptions().topupBalance( "sub_xxx", TopupBalanceParams.builder(5000L).build() ); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $balanceTopup = $commet->subscriptions->topupBalance( id: 'sub_xxx', amount: 5000, ); ``` A top-up charges the payment method attached to the subscription. Customers can also buy balance from the [Customer Portal](/docs/customer-portal). ## Enforce before spending Check usage before work that should not run with insufficient balance, then track only completed work. Use a stable idempotency key so a retry does not deduct twice. Use **Credits** instead when customers should buy product-specific units that persist across resets. # Configure Features (/docs/configure-features) Features are reusable capabilities that can be attached to several plans with different limits and prices. ## Feature types | Type | Use it for | | --------- | ----------------------------------------------- | | `boolean` | On/off capabilities such as SSO | | `usage` | Metered quantities such as API calls | | `seats` | Per-user licenses | | `quota` | Durable integer balances that can rise and fall | The feature code is the identifier used by Feature Access, Usage, Seats, and Quota. Use lowercase letters, numbers, and underscores. ## Current feature state Use `featureAccess.get` to read whether the customer currently has access and the feature's current counters: **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const featureAccess = await commet.featureAccess.get({ code: "api_calls", customerId: "user_123", }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") feature_access = commet.feature_access.get( "api_calls", customer_id="user_123", ) ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() featureAccess, err := client.FeatureAccess.Get(ctx, "api_calls", &commet.GetFeatureAccessParams{ CustomerID: "user_123", }) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.GetFeatureAccessParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var featureAccess = commet.featureAccess().get( "api_calls", GetFeatureAccessParams.builder("user_123").build() ); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $featureAccess = $commet->featureAccess->get( code: 'api_calls', customerId: 'user_123', ); ``` The exact response is discriminated by feature `type`. It can include enabled state, usage, included units, remaining units, seats, or quota values. Use the list operation to retrieve the customer's complete current feature state: **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const response = await commet.featureAccess.list({ customerId: "user_123" }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") feature_access_list_result = commet.feature_access.list(customer_id="user_123") ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() featureAccessListResult, err := client.FeatureAccess.List(ctx, &commet.ListFeatureAccessParams{ CustomerID: "user_123", }) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.ListFeatureAccessParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var featureAccessListResult = commet.featureAccess().list( ListFeatureAccessParams.builder("user_123").build() ); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $featureAccessListResult = $commet->featureAccess->list(customerId: 'user_123'); ``` Feature Access lists return the standard `{ object, data, hasMore, nextCursor }` envelope. ## Prospective consumption Use `usage.check` before an action when you need to know whether a specific quantity can be consumed and what it would cost: **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const usageCheck = await commet.usage.check({ customerId: "user_123", featureCode: "api_calls", }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") usage_check = commet.usage.check( customer_id="user_123", feature_code="api_calls", ) ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() usageCheck, err := client.Usage.Check(ctx, &commet.CheckUsageAvailabilityParams{ CustomerID: "user_123", FeatureCode: "api_calls", }) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.CheckUsageAvailabilityParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var usageCheck = commet.usage().check( CheckUsageAvailabilityParams.builder("user_123", "api_calls").build() ); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $usageCheck = $commet->usage->check( customerId: 'user_123', featureCode: 'api_calls', ); ``` The response is discriminated by the plan's consumption model: - `metered` reports current, remaining, included, and overage pricing. - `credits` reports the estimated credit cost and available pools. - `balance` reports the estimated monetary amount and current balance. Do not use Feature Access as a substitute for this prospective check. Feature Access describes current state; Usage Check evaluates the proposed consumption. ## Track the consumption Once the action succeeds, record it through Usage: **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const usageEvent = await commet.usage.track({ featureCode: "api_calls", customerId: "user_123", model: "example", inputTokens: 1, outputTokens: 1, }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") usage_event = commet.usage.track( feature_code="api_calls", customer_id="user_123", model="example", input_tokens=1, output_tokens=1, ) ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() usageEvent, err := client.Usage.Track(ctx, &commet.TrackUsageParams{ FeatureCode: "api_calls", CustomerID: "user_123", Model: func(value string) *string { return &value }("example"), InputTokens: func(value int) *int { return &value }(1), OutputTokens: func(value int) *int { return &value }(1), }) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.TrackUsageParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var usageEvent = commet.usage().track( TrackUsageParams.builder("api_calls", "user_123").model("example").inputTokens(1L).outputTokens(1L).build() ); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $usageEvent = $commet->usage->track( featureCode: 'api_calls', customerId: 'user_123', model: 'example', inputTokens: 1, outputTokens: 1, ); ``` Use the caller-owned `eventId` for the business event when retries must deduplicate it. Request idempotency is a separate transport option that maps to `Idempotency-Key`. ## Related - [Track Usage](/docs/track-usage) - [Consumption Models](/docs/consumption-models) - [Seat Management](/docs/seat-management) - [Quota Management](/docs/quota-management) # Consumption Models (/docs/consumption-models) Every plan uses one consumption model that defines how customers consume features and how they're billed. Models are mutually exclusive. ## The three models | Model | Description | Example Products | | ----------- | ----------------------------------------------------------------------------- | --------------------------- | | **Metered** | Base price + included usage. Overage charged at period end | AWS, Twilio, SendGrid | | **Credits** | Base price includes credits. Usage consumes credits. Buy packs when exhausted | ChatGPT, Midjourney, Jasper | | **Balance** | Base price becomes a spending balance. Usage deducts real dollars | Google Cloud, Anthropic | ## Metered Customers pay a base price and get included usage. Overage beyond the included amount is charged at the end of the billing period. | Feature | Included | Overage Price | | ----------- | ------------ | ---------------- | | API Calls | 10,000/month | $0.01 per call | | Storage | 100 GB | $0.10 per GB | | Email Sends | 50,000/month | $0.001 per email | ## Credits Customers receive credits with their subscription. Feature usage consumes credits. When credits run out, customers can purchase [Credit Packs](/docs/credit-packs) or wait for the next billing cycle. | Feature | Credits per Use | | ------------------- | --------------- | | AI Image Generation | 10 credits | | AI Text Generation | 2 credits | | AI Voice Synthesis | 25 credits | > **Note** > > Plan credits reset each billing period. Credits purchased via Credit Packs **never expire**. ## Balance Customers pay a base price that becomes their spending balance. Feature usage costs real money deducted from the balance. Overage is charged at period end. Balance supports two pricing modes per feature: | Pricing Mode | How price is determined | Best for | | --------------- | ------------------------------------------------------- | ------------------------------ | | **Fixed Price** | You set a price per unit | API calls, storage, processing | | **AI Model** | Commet calculates from model token prices + your margin | AI-powered features | ### Fixed pricing | Feature | Cost per Use | | --------------------------- | ------------ | | API Call | $0.001 | | Image Processing | $0.05 | | Video Encoding (per minute) | $0.10 | ### AI Model pricing Set a margin percentage instead of a fixed price. Commet looks up the model's token cost and applies your margin automatically. See [AI Token Billing](/docs/ai-token-billing) for details. ## Comparison | Aspect | Metered | Credits | Balance | | -------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------ | ----------------------------------------- | | **When exceeded** | Overage at period end | Blocked or buy packs | Overage at period end | | **Reset behavior** | Usage resets to 0 each period | Plan credits reset each period | Balance resets to plan amount each period | | **Purchased extras** | N/A | Credits persist forever | Top-ups reset at period | | **Reset frequency** | Weekly plans reset every 7 days. Monthly, quarterly, yearly, one-time, and free plans reset monthly | Same | Same | | **Customer portal** | View usage | Buy credit packs | Add balance (top-up) | ## Overage restrictions by plan type | Plan type | Overage | | -------------------------- | ------------------------------------------------------------------------- | | **Paid plan** | Fully supported | | **Free plan** | **Not allowed** — usage is blocked at included limits | | **Trial** (on a paid plan) | **Blocked during trial** — activates when the subscription becomes active | See [Free Plans](/docs/how-do-free-plans-work-without-payment) and [Trials](/docs/how-do-trial-periods-work) for details. ## Learn more - [How Does Billing Work](/docs/how-does-billing-work) ## Related - [Manage Plans](/docs/create-plans) — Create plans with consumption models - [Credit Packs](/docs/credit-packs) — Configure credit packages for credits-based plans - [Configure Features](/docs/configure-features) — Define what customers can access - [Customer Portal](/docs/customer-portal) — Where customers manage their consumption # Manage Plans (/docs/create-plans) Plans are pre-configured billing packages that combine pricing, features, and billing intervals. Assign a plan to a customer and Commet creates the subscription, customer portal, and recurring invoices automatically. ## Plan components | Component | Description | Example | | ---------------------- | ------------------------------------------------- | ------------------------------ | | **Name & Description** | Customer-facing display information | "Pro Plan — For growing teams" | | **Consumption Model** | How customers consume and pay for features | Metered, Credits, or Balance | | **Prices** | Pricing options by billing interval | $99/month, $899/year | | **Features** | What's included — boolean, metered, or seat-based | API Calls (10k), SSO, 5 Seats | | **Trial Days** | Optional free trial period per interval | 14 days | | **Visibility** | Public (pricing page) or private (internal use) | Public or Private | ## Free plans A free plan has a price of $0 and no billing cycle. Customers are activated immediately without checkout. Free plans have one restriction: **overage cannot be configured**. Features on a free plan always block usage at the included limit. See [How Do Free Plans Work](/docs/how-do-free-plans-work-without-payment) for details. ## Create a plan in the dashboard Go to **Plans** and click **Create Plan**. Fill in each component from the table above, then save. For detailed feature configuration, see [Configure Features](/docs/configure-features). ## Retrieve plans via SDK ### TypeScript ```typescript const plans = await commet.plans.list() ``` ### Python ```python plans = commet.plans.list() ``` ### Go ```go plans, err := client.Plans.List(ctx, nil) ``` ### Java ```java var plans = commet.plans().list(); ``` ### PHP ```php $plans = $commet->plans->list(); ``` ### cURL ```bash curl https://commet.co/api/v1/plans \ -H "x-api-key: $COMMET_API_KEY" ``` Include private plans: ### TypeScript ```typescript const plans = await commet.plans.list({ includePrivate: 'true' }) ``` ### Python ```python plans = commet.plans.list(include_private="true") ``` ### Go ```go includePrivate := "true" plans, err := client.Plans.List(ctx, &commet.ListPlansParams{ IncludePrivate: &includePrivate, }) ``` ### Java ```java var plans = commet.plans().list( ListPlansParams.builder().includePrivate("true").build() ); ``` ### PHP ```php $plans = $commet->plans->list(includePrivate: 'true'); ``` ### cURL ```bash curl "https://commet.co/api/v1/plans?includePrivate=true" \ -H "x-api-key: $COMMET_API_KEY" ``` Get a specific plan: ### TypeScript ```typescript const plan = await commet.plans.get({ id: 'pln_xxx' }) ``` ### Python ```python plan = commet.plans.get("pln_xxx") ``` ### Go ```go plan, err := client.Plans.Get(ctx, "pln_xxx") ``` ### Java ```java var plan = commet.plans().get("pln_xxx"); ``` ### PHP ```php $plan = $commet->plans->get('pln_xxx'); ``` ### cURL ```bash curl https://commet.co/api/v1/plans/pln_xxx \ -H "x-api-key: $COMMET_API_KEY" ``` ## Learn more - [How Does Billing Work](/docs/how-does-billing-work) - [How Do Free Plans Work Without Payment](/docs/how-do-free-plans-work-without-payment) ## Related - [Consumption Models](/docs/consumption-models) — Metered, Credits, and Balance explained - [Credit Packs](/docs/credit-packs) — Purchasable credit packages - [Plan Groups](/docs/plan-groups) — Enable self-service upgrades and downgrades - [Configure Features](/docs/configure-features) — Boolean, metered, and seat features - [Manage Subscriptions](/docs/manage-subscriptions) — Assign plans to customers - [Customer Portal](/docs/customer-portal) — Self-service billing portal # Credit Packs (/docs/credit-packs) Credit Packs are additional credit packages customers can purchase when they run out of included plan credits. Only available for plans using the **Credits** [consumption model](/docs/consumption-models). ## Credit pack components | Component | Description | Example | | ------------- | ----------------------------- | ---------------------------- | | **Pack Name** | Customer-facing name | "Starter Pack", "Power Pack" | | **Credits** | Number of credits in the pack | 100, 500, 2000 | | **Price** | How much the pack costs | $10.00, $40.00 | ## Availability by subscription status Credit packs can be purchased on any subscription with a payment method — including during a trial and on free plans. Free plan customers are prompted to enter a payment method on their first purchase. ## Create credit packs in the dashboard Go to **Credit Packs** and click **Create Credit Pack**. Pack names must be unique within your organization. Packs are available to all credits-based plans. ## List credit packs via SDK ### TypeScript ```typescript const { data } = await commet.creditPacks.list() ``` ### Python ```python response = commet.credit_packs.list() ``` ### Go ```go result, err := client.CreditPacks.List(ctx) ``` ### Java ```java var creditPacks = commet.creditPacks().list(); ``` ### PHP ```php $result = $commet->creditPacks->list(); ``` ### cURL ```bash curl https://commet.co/api/v1/credit-packs \ -H "x-api-key: $COMMET_API_KEY" ``` **Response:** ```json { "success": true, "data": [ { "id": "cpk_abc123", "name": "Starter Pack", "description": "100 credits for light usage", "credits": 100, "price": 1000, "currency": "usd" } ] } ``` The `price` field is in **cents** (1000 = $10.00). Prices are always in USD. ## Related - [Consumption Models](/docs/consumption-models) — How credits-based billing works - [Manage Plans](/docs/create-plans) — Create plans with consumption models - [Customer Portal](/docs/customer-portal) — Where customers purchase credit packs - [Configure Features](/docs/configure-features) — Define credit costs per feature # One-Time Payments (/docs/one-time-payments) A one-time payment plan charges the customer once at checkout and never bills again for the plan base. Use it for lifetime deals, one-off purchases, or any plan where recurring billing doesn't apply. > **Note** > > This page covers one-time charges billed as a **plan**. To charge a customer once with **no subscription or plan** — a standalone invoice with tax and receipt — use [Accept One-Time Payments](/docs/accept-one-time-payments) instead. One-time plans behave like any other plan — they support [trials](/docs/trial-periods), [intro offers](/docs/introductory-offers), [add-ons](/docs/add-ons), and all [consumption models](/docs/consumption-models). The only differences are: | Aspect | Behavior | | -------------------- | ------------------------------------------------------------------------- | | **Billing** | Plan base charged once at checkout. Overage billed at each billing cycle. | | **Cancellation** | Not allowed — the subscription stays active permanently | | **Interval changes** | Not allowed — cannot switch from one-time to a recurring interval | ## Configure in the dashboard In the dashboard, go to **Plans**, edit a plan, and add a price with interval **One-time (lifetime)**. You can combine one-time prices with recurring intervals on the same plan — each price has its own interval. ## Create a one-time subscription ### TypeScript ```typescript const subscription = await commet.subscriptions.create({ customerId: 'user_123', planCode: 'pro', billingInterval: 'one_time', }) if (!subscription.checkoutUrl) { throw new Error('Commet did not return a checkout URL') } redirect(subscription.checkoutUrl) ``` ### Python ```python subscription = commet.subscriptions.create( customer_id='user_123', plan_code='pro', billing_interval='one_time', ) if not subscription.checkout_url: raise RuntimeError('Commet did not return a checkout URL') redirect(subscription.checkout_url) ``` ### Go ```go planCode := "pro" billingInterval := "one_time" subscription, err := client.Subscriptions.Create(ctx, &commet.CreateSubscriptionParams{ CustomerID: "user_123", PlanCode: &planCode, BillingInterval: &billingInterval, }) if err != nil { log.Fatal(err) } if subscription.CheckoutURL == nil { log.Fatal("Commet did not return a checkout URL") } http.Redirect(w, r, *subscription.CheckoutURL, http.StatusSeeOther) ``` ### Java ```java CreateSubscriptionParams params = CreateSubscriptionParams.builder() .customerId("user_123") .planCode("pro") .billingInterval("one_time") .build(); var subscription = commet.subscriptions().create(params); if (subscription.checkoutUrl() == null) { throw new IllegalStateException("Commet did not return a checkout URL"); } redirect(subscription.checkoutUrl()); ``` ### PHP ```php $result = $commet->subscriptions->create( customerId: 'user_123', planCode: 'pro', billingInterval: 'one_time', ); if ($result->checkoutUrl === null) { throw new RuntimeException('Commet did not return a checkout URL'); } redirect($result->checkoutUrl); ``` ### cURL ```bash curl -X POST https://commet.co/api/v1/subscriptions \ -H "x-api-key: $COMMET_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "customerId": "user_123", "planCode": "pro", "billingInterval": "one_time" }' ``` The `billingInterval` must be `one_time` and the plan must have a one-time price configured. If the plan's default price is already one-time, you can omit the `billingInterval` parameter. ## Invoicing The initial invoice includes the plan base price as a one-time charge. After that, subsequent billing cycles only generate invoices for overage — the plan base is never charged again. | Invoice | What's included | | --------------------- | ------------------------------------------------------------------------ | | **Initial** | `plan_base` (once) + any applicable intro offer discount | | **Subsequent cycles** | `feature_overage`, `feature_seats`, `addon_base` — only if charges exist | If there's no overage or additional charges at a billing cycle, no invoice is generated. ## Customer portal In the [Customer Portal](/docs/customer-portal), one-time subscriptions display the interval as **Lifetime**. The cancel button is hidden since cancellation is not allowed. Customers can still change plans if the plan belongs to a [Plan Group](/docs/plan-groups). ## Related - [Manage Plans](/docs/create-plans) — Create and configure plans - [Manage Subscriptions](/docs/manage-subscriptions) — Full subscription lifecycle - [Invoices and Billing Cycles](/docs/invoices-and-billing-cycles) — How invoicing works - [Consumption Models](/docs/consumption-models) — Metered, Credits, and Balance explained # Plan Groups (/docs/plan-groups) Plan Groups let customers upgrade or downgrade between plans themselves through the [Customer Portal](/docs/customer-portal). Without a Plan Group, customers won't see upgrade/downgrade options in their portal. ## Plan group components | Component | Description | Example | | -------------- | ----------------------------------------- | ------------------ | | **Group Name** | Name for the collection | "Standard Plans" | | **Plans** | Plans in the group, ordered low to high | Free, Starter, Pro | | **Hierarchy** | Drag-and-drop order defining upgrade path | Lowest tier first | ## Create a plan group in the dashboard Go to **Plan Groups** and click **Create Plan Group**. Name the group, add your plans, then drag and drop to arrange them from lowest to highest tier. Each plan can only belong to one group. Plans with [Regional Prices](/docs/regional-prices) can be added, but all plans in a group must support the same set of currencies so any subscriber can move between them. This order defines the upgrade path and display order in the Customer Portal. ## Learn more - [What Happens When a Customer Changes Plans](/docs/what-happens-when-a-customer-changes-plans) ## Related - [Manage Plans](/docs/create-plans) — Create plans to add to groups - [Upgrade and Downgrade Plans](/docs/upgrade-and-downgrade-plans) — How plan changes work - [Customer Portal](/docs/customer-portal) — Where customers change plans # Custom Domains (/docs/custom-domains) Custom domains replace `commet.co` in new hosted checkout and Customer Portal links. Configure them under **Settings → Custom Domains**. Commet supports separate domains for each surface: - **Checkout**, such as `checkout.example.com`. - **Customer Portal**, such as `billing.example.com`. ## Add and verify a domain 1. Choose the surface and add a subdomain you own. 2. Add every DNS record shown by Commet at your DNS provider. This is normally one CNAME and one ownership TXT record. 3. Return to the dashboard and verify the domain. 4. Wait for DNS propagation if verification remains pending. The hostname becomes active only after verification. Existing hosted links remain valid; newly generated links use the verified custom domain for that surface. If the hostname is already registered to another Vercel account, Commet also shows a `vc-domain-verify` TXT record. Add that extra record before retrying verification. Use a dedicated subdomain rather than a root domain already serving your application. Removing a domain returns future hosted links to the default Commet domain. Custom-domain management is currently a dashboard workflow. Your application continues to request checkout and portal URLs through the same SDK operations. # Dashboard Metrics and Usage Events (/docs/dashboard-metrics-and-events) The dashboard summarizes confirmed billing records. Use it for operational review, not as a replacement for your accounting ledger or product analytics. | Metric | Current calculation | | ------------------------ | ------------------------------------------------------------------------------------------ | | **MRR** | Latest successful gross payment per active subscription, normalized by billing interval | | **Total revenue** | Successful gross transactions paid during the current calendar month | | **Revenue growth** | Percentage change between current and previous monthly recurring revenue | | **Churn** | Subscriptions canceled in the last 30 days divided by the eligible subscription population | | **Active subscriptions** | Current subscriptions whose persisted status is `active` | Metrics are cached and can lag recent writes briefly. Investigate an exact payment in **Transactions** and its accounting result in **Invoices**. ## Usage Events Open **Events** to inspect the usage records sent by your integration. Each row shows the event ID, feature, customer, timestamp, value, kind, and custom properties. Filter by customer or feature when reconciling a usage total. The event timestamp describes when usage occurred; creation time describes when Commet received it. Stable idempotency keys prevent retries from becoming duplicate billable events. See [Track Usage](/docs/track-usage) for implementation and [Invoices and Billing Cycles](/docs/invoices-and-billing-cycles) for settlement timing. # Members and Permissions (/docs/members-and-permissions) Manage team access under **Settings → Members** in the live organization. Membership lives on that live organization and grants the same team access to its sandboxes. Invitations can assign one of three roles. | Role | Intended responsibility | | ---------- | -------------------------------------------------------------------- | | **Owner** | Organization ownership and the most sensitive administrative actions | | **Admin** | Operational configuration and member management | | **Member** | Day-to-day access without organization administration | Owners and admins can invite members, change supported roles, cancel pending invitations, and remove members. An admin cannot manage the owner as if they were a normal member. Member changes are available only from the live organization. Switch back to live before inviting someone or changing access for the live-and-sandbox group. Invite each person with their own account. Do not share dashboard credentials or use a human login as an application credential; server workloads use [API keys](/docs/create-api-key). Permissions protect dashboard actions, but they do not narrow an API key to the member who created it. Rotate or delete keys independently when a workload or team boundary changes. # Organization Settings (/docs/organization-settings) An organization is an isolated billing environment. Its customers, catalog, API keys, providers, transactions, and webhooks do not cross into another organization. ## Sandbox and live Every live organization has a paired sandbox for development. Switch organizations from the dashboard header and confirm the environment before creating catalog data or API keys. IDs created in sandbox are not valid in live. ## Identity and URLs Under **Settings → Organization**, configure the visible name, logo, and slug. The slug identifies dashboard and hosted-page URLs; changing it does not change customer or subscription IDs. ## Customer communication Choose whether Commet sends transactional emails for payments, invoices, and subscription changes. When disabled, Commet sends no customer transactional email; your application must send its own messages from the corresponding webhooks. Set the email language and the internal notification recipient separately. The notification recipient receives important organization alerts and does not replace customer billing email addresses. ## Archive an organization Only an owner can archive an organization. Archiving a live organization also archives every sandbox under it, removes access, releases its slug, and removes its custom and email domains. Billing records are retained for compliance, but you cannot access them afterward and the action is irreversible. Export required records and disconnect workloads first. Do not archive an organization to clear test data; use a sandbox instead. API keys and most organization settings are environment-specific. See [Create an API Key](/docs/create-api-key) and [Testing](/docs/testing-sandbox). # Integrate with Django (/docs/integrate-with-django) Install the Commet Skill so your coding agent can integrate the current SDK and verify its work against the live API contract. ```bash npx skills add commet-labs/skills --skill commet ``` 1. ## Install ### pip ```bash pip install commet-sdk django ``` ### uv ```bash uv add commet-sdk django ``` ### poetry ```bash poetry add commet-sdk django ``` 2. ## Configure ```bash title=".env" COMMET_API_KEY=ck_sandbox_xxx ``` ```python title="billing/commet_client.py" import os from commet import Commet commet = Commet( api_key=os.environ["COMMET_API_KEY"], ) ``` 3. ## Subscribe ```python title="billing/views.py" import json from django.http import JsonResponse from django.views.decorators.http import require_POST from .commet_client import commet @require_POST def subscribe(request): data = json.loads(request.body) commet.customers.create( email=data["email"], id=data["customer_id"], ) subscription = commet.subscriptions.create( customer_id=data["customer_id"], plan_code="pro", ) return JsonResponse({"checkout_url": subscription.checkout_url}) ``` 4. ## Check Access ```python title="billing/views.py" def get_subscription(request, customer_id): sub = commet.subscriptions.get_active(customer_id=customer_id) if sub is None: return JsonResponse({"error": "no_active_subscription"}, status=404) return JsonResponse({"status": sub.status}) def check_feature(request, feature, customer_id): result = commet.feature_access.get(code=feature, customer_id=customer_id) return JsonResponse({"allowed": result.allowed}) ``` 5. ## Track Usage ```python title="billing/views.py" @require_POST def track_usage(request): data = json.loads(request.body) commet.usage.track( customer_id=data["customer_id"], feature_code="api_calls", value=1, ) return JsonResponse({"tracked": True}) ``` Usage is aggregated and billed at end of period. 6. ## Customer Portal ```python title="billing/views.py" from django.shortcuts import redirect def portal(request): result = commet.portal.get_url(customer_id="user_123") return redirect(result.portal_url) ``` 7. ## Webhooks ```python title="billing/views.py" import os from django.views.decorators.csrf import csrf_exempt from commet import Webhooks webhooks = Webhooks() @csrf_exempt @require_POST def handle_webhook(request): payload = webhooks.verify_and_parse( raw_body=request.body.decode(), signature=request.headers.get("x-commet-signature"), secret=os.environ["COMMET_WEBHOOK_SECRET"], ) if payload is None: return JsonResponse({"error": "Invalid signature"}, status=401) if payload["event"] == "subscription.activated": # handle activation pass return JsonResponse({"ok": True}) ``` 8. ## URLs ```python title="billing/urls.py" from django.urls import path from . import views urlpatterns = [ path("subscribe", views.subscribe), path("subscription/", views.get_subscription), path("features//", views.check_feature), path("usage", views.track_usage), path("portal", views.portal), path("webhooks/commet", views.handle_webhook), ] ``` ```python title="project/urls.py" from django.urls import path, include urlpatterns = [ path("billing/", include("billing.urls")), ] ``` ## Related - [Subscriptions](/docs/manage-subscriptions) - [Track Usage](/docs/track-usage) - [Customer Portal](/docs/customer-portal) - [SDK Reference](/docs/sdk-reference) # Integrate with FastAPI (/docs/integrate-with-fastapi) Install the Commet Skill so your coding agent can integrate the current SDK and verify its work against the live API contract. ```bash npx skills add commet-labs/skills --skill commet ``` 1. ## Install ### pip ```bash pip install commet-sdk fastapi uvicorn ``` ### uv ```bash uv add commet-sdk fastapi uvicorn ``` ### poetry ```bash poetry add commet-sdk fastapi uvicorn ``` 2. ## Configure ```bash title=".env" COMMET_API_KEY=ck_sandbox_xxx ``` ```python title="commet_client.py" import os from commet import Commet commet = Commet( api_key=os.environ["COMMET_API_KEY"], ) ``` 3. ## Subscribe ```python title="routes/billing.py" from fastapi import APIRouter, HTTPException from pydantic import BaseModel from commet_client import commet router = APIRouter(prefix="/billing") class SubscribeRequest(BaseModel): customer_id: str email: str @router.post("/subscribe") def subscribe(body: SubscribeRequest): commet.customers.create( email=body.email, id=body.customer_id, ) subscription = commet.subscriptions.create( customer_id=body.customer_id, plan_code="pro", ) return {"checkout_url": subscription.checkout_url} ``` 4. ## Check Access ```python title="routes/billing.py" @router.get("/subscription/{customer_id}") def get_subscription(customer_id: str): sub = commet.subscriptions.get_active(customer_id=customer_id) if sub is None: raise HTTPException(status_code=404, detail="No active subscription") return {"status": sub.status} @router.get("/features/{feature}/{customer_id}") def check_feature(feature: str, customer_id: str): result = commet.feature_access.get(code=feature, customer_id=customer_id) return {"allowed": result.allowed} ``` 5. ## Track Usage ```python title="routes/billing.py" class UsageRequest(BaseModel): customer_id: str @router.post("/usage") def track_usage(body: UsageRequest): commet.usage.track( customer_id=body.customer_id, feature_code="api_calls", value=1, ) return {"tracked": True} ``` Usage is aggregated and billed at end of period. 6. ## Customer Portal ```python title="routes/billing.py" from fastapi.responses import RedirectResponse @router.get("/portal") def portal(): result = commet.portal.get_url(customer_id="user_123") return RedirectResponse(result.portal_url) ``` 7. ## Webhooks ```python title="routes/webhooks.py" import os from fastapi import APIRouter, Request, Response from commet import Webhooks router = APIRouter() webhooks = Webhooks() @router.post("/webhooks/commet") async def handle_webhook(request: Request): raw_body = await request.body() payload = webhooks.verify_and_parse( raw_body=raw_body.decode(), signature=request.headers.get("x-commet-signature"), secret=os.environ["COMMET_WEBHOOK_SECRET"], ) if payload is None: return Response(status_code=401) if payload["event"] == "subscription.activated": # handle activation pass return Response(status_code=200) ``` 8. ## Start Server ```python title="main.py" from fastapi import FastAPI from routes.billing import router as billing_router from routes.webhooks import router as webhooks_router app = FastAPI() app.include_router(billing_router) app.include_router(webhooks_router) ``` ```bash uvicorn main:app --port 3000 ``` ## Related - [Subscriptions](/docs/manage-subscriptions) - [Track Usage](/docs/track-usage) - [Customer Portal](/docs/customer-portal) - [SDK Reference](/docs/sdk-reference) # Integrate with Flask (/docs/integrate-with-flask) Install the Commet Skill so your coding agent can integrate the current SDK and verify its work against the live API contract. ```bash npx skills add commet-labs/skills --skill commet ``` 1. ## Install ### pip ```bash pip install commet-sdk flask ``` ### uv ```bash uv add commet-sdk flask ``` ### poetry ```bash poetry add commet-sdk flask ``` 2. ## Configure ```bash title=".env" COMMET_API_KEY=ck_sandbox_xxx ``` ```python title="commet_client.py" import os from commet import Commet commet = Commet( api_key=os.environ["COMMET_API_KEY"], ) ``` 3. ## Subscribe ```python title="routes/billing.py" from flask import Blueprint, request, jsonify, redirect from commet_client import commet billing = Blueprint("billing", __name__) @billing.route("/subscribe", methods=["POST"]) def subscribe(): data = request.get_json() commet.customers.create( email=data["email"], id=data["customer_id"], ) subscription = commet.subscriptions.create( customer_id=data["customer_id"], plan_code="pro", ) return jsonify({"checkout_url": subscription.checkout_url}) ``` 4. ## Check Access ```python title="routes/billing.py" @billing.route("/subscription/") def get_subscription(customer_id): sub = commet.subscriptions.get_active(customer_id=customer_id) if sub is None: return jsonify({"error": "no_active_subscription"}), 404 return jsonify({"status": sub.status}) @billing.route("/features//") def check_feature(feature, customer_id): result = commet.feature_access.get(code=feature, customer_id=customer_id) return jsonify({"allowed": result.allowed}) ``` 5. ## Track Usage ```python title="routes/billing.py" @billing.route("/usage", methods=["POST"]) def track_usage(): data = request.get_json() commet.usage.track( customer_id=data["customer_id"], feature_code="api_calls", value=1, ) return jsonify({"tracked": True}) ``` Usage is aggregated and billed at end of period. 6. ## Customer Portal ```python title="routes/billing.py" @billing.route("/portal") def portal(): result = commet.portal.get_url(customer_id="user_123") return redirect(result.portal_url) ``` 7. ## Webhooks ```python title="routes/webhooks.py" import os from flask import Blueprint, request from commet import Webhooks webhooks_bp = Blueprint("webhooks", __name__) webhooks = Webhooks() @webhooks_bp.route("/webhooks/commet", methods=["POST"]) def handle_webhook(): payload = webhooks.verify_and_parse( raw_body=request.get_data(as_text=True), signature=request.headers.get("x-commet-signature"), secret=os.environ["COMMET_WEBHOOK_SECRET"], ) if payload is None: return "Invalid signature", 401 if payload["event"] == "subscription.activated": # handle activation pass return "", 200 ``` 8. ## Start Server ```python title="app.py" from flask import Flask from routes.billing import billing from routes.webhooks import webhooks_bp app = Flask(__name__) app.register_blueprint(billing, url_prefix="/billing") app.register_blueprint(webhooks_bp) if __name__ == "__main__": app.run(port=3000) ``` ## Related - [Subscriptions](/docs/manage-subscriptions) - [Track Usage](/docs/track-usage) - [Customer Portal](/docs/customer-portal) - [SDK Reference](/docs/sdk-reference) # Integrate with Python (/docs/integrate-with-python) Install the Commet Skill so your coding agent can integrate the current SDK and verify its work against the live API contract. ```bash npx skills add commet-labs/skills --skill commet ``` 1. ## Install ### pip ```bash pip install commet-sdk ``` ### uv ```bash uv add commet-sdk ``` ### poetry ```bash poetry add commet-sdk ``` 2. ## Configure ```bash title=".env" COMMET_API_KEY=ck_sandbox_xxx ``` ```python title="commet_client.py" import os from commet import Commet commet = Commet( api_key=os.environ["COMMET_API_KEY"], ) ``` 3. ## Create Customer and Subscribe `customers.create` is idempotent — if a customer with the same `id` already exists, it returns the existing record. ```python response = commet.customers.create( email="user@example.com", id="user_123", ) subscription = commet.subscriptions.create( customer_id="user_123", plan_code="pro", ) checkout_url = subscription.checkout_url ``` The customer is redirected to checkout to complete payment. 4. ## Check Access ```python sub = commet.subscriptions.get_active(customer_id="user_123") status = sub.status if sub else None access = commet.feature_access.get(code="custom_branding", customer_id="user_123") allowed = access.allowed ``` 5. ## Track Usage ```python commet.usage.track( customer_id="user_123", feature_code="api_calls", value=1, ) ``` Usage is aggregated and billed at end of period. 6. ## Multiple Operations Call each resource directly with `customer_id`: ```python commet.usage.track(customer_id="user_123", feature_code="api_calls", value=1) commet.feature_access.get(code="custom_branding", customer_id="user_123") commet.seats.add(customer_id="user_123", feature_code="editor", count=3) ``` ## Related - [Flask](/docs/integrate-with-flask) - [FastAPI](/docs/integrate-with-fastapi) - [Django](/docs/integrate-with-django) - [SDK Reference](/docs/sdk-reference) # Card Promotions (/docs/card-promotions) Card Promotions connect an existing Promotional Offer to one or more card BINs. The Offer owns the discount and duration; the Card Promotion controls who can receive it. This feature appears only for organizations with Card Promotions enabled. ## Configure the promotion 1. Create a [Promotional Offer](/docs/promotional-offers) whose first phase is a discount. 2. Open **Card Promotions** and choose that Offer. 3. Add the eligible 6- or 8-digit BINs. 4. Choose whether it applies to every billing interval or one specific interval. 5. Choose automatic discovery or require your application to preselect it. 6. Activate it when the commercial campaign begins. A BIN can belong to only one Card Promotion. Eight-digit matches take precedence over their six-digit fallback. ## Preselect it in checkout Pass the public `cardPromotionId` when creating the subscription: ```typescript const subscription = await commet.subscriptions.create( { customerId: 'cus_01J...', planId: 'pln_01J...', cardPromotionId: 'cpr_01J...', }, { idempotencyKey: 'checkout-acme-2026-08-16' }, ) ``` The checkout can show the benefit immediately, but it remains conditional. Commet reads the entered card and verifies its BIN, the configured billing interval, and the Offer's compatibility with the selected price. It removes the promotion before confirmation if those checks fail. Do not pass the underlying `offerId` to simulate a card promotion. A direct Offer is unconditional; `cardPromotionId` preserves the eligibility check and records the application source correctly. Use sandbox cards that match your configured BINs and verify both eligible and ineligible confirmation paths. # Introductory Offers (/docs/introductory-offers) An Introductory Offer is not a separate Offer type. It is an existing Offer attached to one base plan price with introductory placement. That placement adds two behaviors: - Commet selects the Offer automatically when no explicit override is supplied. - Commet checks introductory eligibility before applying it. Selectable price variants inherit the introductory placement from their base price. ## Compatible phases An Offer used as introductory may contain: 1. One optional `free_trial` phase at the beginning. 2. At most one finite `percentage` or `amount_off` phase. It cannot use `fixed_price`, multiple discount phases, or an open-ended discount. ```typescript const onboarding = await commet.offers.create({ name: 'Starter onboarding', phases: [ { type: 'free_trial', durationDays: 14 }, { type: 'percentage', durationCycles: 3, percentage: 3000 }, ], }) ``` In the Dashboard, open the plan price and choose **Add intro offer** to attach the existing Offer. A base price can have only one introductory placement at a time. ## Eligibility Current automatic eligibility excludes a customer who already has an `active` or `past_due` subscription in the organization. Other historical statuses do not create a lifetime ban. ## Selection at subscription creation ```typescript await commet.subscriptions.create({ customerId: 'user_123', planCode: 'starter', }) ``` When `offerId` is omitted, Commet resolves the selected price and applies its introductory Offer if the customer is eligible. An explicit `offerId` applies that Offer directly instead. It cannot be combined with `promoCode`, `customTrialDays`, or `skipTrial: true`. ## Trials and compatibility fields `trialDays`, `customTrialDays`, and `skipTrial` remain supported API shortcuts. Internally, an accepted trial is recorded as a `free_trial` phase in the Offer Application. Prefer catalog Offers when the same terms should be named, reused, inspected, or distributed consistently. ## Related - [Offers](/docs/offers) - [Promotional Offers](/docs/promotional-offers) - [Promo Codes](/docs/promo-codes) - [Trial Periods](/docs/trial-periods) - [Manage Subscriptions](/docs/manage-subscriptions) # Offers (/docs/offers) An Offer is a reusable sequence of benefits. It defines **what changes**—a trial, a discount, or a temporary fixed price—without deciding which plan uses it or how a customer receives it. ## Offer phases An Offer contains between 1 and 10 ordered phases: | Phase | Effect | | ------------- | --------------------------------------------------------------------------------- | | `free_trial` | Delays the first charge for a fixed number of days | | `percentage` | Reduces the plan base price by basis points | | `amount_off` | Subtracts an explicit amount for each configured currency | | `fixed_price` | Replaces the plan base price with an explicit amount for each configured currency | A free trial can appear only as the first phase. A discount phase may have a finite `durationCycles`, or `null` when it is the final open-ended phase. ```typescript const launchOffer = await commet.offers.create({ name: 'Launch sequence', phases: [ { type: 'free_trial', durationDays: 14 }, { type: 'percentage', durationCycles: 3, percentage: 5000 }, { type: 'percentage', durationCycles: null, percentage: 2000 }, ], metadata: { campaign: 'launch-2026' }, }) ``` The Offer does not contain `purpose`, `planPriceIds`, eligibility, or redemption rules. ## Apply the same Offer in different ways | Channel | How the Offer is selected | What the channel adds | | ---------------- | --------------------------------- | ------------------------------------------------------ | | **Introductory** | Attached to one base plan price | Automatic selection and new-customer eligibility | | **Promotional** | Your integration passes `offerId` | Explicit campaign, retention, or experiment assignment | | **Promo Code** | The customer enters a code | Redemption limits, plan restrictions, and expiration | The channel does not create another copy of the Offer. It only decides how the reusable terms reach a customer. ## Accepted terms When Commet quotes or applies an Offer, it records an immutable Offer Application. The application includes: - the Offer and selection source; - the target under `appliesTo`; - the resolved currency and amounts when available; - the exact accepted phases and dates. Editing, deactivating, or archiving the catalog Offer affects future applications only. Existing applications remain available for billing and audit. The v9 response is target-aware (`plan_price`, `addon`, or `credit_pack`) so new purchase surfaces do not require another breaking response change. Current public subscription, Introductory, direct, and Promo Code flows create `plan_price` applications. ## Related - [Introductory Offers](/docs/introductory-offers) - [Promotional Offers](/docs/promotional-offers) - [Promo Codes](/docs/promo-codes) - [Trial Periods](/docs/trial-periods) - [Offers API reference](/docs/api-reference/offers/list-offers) # Promo Codes (/docs/promo-codes) A Promo Code is a distribution channel. The referenced Offer owns the economic terms; the code owns who can redeem them and when. ## Create a compatible Offer A Promo Code can reference an Offer with exactly one `percentage` or `amount_off` phase. ```typescript const launchOffer = await commet.offers.create({ name: 'Launch 50', phases: [ { type: 'percentage', durationCycles: 2, percentage: 5000 }, ], }) ``` Trial, multi-phase, and `fixed_price` Offers can still be applied directly with `offerId`, but cannot be distributed through a Promo Code. ## Create the code ```typescript const promoCode = await commet.promoCodes.create({ code: 'LAUNCH50', offerId: launchOffer.id, billingInterval: 'monthly', maxRedemptions: 100, expiresAt: '2026-12-31T23:59:59.000Z', planIds: ['pln_pro'], }) ``` The Promo Code owns: - the customer-facing code; - optional plan and billing-interval restrictions; - the redemption limit; - expiration and active state. Updating the Offer changes future redemptions. Existing Offer Applications keep their accepted terms. ## Checkout behavior The customer enters the code during checkout. Commet validates the code, resolves the referenced Offer in the checkout currency, and records an Offer Application with source `promo_code`. If an eligible automatic Introductory Offer applies, the code is rejected with `intro_offer_active`. A Promo Code also cannot be combined with an explicit `offerId`. ```typescript await commet.subscriptions.create({ customerId: 'user_123', planCode: 'pro', promoCode: 'LAUNCH50', }) ``` ## Related - [Offers](/docs/offers) - [Promotional Offers](/docs/promotional-offers) - [Introductory Offers](/docs/introductory-offers) - [Manage Subscriptions](/docs/manage-subscriptions) # Promotional Offers (/docs/promotional-offers) A Promotional Offer is an Offer applied directly with `offerId`. Promotional describes the application channel, not a different catalog resource or `purpose`. The Offer stays independent from plans and prices until your integration selects it. ## Create the terms Direct Offers may combine a free trial with ordered discount or fixed-price phases: ```typescript const retentionOffer = await commet.offers.create({ name: 'Return to Pro', phases: [ { type: 'free_trial', durationDays: 7 }, { type: 'fixed_price', durationCycles: 2, prices: [ { currency: 'usd', amount: 2900 }, { currency: 'ars', amount: 3990000 }, ] }, { type: 'percentage', durationCycles: null, percentage: 2000 }, ], }) ``` Currency-specific phases require an explicit value for the checkout currency. Commet does not reuse a USD amount silently in another currency. ## Apply it directly ```typescript await commet.subscriptions.create({ customerId: 'user_123', planCode: 'pro', offerId: retentionOffer.id, }) ``` The Offer must be active, inside its availability window, and resolvable in the selected currency. It does not need a prior association with the plan or price. An explicit `offerId` overrides automatic introductory selection. It cannot be combined with `promoCode`, `customTrialDays`, or `skipTrial: true`. Immediate plan changes, plan-change previews, and supported reactivation flows also accept `offerId`. Scheduled plan changes do not accept an Offer. ## Apply it to an active subscription For retention, apply an Offer to a subscription that is already active. The discount phases start at the next billing cycle, so the current period stays untouched and the next invoice carries the discount: ```typescript await commet.subscriptions.applyOffer({ id: 'sub_123', offerId: retentionOffer.id, }) ``` Only one Offer applies at a time: while an accepted Offer still has active or upcoming discount phases, a new application is rejected. Once its phases are exhausted, the subscription accepts a new Offer. Offers with a free trial phase cannot be applied to an active subscription, and the discount applies to the plan base price only. The applied Offer belongs to that subscription and plan: it ends with a cancellation and a plan change removes it. The same operation on a subscription with a pending payment checkout quotes or replaces the checkout discount instead; there it accepts an optional `expiresAt` for the quote. ## Direct application or Promo Code? Use direct `offerId` when your application decides who receives the terms. Use a Promo Code when the customer should enter a code and the campaign needs redemption restrictions. A Promo Code can reference only an Offer with one `percentage` or `amount_off` phase. Multi-phase, trial, and `fixed_price` Offers remain available for direct application. ## Related - [Offers](/docs/offers) - [Introductory Offers](/docs/introductory-offers) - [Promo Codes](/docs/promo-codes) - [Manage Subscriptions](/docs/manage-subscriptions) # Markets and Regional Pricing (/docs/regional-prices) Commet has two complementary pricing layers: | Layer | Use it for | | -------------------- | ------------------------------------------------------------------------- | | **Currency Pricing** | Define plan, balance, and overage values in one presentment currency | | **Markets** | Group countries that should resolve explicit price and currency overrides | Currency Pricing remains the fallback. Add Markets when country segmentation or selectable variants require it. ## Create a Market A Market is a top-level Sales resource. It does not require a plan or price. ```typescript const argentina = await commet.markets.create({ name: 'Argentina', countryCodes: ['AR'], }) const southernCone = await commet.markets.create({ name: 'Southern Cone', countryCodes: ['BO', 'PY', 'UY'], }) ``` Countries can belong to only one active Market. The v9 REST paths are `/markets` and `/markets/{id}`. ## Add Market prices The base amount remains the fallback for every country without a Market override. ```typescript const basePrice = await commet.plans.addPrice({ id: 'pln_pro', billingInterval: 'monthly', price: 4900, isDefault: true, marketPrices: [ { marketGroupId: argentina.id, currency: 'ars', price: 6490000 }, { marketGroupId: southernCone.id, currency: 'usd', price: 3900 }, ], }) ``` At checkout, Commet resolves the Market from the request country. Sandbox checkout lets you change **Country** to test another Market; live checkout ignores that override. ## Selectable variants A variant inherits one base price and overrides only Markets already configured on that base: ```typescript const foundingCustomers = await commet.plans.addPrice({ id: 'pln_pro', billingInterval: 'monthly', inheritsFromPriceId: basePrice.id, metadata: { name: 'Founding customers' }, marketPrices: [ { marketGroupId: argentina.id, currency: 'ars', price: 3990000 }, ], }) ``` Pass `priceId` only when the customer deliberately selects that variant: ```typescript await commet.subscriptions.create({ customerId: 'user_123', planCode: 'pro', priceId: foundingCustomers.id, }) ``` Omitting `priceId` keeps normal default-price and Market resolution. ## Renewal and archival - A subscription stores the selected price identity. - Renewals use that price row's current catalog value. - A variant inherits every Market it does not override. - Archiving hides a price from new selection without breaking existing subscriptions. - A referenced Market cannot be deleted. Accepted Offer phases are a separate immutable snapshot; the selected catalog price remains editable. ## v8 compatibility API `2026-07-24` and SDK v8 keep `/pricing/market-groups` and `commet.pricing.*`. API `2026-07-31` and SDK v9 use the top-level Market surface. ## Related - [Offers](/docs/offers) - [Create Plans](/docs/create-plans) - [Manage Subscriptions](/docs/manage-subscriptions) - [Markets API reference](/docs/api-reference/markets/list-markets) # Trial Periods (/docs/trial-periods) A trial is a `free_trial` phase in an Offer. It is not a separate discount system. The phase records how long payment is delayed. The application channel decides who receives it: | Channel | Trial behavior | | ---------------------- | ---------------------------------------------------------------------- | | Introductory placement | Applied automatically to an eligible new subscription | | Direct `offerId` | Applied explicitly by your application | | `customTrialDays` | Creates customer-specific trial terms without a reusable catalog Offer | | `skipTrial: true` | Bypasses the automatic trial | Promo Codes cannot distribute trial phases. ## Reusable trial Create one Offer and reuse it: ```typescript const trialOffer = await commet.offers.create({ name: '14-day onboarding', phases: [ { type: 'free_trial', durationDays: 14 }, ], }) ``` Attach it to a base price in the Dashboard for automatic introductory selection, or pass it directly: ```typescript await commet.subscriptions.create({ customerId: 'user_123', planCode: 'pro', offerId: trialOffer.id, }) ``` An Offer can also continue into paid promotional phases after the trial. ## Customer-specific trial Use `customTrialDays` when the terms are exclusive to one subscription and do not need a reusable catalog identity: ```typescript await commet.subscriptions.create({ customerId: 'user_123', planCode: 'pro', customTrialDays: 21, }) ``` Commet persists the accepted trial as a `custom` Offer Application with a `free_trial` phase. ## Skip an automatic trial ```typescript await commet.subscriptions.create({ customerId: 'user_123', planCode: 'pro', skipTrial: true, }) ``` `offerId` cannot be combined with `customTrialDays` or `skipTrial: true`; the explicit Offer already defines the full sequence. ## Checkout and billing The customer provides a payment method during checkout but is not charged immediately. After setup succeeds: 1. the subscription becomes `trialing`; 2. `trialEndsAt` records the end of the free-trial phase; 3. Commet charges the current selected price when the trial ends; 4. any following Offer phase becomes active. Accepted Offer phases remain immutable, but the selected catalog price does not. If the price changes during the trial, the first paid charge uses the current value of the selected price. ## Related - [Offers](/docs/offers) - [Introductory Offers](/docs/introductory-offers) - [Promotional Offers](/docs/promotional-offers) - [How trial periods work](/docs/how-do-trial-periods-work) - [Handle Failed Payments](/docs/handle-failed-payments) # Manage Subscriptions (/docs/manage-subscriptions) Install the Commet Skill so your coding agent can implement the current subscription lifecycle and verify the result. ```bash npx skills add commet-labs/skills --skill commet ``` Subscriptions connect a customer to a plan and drive checkout, invoices, feature access, usage, and renewals. ## Lifecycle The persisted statuses are: | Status | Meaning | | ----------------- | --------------------------------------- | | `draft` | Created but not ready for billing | | `pending_payment` | Waiting for checkout | | `trialing` | Trial access is active | | `active` | Billing normally | | `past_due` | Renewal failed and dunning is active | | `paused` | Access and renewals paused until resume | | `canceled` | Billing and subscription access ended | ## Create **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const createdSubscription = await commet.subscriptions.create({ customerId: "user_123", planId: "pln_xxx", }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") created_subscription = commet.subscriptions.create( customer_id="user_123", plan_id="pln_xxx", ) ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() createdSubscription, err := client.Subscriptions.Create(ctx, &commet.CreateSubscriptionParams{ CustomerID: "user_123", PlanID: func(value string) *string { return &value }("pln_xxx"), }) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.CreateSubscriptionParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var createdSubscription = commet.subscriptions().create( CreateSubscriptionParams.builder("user_123").planId("pln_xxx").build() ); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $createdSubscription = $commet->subscriptions->create( customerId: 'user_123', planId: 'pln_xxx', ); ``` For a paid plan, redirect the customer to `checkoutUrl`. Free plans can activate without checkout and return `checkoutUrl: null`. The normal path needs only `customerId` and either `planCode` or `planId`. Optional selection fields are: | Field | When to send it | | -------------------------------- | ------------------------------------------------------------------------------------------ | | `billingInterval` | The customer selected a non-default interval | | `priceId` | The customer selected a concrete price variant | | `offerId` | Your application selected an Offer directly; it overrides automatic introductory selection | | `promoCode` | The customer entered a Promo Code | | `initialSeats` | You know the initial seat quantities at creation | | `skipTrial` or `customTrialDays` | You intentionally override the configured trial | Omitting `priceId` preserves default price and Market resolution. Omitting `offerId` preserves automatic Introductory Offer selection. A compatible `pending_payment` checkout may be reused. An incompatible pending selection can be replaced without duplicating a paid subscription. ## Retrieve current or historical state **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const subscription = await commet.subscriptions.getActive({ customerId: "user_123" }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") subscription = commet.subscriptions.get_active(customer_id="user_123") ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() subscription, err := client.Subscriptions.GetActive(ctx, &commet.GetActiveSubscriptionParams{ CustomerID: "user_123", }) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.GetActiveSubscriptionParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var subscription = commet.subscriptions().getActive( GetActiveSubscriptionParams.builder("user_123").build() ); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $subscription = $commet->subscriptions->getActive(customerId: 'user_123'); ``` `getActive` returns the customer's current subscription relationship or `null`. **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const subscription = await commet.subscriptions.get({ id: "sub_xxx" }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") subscription = commet.subscriptions.get("sub_xxx") ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() subscription, err := client.Subscriptions.Get(ctx, "sub_xxx") if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; var commet = Commet.builder().apiKey("ck_xxx").build(); var subscription = commet.subscriptions().get("sub_xxx"); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $subscription = $commet->subscriptions->get(id: 'sub_xxx'); ``` Use `get` with a public subscription ID to retrieve any persisted status, including `pending_payment`, `past_due`, and `canceled`. ## Pause and resume A pause temporarily stops subscription access and renewals without canceling the subscription. You can pause a paid recurring subscription in `active` or `trialing` status. Free, one-time, pending-payment, past-due, and canceled subscriptions are not eligible. Resolve any scheduled cancellation, scheduled plan change, or pending plan-change checkout before creating a pause. ### Choose when to pause | Mode | Access and billing | What happens on resume | | ------------ | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | `immediate` | Access stops immediately. The unused portion of the current paid or trial period is preserved. | Restores the remaining period without a new charge. Renewal moves to the end of that remaining time. | | `period_end` | Access continues until the current billing or trial period ends. The next renewal is skipped. | Charges a new period using the saved payment method. Access returns after successful settlement. | For example, an immediate pause with 10 paid days remaining preserves those 10 days. Resuming after a week still leaves 10 days before renewal. While paused, the subscription does not generate renewal invoices. A period-end pause may still produce a final invoice for pending usage, overage, or seat/quota adjustments from the period that ended. It does not charge advance capacity for the next period. ### Set the duration Set `durationDays` to a positive integer for an automatic resume attempt, or `null` for an indefinite pause that requires manual resume. The duration starts when the pause becomes effective, not when a future pause is scheduled. In the Dashboard, open the subscription detail and choose **Pause**. Choose when the pause starts, then select a suggested duration or enter a number of days, weeks, or calendar months. Leave the duration empty for an indefinite pause. The dialog shows the effective date and the automatic resume attempt date before you confirm. Customers can edit an existing pause, cancel a scheduled pause, and resume a paused subscription in the [Customer Portal](/docs/customer-portal), but cannot create pauses; Commet Admin exposes them in the subscription detail. Dashboard mutations require permission to edit subscriptions. Editing a pause derives an equivalent duration from its stored dates: weekly subscriptions prefer whole weeks; other subscriptions prefer whole calendar months, then weeks or days. Durations are always measured from the original effective date; a month ending on a shorter month uses its last day. The current pause shows its effective date and resume date. Use its actions to edit the duration, revoke a scheduled pause, or resume an effective pause. ### Create a pause This request schedules a seven-day pause at period end: ```bash curl -X POST https://commet.co/api/v1/subscriptions/sub_xxx/pause \ -H "x-api-key: $COMMET_API_KEY" \ -H "commet-version: 2026-08-27" \ -H "Content-Type: application/json" \ -d '{"mode":"period_end","durationDays":7}' ``` Use `"mode":"immediate"` to pause now, or `"durationDays":null` to leave the resume date open. Creation returns the updated subscription. A future pause has `pause.status: "scheduled"` while the subscription remains `active` or `trialing`. Once effective, the subscription is `paused` and `pause.status` is `"active"`. The `pause` object includes `mode`, `effectiveAt`, and `resumeAt`; it is `null` when there is no current or scheduled pause. ### Edit or revoke a pause Change the duration of a scheduled or effective pause: ```bash curl -X PATCH https://commet.co/api/v1/subscriptions/sub_xxx/pause \ -H "x-api-key: $COMMET_API_KEY" \ -H "commet-version: 2026-08-27" \ -H "Content-Type: application/json" \ -d '{"durationDays":14}' ``` This sets the total duration to 14 days from the original effective date; it does not add 14 days from today. Send `null` to make the pause indefinite. This operation does not change its mode. Revoke a pause before it becomes effective: ```bash curl -X DELETE https://commet.co/api/v1/subscriptions/sub_xxx/pause \ -H "x-api-key: $COMMET_API_KEY" \ -H "commet-version: 2026-08-27" ``` The subscription keeps its current period and access. An effective pause must be resumed instead. ### Resume and handle payment outcomes Use `resume` for a paused subscription; `reactivate` is a separate operation for canceled or past-due subscriptions. ```bash curl -X POST https://commet.co/api/v1/subscriptions/sub_xxx/resume \ -H "x-api-key: $COMMET_API_KEY" \ -H "commet-version: 2026-08-27" ``` The response includes `subscriptionId`, `invoiceId`, and `status`. An immediate resume has no new invoice, so `invoiceId` is `null`. | Response | Meaning | | ----------------------------- | ------------------------------------------------------------------------------------------------------ | | `200`, `status: "succeeded"` | Resume completed. Access is restored. | | `200`, `status: "processing"` | The resume payment or settlement is still pending. Do not restore access based on this response alone. | | `402`, `charge_failed` | The payment was declined. The subscription stays paused. | | `422`, `no_payment_method` | A period-end resume needs a saved payment method. | | `500`, `internal_error` | An internal failure prevented completion; this is distinct from a declined payment. | A declined resume keeps its invoice outstanding. Automatic payment retries follow days 1, 3, 5, and 7 from the original failure and reuse that invoice. The subscription stays paused during retries. If all retries fail, it is canceled and the resume invoice becomes uncollectible. Manual resume attempts do not consume automatic retry slots. A period-end resume uses the current base price when its new invoice is created. An outstanding resume invoice keeps its original amounts and included credits or balance on retry, even if the catalog changes again. Accepted Introductory Offers and discounts preserve their unconsumed terms; resume does not grant a new Introductory Offer or redeem a promo code again. See the [Pause API reference](/docs/api-reference/subscriptions/pause-subscription) and [Resume API reference](/docs/api-reference/subscriptions/resume-subscription) for the complete schemas. ### Keep access in sync Use [Feature Access](/docs/api-reference/features/get-feature-access) to authorize requests against the current subscription state. Subscribe to these webhooks for asynchronous updates in your application: - [`subscription.pause_scheduled`](/docs/webhooks/subscription-pause-scheduled): a future pause was scheduled; access has not stopped yet. - [`subscription.pause_updated`](/docs/webhooks/subscription-pause-updated): the pause duration changed. - [`subscription.pause_revoked`](/docs/webhooks/subscription-pause-revoked): the scheduled pause was removed. - [`subscription.paused`](/docs/webhooks/subscription-paused): the pause became effective and subscription access stopped. - [`subscription.resumed`](/docs/webhooks/subscription-resumed): resume completed and subscription access returned. - [`subscription.resume_failed`](/docs/webhooks/subscription-resume-failed): resume payment failed and the subscription remains paused. ## Cancel **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const subscription = await commet.subscriptions.cancel({ id: "sub_xxx" }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") subscription = commet.subscriptions.cancel("sub_xxx") ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() subscription, err := client.Subscriptions.Cancel(ctx, "sub_xxx", nil) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.CancelSubscriptionParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var subscription = commet.subscriptions().cancel( "sub_xxx", CancelSubscriptionParams.builder().build() ); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $subscription = $commet->subscriptions->cancel(id: 'sub_xxx'); ``` A normal paid active subscription schedules cancellation at period end unless `immediate: true` is sent. Free, pending-payment, and past-due relationships cancel immediately. Cancellation does not erase the stored subscription balance. Canceling a paused subscription is immediate and discards its preserved time. Pending usage, overage, or seat adjustments may still produce a final invoice. To cancel while a future pause is scheduled, revoke that pause first. ## Reverse a scheduled cancellation **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const subscription = await commet.subscriptions.uncancel({ id: "sub_xxx" }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") subscription = commet.subscriptions.uncancel("sub_xxx") ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() subscription, err := client.Subscriptions.Uncancel(ctx, "sub_xxx", nil) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.UncancelSubscriptionParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var subscription = commet.subscriptions().uncancel( "sub_xxx", UncancelSubscriptionParams.builder().build() ); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $subscription = $commet->subscriptions->uncancel(id: 'sub_xxx'); ``` `uncancel` works only before an end-of-period cancellation takes effect. It keeps the same subscription and current period. ## Recover a past-due subscription Reactivate retries the outstanding renewal charge and keeps the original billing relationship: **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const reactivatedSubscription = await commet.subscriptions.reactivate({ id: "sub_xxx" }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") reactivated_subscription = commet.subscriptions.reactivate("sub_xxx") ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() reactivatedSubscription, err := client.Subscriptions.Reactivate(ctx, "sub_xxx", nil) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.ReactivateSubscriptionParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var reactivatedSubscription = commet.subscriptions().reactivate( "sub_xxx", ReactivateSubscriptionParams.builder().build() ); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $reactivatedSubscription = $commet->subscriptions->reactivate(id: 'sub_xxx'); ``` If the customer must update their payment method, create a hosted recovery link: **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const recoveryLink = await commet.subscriptions.createRecoveryLink({ id: "sub_xxx" }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") recovery_link = commet.subscriptions.create_recovery_link("sub_xxx") ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() recoveryLink, err := client.Subscriptions.CreateRecoveryLink(ctx, "sub_xxx", nil) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.CreateSubscriptionRecoveryLinkParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var recoveryLink = commet.subscriptions().createRecoveryLink( "sub_xxx", CreateSubscriptionRecoveryLinkParams.builder().build() ); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $recoveryLink = $commet->subscriptions->createRecoveryLink(id: 'sub_xxx'); ``` Automatic dunning retries are anchored to the original decline on days 1, 3, 5, and 7. A successful retry returns the subscription to `active`. ## Reactivate a canceled subscription The same `reactivate` operation charges the saved payment method, reuses the subscription record, and starts a fresh period anchored to the reactivation date. You may pass an `offerId`; accepted phases are persisted as an immutable Offer Application. The selected price is not snapshotted. Future renewals use its current catalog value. Archiving that price prevents new selection but does not break the existing subscription. ## Related - [Grant Temporary Plan Access](/docs/plan-grants) - [Upgrade and Downgrade Plans](/docs/upgrade-and-downgrade-plans) - [Handle Failed Payments](/docs/handle-failed-payments) - [Regional and Market Pricing](/docs/regional-prices) - [Introductory Offers](/docs/introductory-offers) - [Customer Portal](/docs/customer-portal) # Grant Temporary Plan Access (/docs/plan-grants) A Plan Grant temporarily expands an active subscription's access. It does not change the subscription's plan, price, currency, billing anchor, period, invoice, payment method, or status. Use a Plan Grant when a selected customer needs higher limits or additional features for an evaluation, migration, support exception, or negotiated access period without changing what they are billed. ## How access is resolved The subscription keeps its immutable base-plan contract. The grant pins an immutable release of a higher plan and combines both contracts: - boolean features are enabled when either contract enables them; - included usage, quota, and seat limits use the higher allowance; - unlimited access applies when either contract is unlimited; and - paid overage is never introduced by the grant. When the grant ends, Commet immediately evaluates access against the base subscription again. Existing seats and recorded usage are not deleted. If the customer is already above a base-plan hard limit, further use is blocked until usage resets, capacity falls below the limit, or the subscription is upgraded normally. ## Eligibility The base subscription must be active and recurring. The target plan must belong to the same Plan Group and have a higher `sortOrder` than the base plan. The initial release supports metered plans with boolean features and hard-capped usage, quota, or seat limits. Commet rejects a grant when either plan uses: - Credits or Balance consumption; - paid overage; - AI model pricing; or - active subscription add-ons. The target plan may itself be free or paid. Its prices and billing intervals are irrelevant because the grant does not use them. ## Choose a duration | Duration | Behavior | | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `cycles` | Ends on an existing subscription billing boundary. One cycle means the current `currentPeriodEnd`; additional cycles advance from the same billing anchor. | | `until_date` | Ends at the exact ISO timestamp provided. The Dashboard treats the selected date as the end of that day in UTC. | | `until_revoked` | Continues until it is revoked or changed to a finite duration. | Creating or updating a grant never restarts the billing cycle. ## Grant access Create the grant with the customer, active subscription, and target plan public IDs: ```bash curl -X POST https://commet.co/api/v1/customers/cus_xxx/plan-grants \ -H "x-api-key: $COMMET_API_KEY" \ -H "commet-version: 2026-07-31" \ -H "Content-Type: application/json" \ -d '{ "subscriptionId": "sub_xxx", "planId": "pln_pro", "duration": "cycles", "durationCycles": 2, "reason": "Selected customer evaluation" }' ``` Access expands immediately. No checkout, invoice, charge, credit, or subscription plan change is created. ## Update the duration Use the grant public ID to change its remaining duration: ```bash curl -X PATCH https://commet.co/api/v1/customers/cus_xxx/plan-grants/grt_xxx \ -H "x-api-key: $COMMET_API_KEY" \ -H "commet-version: 2026-07-31" \ -H "Content-Type: application/json" \ -d '{ "duration": "until_date", "expiresAt": "2026-10-31T23:59:59.999Z", "reason": "Extended evaluation" }' ``` You can also send `until_revoked`, or `cycles` with a new `durationCycles` value. A new cycle count is resolved from the subscription's current period and original billing anchor. ## Revoke access Revocation restores base-plan access immediately: ```bash curl -X POST https://commet.co/api/v1/customers/cus_xxx/plan-grants/grt_xxx/revoke \ -H "x-api-key: $COMMET_API_KEY" \ -H "commet-version: 2026-07-31" \ -H "Content-Type: application/json" \ -d '{"reason":"Evaluation ended"}' ``` Revocation does not create a full-price obligation, re-anchor the subscription, or move it to `pending_payment`. ## Plan Grants, Offers, trials, and Invoice Credit | Need | Use | | ---------------------------------------------------------------------- | -------------- | | Temporarily expand features or limits without changing billing | Plan Grant | | Temporarily change reusable commercial terms | Offer | | Delay the first recurring charge as part of the subscription lifecycle | Trial | | Reduce invoice totals in one currency | Invoice Credit | ## Keep access in sync Listen for [`customer.state_changed`](/docs/webhooks/customer-state-changed). `plan_access_granted` reports activation, while `plan_access_ended` reports expiration or revocation. List a customer's grants to inspect their status and duration-change timeline: ```bash curl https://commet.co/api/v1/customers/cus_xxx/plan-grants \ -H "x-api-key: $COMMET_API_KEY" \ -H "commet-version: 2026-07-31" ``` See the [Plan Grants API reference](/docs/api-reference/customers/list-plan-grants) for exact request and response schemas. ## Related - [Manage Subscriptions](/docs/manage-subscriptions) - [Introductory Offers](/docs/introductory-offers) - [Customer State Changed](/docs/webhooks/customer-state-changed) # Upgrade and Downgrade Plans (/docs/upgrade-and-downgrade-plans) Customers change plans through the [Customer Portal](/docs/customer-portal) or from the dashboard. Commet handles proration, feature transitions, and billing adjustments automatically. ## Plan change behavior | Change | Behavior | Example | | -------------------------- | ----------------------------------------- | ------------------------- | | **Upgrade** | Applied immediately with prorated billing | Starter ($29) → Pro ($99) | | **Downgrade** | Takes effect at next renewal | Pro ($99) → Starter ($29) | | **Interval change (up)** | Applied immediately | Monthly → Yearly | | **Interval change (down)** | Takes effect at next renewal | Yearly → Monthly | Both plans must be in the same [Plan Group](/docs/plan-groups) for customers to change plans themselves through the portal. Free-to-paid changes require a new checkout — the customer is redirected to complete payment. An immediate upgrade is rejected with a seat-limit error when the customer's current seats exceed the target plan's included seats for a seat feature that is not unlimited and has overage disabled. Reduce seats first, or pick a plan with enough included seats. Interval-only changes and scheduled downgrades are not affected. If you need to expand features or limits temporarily without changing the subscription's plan, price, or billing cycle, use a [Plan Grant](/docs/plan-grants) instead. ## Dashboard From a customer's subscription detail page, click **Change Plan** and select the new plan. The same upgrade/downgrade rules apply. ## Learn more - [What Happens When a Customer Changes Plans](/docs/what-happens-when-a-customer-changes-plans) - [How Is Proration Calculated](/docs/how-is-proration-calculated-when-changing-plans) ## Related - [Manage Subscriptions](/docs/manage-subscriptions) — Create, get, and cancel subscriptions - [Plan Groups](/docs/plan-groups) — Group plans together for self-service upgrades - [Customer Portal](/docs/customer-portal) — Self-service billing portal for customers # AI Token Billing (/docs/ai-token-billing) AI Token Billing lets you charge customers based on the actual AI model tokens they consume. Commet maintains a catalog of 180+ AI model prices, calculates the cost per request, applies your margin, and deducts from the customer's balance. Only available for plans using the **Balance** [consumption model](/docs/consumption-models). ## How it works 1. Your app calls an AI model (GPT-4o, Claude, Gemini, etc.) 2. You report the tokens consumed to Commet 3. Commet looks up the model's token price, applies your margin, and deducts from the customer's balance 4. At the end of the billing period, any overdraft is invoiced as overage ## Setup ### 1. Create a feature Go to **Features**, create a metered feature (e.g., name: "AI Chat", code: `ai_chat`). ### 2. Add to a balance plan with AI Model pricing Go to **Plans**, open a plan with Balance consumption model, and add the feature. Select **AI Model** as the pricing mode and set your margin percentage. | Setting | Description | | ---------------- | ------------------------------------------------ | | **Pricing Mode** | Choose "AI Model" instead of "Fixed Price" | | **Margin** | Your markup on top of the model cost (e.g., 20%) | ### 3. Track usage Pass the `model`, `inputTokens`, and `outputTokens` when tracking. Commet handles the rest. ## Track AI tokens with the SDK Use the same `track()` method in every SDK. When you pass `model`, Commet switches to AI token pricing. ### TypeScript ```typescript await commet.usage.track({ customerId: "user_123", featureCode: "ai_chat", model: "gpt-4o", inputTokens: 1500, outputTokens: 300, }) ``` ### Python ```python commet.usage.track( customer_id="user_123", feature_code="ai_chat", model="gpt-4o", input_tokens=1500, output_tokens=300, ) ``` ### Go ```go inputTokens := 1500 outputTokens := 300 model := "gpt-4o" client.Usage.Track(ctx, &commet.TrackUsageParams{ FeatureCode: "ai_chat", CustomerID: "user_123", Model: &model, InputTokens: &inputTokens, OutputTokens: &outputTokens, }) ``` ### Java ```java commet.usage().track( TrackUsageParams.builder("ai_chat", "user_123") .model("gpt-4o") .inputTokens(1500L) .outputTokens(300L) .build() ); ``` ### PHP ```php $commet->usage->track( featureCode: 'ai_chat', model: 'gpt-4o', inputTokens: 1500, outputTokens: 300, customerId: 'user_123', ); ``` ### cURL ```bash curl -X POST https://commet.co/api/v1/usage/events \ -H "x-api-key: $COMMET_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "featureCode": "ai_chat", "customerId": "user_123", "model": "gpt-4o", "inputTokens": 1500, "outputTokens": 300 }' ``` For models with prompt caching, include cache tokens for accurate billing: ### TypeScript ```typescript await commet.usage.track({ customerId: "user_123", featureCode: "ai_chat", model: "anthropic/claude-sonnet-4.6", inputTokens: 10000, outputTokens: 2000, cacheReadTokens: 7000, cacheWriteTokens: 1000, }) ``` ### Python ```python commet.usage.track( customer_id="user_123", feature_code="ai_chat", model="anthropic/claude-sonnet-4.6", input_tokens=10000, output_tokens=2000, cache_read_tokens=7000, cache_write_tokens=1000, ) ``` ### Go ```go inputTokens := 10000 outputTokens := 2000 cacheReadTokens := 7000 cacheWriteTokens := 1000 model := "anthropic/claude-sonnet-4.6" client.Usage.Track(ctx, &commet.TrackUsageParams{ FeatureCode: "ai_chat", CustomerID: "user_123", Model: &model, InputTokens: &inputTokens, OutputTokens: &outputTokens, CacheReadTokens: &cacheReadTokens, CacheWriteTokens: &cacheWriteTokens, }) ``` ### Java ```java commet.usage().track( TrackUsageParams.builder("ai_chat", "user_123") .model("anthropic/claude-sonnet-4.6") .inputTokens(10000L) .outputTokens(2000L) .cacheReadTokens(7000L) .cacheWriteTokens(1000L) .build() ); ``` ### PHP ```php $commet->usage->track( featureCode: 'ai_chat', model: 'anthropic/claude-sonnet-4.6', inputTokens: 10000, outputTokens: 2000, customerId: 'user_123', cacheReadTokens: 7000, cacheWriteTokens: 1000, ); ``` ### cURL ```bash curl -X POST https://commet.co/api/v1/usage/events \ -H "x-api-key: $COMMET_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "featureCode": "ai_chat", "customerId": "user_123", "model": "anthropic/claude-sonnet-4.6", "inputTokens": 10000, "outputTokens": 2000, "cacheReadTokens": 7000, "cacheWriteTokens": 1000 }' ``` > **Note** > > Cache read tokens are significantly cheaper than regular input tokens. Commet prices each token type separately so customers pay fair rates. ## Automatic tracking with `@commet/ai-sdk` If you use the [Vercel AI SDK](https://ai-sdk.dev) (Node.js only), install `@commet/ai-sdk` to track tokens automatically. ```bash npm install @commet/ai-sdk ``` Wrap your model with `tracked()`. Every `generateText` and `streamText` call is tracked without extra code. ```typescript import { tracked } from "@commet/ai-sdk" import { Commet } from "@commet/node" import { openai } from "@ai-sdk/openai" import { generateText } from "ai" const commet = new Commet({ apiKey: process.env.COMMET_API_KEY! }) const model = tracked(openai("gpt-4o"), { commet, feature: "ai_chat", customerId: "user_123", }) const result = await generateText({ model, prompt: "Hello!" }) // Tokens tracked and balance deducted automatically ``` Works with any AI SDK provider: OpenAI, Anthropic, Google, and any model available through the Vercel AI Gateway. ## Parameters | Parameter | Type | Required | Description | | ------------------ | -------- | -------- | ------------------------------------------------------------------- | | `featureCode` | `string` | Yes | Event code of a metered feature | | `customerId` | `string` | Yes | Commet customer ID (`cus_xxx`) or your external ID | | `model` | `string` | Yes | AI model identifier (e.g., `gpt-4o`, `anthropic/claude-sonnet-4.6`) | | `inputTokens` | `number` | Yes | Number of input (prompt) tokens | | `outputTokens` | `number` | Yes | Number of output (completion) tokens | | `cacheReadTokens` | `number` | No | Cached input tokens read (cheaper rate) | | `cacheWriteTokens` | `number` | No | Cached input tokens written (higher rate) | | `eventId` | `string` | No | Caller-owned event ID for deduplicating business-event retries | ## Model identifier formats Commet accepts model identifiers in two formats: | Format | Example | When to use | | -------------- | ----------------------------- | ----------------------------------- | | Model ID only | `gpt-4o` | Direct provider SDK usage | | Provider/Model | `anthropic/claude-sonnet-4.6` | AI Gateway or multi-provider setups | ## Cost calculation For each request, Commet calculates: ``` inputCost = nonCachedInputTokens x inputPrice / 1M outputCost = outputTokens x outputPrice / 1M cacheCost = cacheReadTokens x cachePrice / 1M + cacheWriteTokens x cacheWritePrice / 1M subtotal = inputCost + outputCost + cacheCost total = subtotal x (1 + margin%) ``` ## AI model catalog Commet maintains a catalog of 180+ AI models with up-to-date token prices, synchronized daily from the Vercel AI Gateway. The catalog includes input, output, cache read, and cache write prices for each model. Supported providers include OpenAI, Anthropic, Google, Meta, Mistral, Cohere, and more. ## AI Costs dashboard View all AI token costs in the dashboard under **AI Costs**. Each entry shows the model used, token breakdown, cost calculation, margin applied, and total charged. ## Related - [Consumption Models](/docs/consumption-models) — How Balance model works - [Track Usage](/docs/track-usage) — Standard usage tracking - [Configure Features](/docs/configure-features) — Create metered features # Quota Management (/docs/quota-management) Quota tracks a durable, countable balance that rises and falls as customers create and delete — tasks, WhatsApp numbers, parallel automations. Commet includes an amount with the plan and bills per-unit overage automatically. ## Quota components | Component | Description | Example | | ---------------- | -------------------------------------- | ------------------------------------------ | | **Feature Code** | The quota resource you track | `tasks`, `whatsapp_numbers`, `automations` | | **Count** | Units to add, remove, or set | `5`, `10`, `50` | | **Billing** | Overage is billed per unit on the plan | $1/task/month | ## Dashboard Create a quota feature from **Features**, then **Create Feature**, and choose the **Quota** type. Feature codes must be created before use. View current balances on each customer's subscription detail page. ## Add to quota Defaults to 1 unit if `count` is omitted. ### TypeScript ```typescript await commet.quota.add({ customerId: 'user_123', featureCode: 'tasks', count: 5, }) ``` ### Python ```python commet.quota.add( feature_code='tasks', count=5, customer_id='user_123', ) ``` ### Go ```go customerID := "user_123" count := 5 client.Quota.Add(ctx, &commet.AddQuotaParams{ FeatureCode: "tasks", Count: &count, CustomerID: &customerID, }) ``` ### Java ```java commet.quota().add(AddQuotaParams.builder("tasks").customerId("user_123").count(5L).build()); ``` ### PHP ```php $commet->quota->add( featureCode: 'tasks', count: 5, customerId: 'user_123', ); ``` ### cURL ```bash curl -X POST https://commet.co/api/v1/usage/quota \ -H "x-api-key: $COMMET_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "customerId": "user_123", "featureCode": "tasks", "count": 5 }' ``` ## Remove from quota ### TypeScript ```typescript await commet.quota.remove({ customerId: 'user_123', featureCode: 'tasks', count: 2, }) ``` ### Python ```python commet.quota.remove( feature_code='tasks', count=2, customer_id='user_123', ) ``` ### Go ```go customerID := "user_123" count := 2 client.Quota.Remove(ctx, &commet.RemoveQuotaParams{ FeatureCode: "tasks", Count: &count, CustomerID: &customerID, }) ``` ### Java ```java commet.quota().remove(RemoveQuotaParams.builder("tasks").customerId("user_123").count(2L).build()); ``` ### PHP ```php $commet->quota->remove( featureCode: 'tasks', count: 2, customerId: 'user_123', ); ``` ### cURL ```bash curl -X DELETE https://commet.co/api/v1/usage/quota \ -H "x-api-key: $COMMET_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "customerId": "user_123", "featureCode": "tasks", "count": 2 }' ``` Quota can't go below zero — removing more than the current balance returns `400 insufficient_balance`. ## Set exact amount Use `set` when syncing the balance from your system. ### TypeScript ```typescript await commet.quota.set({ customerId: 'user_123', featureCode: 'tasks', count: 10, }) ``` ### Python ```python commet.quota.set( feature_code='tasks', count=10, customer_id='user_123', ) ``` ### Go ```go customerID := "user_123" client.Quota.Set(ctx, &commet.SetQuotaParams{ FeatureCode: "tasks", Count: 10, CustomerID: &customerID, }) ``` ### Java ```java commet.quota().set(SetQuotaParams.builder("tasks", 10L).customerId("user_123").build()); ``` ### PHP ```php $commet->quota->set( featureCode: 'tasks', count: 10, customerId: 'user_123', ); ``` ### cURL ```bash curl -X PUT https://commet.co/api/v1/usage/quota \ -H "x-api-key: $COMMET_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "customerId": "user_123", "featureCode": "tasks", "count": 10 }' ``` ## Get a quota allowance ### TypeScript ```typescript const allowance = await commet.quota.get({ customerId: 'user_123', featureCode: 'tasks', }) ``` ### Python ```python allowance = commet.quota.get( customer_id='user_123', feature_code='tasks', ) ``` ### Go ```go allowance, err := client.Quota.Get(ctx, &commet.GetQuotaAllowanceParams{ CustomerID: "user_123", FeatureCode: "tasks", }) ``` ### Java ```java var allowance = commet.quota() .get(GetQuotaAllowanceParams.builder("user_123", "tasks").build()); ``` ### PHP ```php $allowance = $commet->quota->get( customerId: 'user_123', featureCode: 'tasks', ); ``` ### cURL ```bash curl "https://commet.co/api/v1/usage/quota?customerId=user_123&featureCode=tasks" \ -H "x-api-key: $COMMET_API_KEY" ``` **Response:** ```json { "featureCode": "tasks", "current": 30, "included": 50, "remaining": 20, "billedQuantity": 0, "unlimited": false, "overageEnabled": true } ``` `current` is the live balance, `included` is the plan's free amount, `remaining` is what's left before overage, and `billedQuantity` is the extra units billed this period. Pass either a Commet ID (`cus_xxx`) or your external ID as `customerId`. One active subscription per customer is required. ## Get all allowances Returns an allowance for every quota feature on the customer's active subscription. ### TypeScript ```typescript const allowances = await commet.quota.getAll({ customerId: 'user_123', }) ``` ### Python ```python allowances = commet.quota.get_all( customer_id='user_123', ) ``` ### Go ```go allowances, err := client.Quota.GetAll(ctx, &commet.GetAllQuotaAllowancesParams{ CustomerID: "user_123", }) ``` ### Java ```java var allowances = commet.quota() .getAll(GetAllQuotaAllowancesParams.builder("user_123").build()); ``` ### PHP ```php $allowances = $commet->quota->getAll( customerId: 'user_123', ); ``` ### cURL ```bash curl "https://commet.co/api/v1/usage/quota/all?customerId=user_123" \ -H "x-api-key: $COMMET_API_KEY" ``` ## Learn more - [How Does Quota-Based Billing Work](/docs/how-does-quota-based-billing-work) ## Related - [Configure Features](/docs/configure-features) — Create quota features on your plans - [Manage Plans](/docs/create-plans) — Plans that include quota-based pricing - [Track Usage](/docs/track-usage) — Send usage events for metered features # Seat Management (/docs/seat-management) Seats are per-user licenses that let you charge based on team size. Commet tracks seat changes and bills them automatically — included seats at the start of the period, additional seats prorated. ## Seat components | Component | Description | Example | | ---------------- | -------------------------------------- | --------------------------- | | **Feature Code** | Category of user license | `editor`, `admin`, `viewer` | | **Count** | Number of seats to add, remove, or set | `5`, `10`, `50` | | **Billing** | Seats are billed per unit on the plan | $25/seat/month | ## Dashboard Seat features are created in **Features** → **Create Feature** with type **Seats** — the feature's code is what you pass as `featureCode`. Feature codes must be created in the dashboard before use. The **Seats** page shows seat balances and seat events per customer; balances also appear on each customer's subscription detail page. ## Add seats ### TypeScript ```typescript await commet.seats.add({ customerId: 'user_123', featureCode: 'editor', count: 5, }) ``` ### Python ```python commet.seats.add( feature_code='editor', count=5, customer_id='user_123', ) ``` ### Go ```go client.Seats.Add(ctx, &commet.AddSeatsParams{ FeatureCode: "editor", Count: 5, CustomerID: "user_123", }) ``` ### Java ```java commet.seats().add("editor", 5, "user_123", null); ``` ### PHP ```php $commet->seats->add( featureCode: 'editor', count: 5, customerId: 'user_123', ); ``` ### cURL ```bash curl -X POST https://commet.co/api/v1/seats \ -H "x-api-key: $COMMET_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "customerId": "user_123", "featureCode": "editor", "count": 5 }' ``` ## Remove seats ### TypeScript ```typescript await commet.seats.remove({ customerId: 'user_123', featureCode: 'editor', count: 2, }) ``` ### Python ```python commet.seats.remove( feature_code='editor', count=2, customer_id='user_123', ) ``` ### Go ```go client.Seats.Remove(ctx, &commet.RemoveSeatsParams{ FeatureCode: "editor", Count: 2, CustomerID: "user_123", }) ``` ### Java ```java commet.seats().remove("editor", 2, "user_123", null); ``` ### PHP ```php $commet->seats->remove( featureCode: 'editor', count: 2, customerId: 'user_123', ); ``` ### cURL ```bash curl -X DELETE https://commet.co/api/v1/seats \ -H "x-api-key: $COMMET_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "customerId": "user_123", "featureCode": "editor", "count": 2 }' ``` Seats cannot go below zero — removing more than the current count will fail. ## Set exact count Use `set` when syncing seat counts from your system. ### TypeScript ```typescript await commet.seats.set({ customerId: 'user_123', featureCode: 'editor', count: 10, }) ``` ### Python ```python commet.seats.set( feature_code='editor', count=10, customer_id='user_123', ) ``` ### Go ```go client.Seats.Set(ctx, &commet.SetSeatsParams{ FeatureCode: "editor", Count: 10, CustomerID: "user_123", }) ``` ### Java ```java commet.seats().set("editor", 10, "user_123", null); ``` ### PHP ```php $commet->seats->set( featureCode: 'editor', count: 10, customerId: 'user_123', ); ``` ### cURL ```bash curl -X PUT https://commet.co/api/v1/seats \ -H "x-api-key: $COMMET_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "customerId": "user_123", "featureCode": "editor", "count": 10 }' ``` ## Set all feature codes Use `setAll` to sync multiple feature codes at once. Useful when your system tracks all roles and you want a single call to reconcile. ### TypeScript ```typescript await commet.seats.setAll({ customerId: 'user_123', seats: { editor: 5, viewer: 20, admin: 2 }, }) ``` ### Python ```python commet.seats.set_all( seats={'editor': 5, 'viewer': 20, 'admin': 2}, customer_id='user_123', ) ``` ### Go ```go client.Seats.SetAll(ctx, &commet.BulkSetSeatsParams{ Seats: map[string]int{"editor": 5, "viewer": 20, "admin": 2}, CustomerID: "user_123", }) ``` ### Java ```java commet.seats().setAll(Map.of("editor", 5, "viewer", 20, "admin", 2), "user_123", null); ``` ### PHP ```php $commet->seats->setAll( seats: ['editor' => 5, 'viewer' => 20, 'admin' => 2], customerId: 'user_123', ); ``` ### cURL ```bash curl -X PUT https://commet.co/api/v1/seats/bulk \ -H "x-api-key: $COMMET_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "customerId": "user_123", "seats": { "editor": 5, "viewer": 20, "admin": 2 } }' ``` ## Get balance ### TypeScript ```typescript const balance = await commet.seats.getBalance({ customerId: 'user_123', featureCode: 'editor', }) ``` ### Python ```python balance = commet.seats.get_balance( feature_code='editor', customer_id='user_123', ) ``` ### Go ```go balance, err := client.Seats.GetBalance(ctx, &commet.GetSeatBalanceParams{ FeatureCode: "editor", CustomerID: "user_123", }) ``` ### Java ```java var balance = commet.seats().getBalance("editor", "user_123"); ``` ### PHP ```php $balance = $commet->seats->getBalance( featureCode: 'editor', customerId: 'user_123', ); ``` ### cURL ```bash curl "https://commet.co/api/v1/seats/balance?customerId=user_123&featureCode=editor" \ -H "x-api-key: $COMMET_API_KEY" ``` Pass either a Commet ID (`cus_xxx`) or your external ID as `customerId`. One active subscription per customer is required. ## Get all balances Returns balances for every feature code on the customer's active subscription. ### TypeScript ```typescript const balances = await commet.seats.getAllBalances({ customerId: 'user_123', }) // { editor: { included: 5, used: 3, ... }, viewer: { ... } } ``` ### Python ```python balances = commet.seats.get_all_balances( customer_id='user_123', ) ``` ### Go ```go balances, err := client.Seats.GetAllBalances(ctx, &commet.GetAllSeatBalancesParams{ CustomerID: "user_123", }) ``` ### Java ```java var balances = commet.seats().getAllBalances("user_123"); ``` ### PHP ```php $balances = $commet->seats->getAllBalances( customerId: 'user_123', ); ``` ### cURL ```bash curl "https://commet.co/api/v1/seats/balances?customerId=user_123" \ -H "x-api-key: $COMMET_API_KEY" ``` ## Learn more - [How Does Seat-Based Billing Work](/docs/how-does-seat-based-billing-work) ## Related - [Configure Features](/docs/configure-features) — Create seat features on your plans - [Manage Plans](/docs/create-plans) — Plans that include seat-based pricing - [Manage Subscriptions](/docs/manage-subscriptions) — Assign plans with initial seats # Track Usage (/docs/track-usage) Every metered feature has one code. Send that code as `featureCode`; Commet aggregates events for access, credits, balance, and billing. ## Track an event **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const usageEvent = await commet.usage.track({ featureCode: "api_calls", customerId: "user_123", model: "example", inputTokens: 1, outputTokens: 1, }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") usage_event = commet.usage.track( feature_code="api_calls", customer_id="user_123", model="example", input_tokens=1, output_tokens=1, ) ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() usageEvent, err := client.Usage.Track(ctx, &commet.TrackUsageParams{ FeatureCode: "api_calls", CustomerID: "user_123", Model: func(value string) *string { return &value }("example"), InputTokens: func(value int) *int { return &value }(1), OutputTokens: func(value int) *int { return &value }(1), }) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.TrackUsageParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var usageEvent = commet.usage().track( TrackUsageParams.builder("api_calls", "user_123").model("example").inputTokens(1L).outputTokens(1L).build() ); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $usageEvent = $commet->usage->track( featureCode: 'api_calls', customerId: 'user_123', model: 'example', inputTokens: 1, outputTokens: 1, ); ``` The request accepts: | Field | Required | Meaning | | ------------- | -------- | ---------------------------------------------------------- | | `featureCode` | Yes | Metered feature code | | `customerId` | Yes | Commet customer ID or your stable customer ID | | `value` | No | Numeric quantity; defaults to one for normal usage events | | `eventId` | No | Caller-owned business event ID used to deduplicate retries | | `timestamp` | No | ISO 8601 event time; defaults to now | | `properties` | No | String property entries for attribution and debugging | AI-model events use the same operation but send `model`, `inputTokens`, and `outputTokens` instead of `value`. ## Business event ID vs request idempotency `eventId` identifies the consumption event in your system. Reuse it when the same event is retried. Request idempotency protects one HTTP mutation and is passed through the SDK's request options as `idempotencyKey`. It becomes the `Idempotency-Key` header. Do not put that transport key in the usage body. ```typescript await commet.usage.track( { featureCode: 'api_calls', customerId: 'user_123', value: 1, eventId: 'request_01JXYZ', }, { idempotencyKey: 'delivery_01JXYZ' }, ) ``` ## Correct current-period usage Use `set` when your source of truth needs to replace the current metered total: **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const usageAdjustment = await commet.usage.set({ customerId: "user_123", featureCode: "api_calls", value: 1, }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") usage_adjustment = commet.usage.set( customer_id="user_123", feature_code="api_calls", value=1, ) ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() usageAdjustment, err := client.Usage.Set(ctx, &commet.SetUsageParams{ CustomerID: "user_123", FeatureCode: "api_calls", Value: 1, }) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.SetUsageParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var usageAdjustment = commet.usage().set( SetUsageParams.builder("user_123", "api_calls", 1L).build() ); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $usageAdjustment = $commet->usage->set( customerId: 'user_123', featureCode: 'api_calls', value: 1, ); ``` `value` is the desired total, not a delta. Commet records a signed adjustment and preserves the original event trail. This operation applies only to the active metered period. ## Check before consuming When access or cost depends on the proposed quantity, call Usage Check before performing the action: **TypeScript** ```typescript import { Commet } from "@commet/node"; const commet = new Commet({ apiKey: "ck_xxx" }); const usageCheck = await commet.usage.check({ customerId: "user_123", featureCode: "api_calls", }); ``` **Python** ```python from commet import Commet commet = Commet("ck_xxx") usage_check = commet.usage.check( customer_id="user_123", feature_code="api_calls", ) ``` **Go** ```go client, err := commet.New("ck_xxx") if err != nil { log.Fatal(err) } ctx := context.Background() usageCheck, err := client.Usage.Check(ctx, &commet.CheckUsageAvailabilityParams{ CustomerID: "user_123", FeatureCode: "api_calls", }) if err != nil { log.Fatal(err) } ``` **Java** ```java import co.commet.Commet; import co.commet.params.CheckUsageAvailabilityParams; var commet = Commet.builder().apiKey("ck_xxx").build(); var usageCheck = commet.usage().check( CheckUsageAvailabilityParams.builder("user_123", "api_calls").build() ); ``` **PHP** ```php use Commet\Commet; $commet = new Commet('ck_xxx'); $usageCheck = $commet->usage->check( customerId: 'user_123', featureCode: 'api_calls', ); ``` ## Related - [Configure Features](/docs/configure-features) - [AI Token Billing](/docs/ai-token-billing) - [Consumption Models](/docs/consumption-models) # validation_error The request failed validation. The response message, param, and details identify the invalid field, value, or field combination. - **Error type:** `validation_error` - **`code`:** `validation_error` - **API version:** `2026-08-27` ## What to do Correct the condition identified by the response before sending the request again. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only after changing the invalid request. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # invalid_json The request body is not valid JSON. - **Error type:** `validation_error` - **`code`:** `invalid_json` - **API version:** `2026-08-27` ## What to do Encode the body as valid JSON and send it with the expected content type. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only after correcting the request body. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # unauthorized The request does not contain a valid Commet API key. - **Error type:** `authentication_error` - **`code`:** `unauthorized` - **API version:** `2026-08-27` ## What to do Provide the correct key in the x-api-key header and verify that it belongs to the intended organization and mode. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry with the same missing or invalid credentials. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # forbidden The caller is authenticated but cannot perform this operation in the current organization or state. - **Error type:** `authentication_error` - **`code`:** `forbidden` - **API version:** `2026-08-27` ## What to do Use the response message to identify the missing permission or capability, then change the caller or organization configuration. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only after the required access or capability changes. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # sandbox_only This operation is available only to sandbox organizations. - **Error type:** `authentication_error` - **`code`:** `sandbox_only` - **API version:** `2026-08-27` ## What to do Run the operation with a sandbox organization. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry against a live organization. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # payouts_not_available_in_sandbox Payout operations are not available in sandbox mode. - **Error type:** `authentication_error` - **`code`:** `payouts_not_available_in_sandbox` - **API version:** `2026-08-27` ## What to do Use a live organization with payouts configured before creating payout resources or requesting a payout. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry while the organization remains in sandbox mode. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # not_found The requested endpoint or resource could not be found in the current organization, mode, or API version. - **Error type:** `not_found_error` - **`code`:** `not_found` - **API version:** `2026-08-27` ## What to do Use the response message to identify the missing target, then verify its identifier and request context. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only after correcting the target or context. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # customer_not_found The requested customer does not exist in this organization. - **Error type:** `not_found_error` - **`code`:** `customer_not_found` - **API version:** `2026-08-27` ## What to do Verify the customer identifier and the sandbox or live organization used by the API key. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only with an existing customer in the same request context. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # feature_not_found The requested feature, seat feature, or quota feature does not exist in this organization. - **Error type:** `not_found_error` - **`code`:** `feature_not_found` - **API version:** `2026-08-27` ## What to do Verify the feature code or identifier. Create the feature first when the response indicates that it is missing. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only after using or creating a valid feature. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # plan_not_found The requested plan does not exist or is not available in this organization. - **Error type:** `not_found_error` - **`code`:** `plan_not_found` - **API version:** `2026-08-27` ## What to do Verify the plan ID or code, organization, and sandbox or live mode. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only with an existing plan. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # subscription_not_found The requested subscription does not exist in this organization. - **Error type:** `not_found_error` - **`code`:** `subscription_not_found` - **API version:** `2026-08-27` ## What to do Verify the subscription ID, organization, and sandbox or live mode. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only with an existing subscription. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # no_active_subscription The customer has no subscription that is active for this operation. - **Error type:** `not_found_error` - **`code`:** `no_active_subscription` - **API version:** `2026-08-27` ## What to do Inspect the customer's subscriptions and create, activate, or recover the appropriate subscription. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only after an eligible subscription exists. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # no_prices_configured The selected plan has no price available for the request. - **Error type:** `conflict_error` - **`code`:** `no_prices_configured` - **API version:** `2026-08-27` ## What to do Configure a plan price that matches the requested currency, market, and billing context. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry after a matching price is configured. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # already_has_active_subscription The customer already has a draft, trialing, active, or past-due subscription that blocks creating another subscription. - **Error type:** `conflict_error` - **`code`:** `already_has_active_subscription` - **API version:** `2026-08-27` ## What to do Inspect the existing subscription and continue, recover, cancel, or change it instead of creating a conflicting subscription. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry the same subscription creation request. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # subscription_not_active The subscription status does not permit the requested operation. - **Error type:** `conflict_error` - **`code`:** `subscription_not_active` - **API version:** `2026-08-27` ## What to do Read the current subscription status and use the lifecycle operation supported for that state. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only after the subscription enters an eligible state. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # subscription_has_open_dispute The subscription has an unresolved payment dispute that blocks the requested billing change. - **Error type:** `internal_error` - **`code`:** `subscription_has_open_dispute` - **API version:** `2026-08-27` ## What to do Inspect the disputed payment and wait until its dispute no longer blocks charges before changing or reactivating the subscription. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only after the subscription no longer has an open payment dispute. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # subscription_required The requested operation requires a chargeable subscription. - **Error type:** `conflict_error` - **`code`:** `subscription_required` - **API version:** `2026-08-27` ## What to do Create or select the subscription required by the operation before continuing. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry after the required subscription exists. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # provider_unsupported The configured payment connection does not support the requested operation. - **Error type:** `conflict_error` - **`code`:** `provider_unsupported` - **API version:** `2026-08-27` ## What to do Configure or select a payment connection that provides the required capability. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only after selecting a capable payment connection. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # no_payment_method The subscription has no reusable payment method for this operation. - **Error type:** `conflict_error` - **`code`:** `no_payment_method` - **API version:** `2026-08-27` ## What to do Collect or update the customer's payment method through the supported checkout or payment-method flow. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry after a usable payment method is attached. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # duplicate The request conflicts with an existing resource, unique value, or previously recorded event. - **Error type:** `conflict_error` - **`code`:** `duplicate` - **API version:** `2026-08-27` ## What to do Read the response message to identify the duplicate. Reuse the existing resource or choose a unique value when a new resource is intended. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry the same request unchanged. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # already_scheduled The requested subscription action is already scheduled. - **Error type:** `conflict_error` - **`code`:** `already_scheduled` - **API version:** `2026-08-27` ## What to do Read the subscription's current schedule instead of creating the same schedule again. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry while the existing schedule remains active. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # feature_not_in_plan The requested feature is not included in the customer's plan or subscription. - **Error type:** `billing_error` - **`code`:** `feature_not_in_plan` - **API version:** `2026-08-27` ## What to do Attach the feature to the plan or use a plan that includes it before checking or recording usage. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry after the subscription includes the feature. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # feature_disabled The requested feature is disabled for the customer's subscription. - **Error type:** `billing_error` - **`code`:** `feature_disabled` - **API version:** `2026-08-27` ## What to do Enable the feature through the plan or subscription configuration before using it. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry after the feature is enabled. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # insufficient_balance The operation cannot be charged against the available balance, or the required regional overage price is missing. - **Error type:** `billing_error` - **`code`:** `insufficient_balance` - **API version:** `2026-08-27` ## What to do Follow the operation-specific response message. Add balance, reduce the requested amount, or configure the missing regional price it identifies. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only after correcting the balance or pricing condition identified by the response. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # insufficient_credits The subscription does not have enough credits for the usage event. - **Error type:** `billing_error` - **`code`:** `insufficient_credits` - **API version:** `2026-08-27` ## What to do Add credits, activate a credit pack, or reduce the requested usage according to the response details. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry after sufficient credits are available. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # insufficient_seats The requested seat removal exceeds the subscription's available seat balance. - **Error type:** `billing_error` - **`code`:** `insufficient_seats` - **API version:** `2026-08-27` ## What to do Use the current balance from the response and request a removable seat quantity. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry with a quantity that does not exceed the current balance. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # included_limit_reached The requested usage exceeds the plan's included usage limit. - **Error type:** `billing_error` - **`code`:** `included_limit_reached` - **API version:** `2026-08-27` ## What to do Use the current, included, and remaining values from the response to reduce usage or enable an eligible overage path. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only after capacity becomes available or the request is reduced. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # missing_regional_price The plan has no price for the subscription's currency or resolved market. - **Error type:** `billing_error` - **`code`:** `missing_regional_price` - **API version:** `2026-08-27` ## What to do Configure the missing regional or currency price identified by the response. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry after the required price is configured. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # payment_failed The operation could not complete its payment or required tax calculation. - **Error type:** `billing_error` - **`code`:** `payment_failed` - **API version:** `2026-08-27` ## What to do Follow the operation-specific response message and details to correct the payment method, provider condition, or tax calculation failure. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not loop on the same failure. Retry only when the response permits it or after correcting the reported condition. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # payment_not_found The requested payment does not exist in this organization. - **Error type:** `not_found_error` - **`code`:** `payment_not_found` - **API version:** `2026-08-27` ## What to do Verify the payment ID, organization, and sandbox or live mode. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only with an existing payment. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # payment_method_not_found The requested or required payment method could not be found. - **Error type:** `not_found_error` - **`code`:** `payment_method_not_found` - **API version:** `2026-08-27` ## What to do Collect a payment method or verify the payment-method identifier before continuing. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry after a usable payment method exists. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # payment_not_cancelable The payment's current state does not allow cancellation. - **Error type:** `conflict_error` - **`code`:** `payment_not_cancelable` - **API version:** `2026-08-27` ## What to do Inspect the payment status and use the operation supported for its current state. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only if the payment later enters a cancelable state. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # invalid_subscription_status The subscription's current status is incompatible with the requested operation. - **Error type:** `conflict_error` - **`code`:** `invalid_subscription_status` - **API version:** `2026-08-27` ## What to do Use the response message to identify the accepted states, then choose the appropriate lifecycle action. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only after the subscription enters an accepted state. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # one_time_subscription_not_cancelable A one-time subscription cannot be canceled as a recurring subscription. - **Error type:** `conflict_error` - **`code`:** `one_time_subscription_not_cancelable` - **API version:** `2026-08-27` ## What to do Do not use the subscription cancellation endpoint for a one-time purchase. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry this cancellation request. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # not_scheduled_for_cancellation The subscription has no scheduled cancellation to remove. - **Error type:** `conflict_error` - **`code`:** `not_scheduled_for_cancellation` - **API version:** `2026-08-27` ## What to do Inspect the subscription state before attempting to undo a cancellation. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry unless a cancellation is scheduled first. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # subscription_already_canceled The subscription is already canceled. - **Error type:** `conflict_error` - **`code`:** `subscription_already_canceled` - **API version:** `2026-08-27` ## What to do Use a supported reactivation flow when the subscription is eligible instead of undoing a pending cancellation. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry the same uncancel request. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # subscription_creation_failed Platform could not complete subscription creation. - **Error type:** `internal_error` - **`code`:** `subscription_creation_failed` - **API version:** `2026-08-27` ## What to do Keep the request ID and inspect the response message before deciding whether the operation can be repeated. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior For the same logical write, preserve the Idempotency-Key and retry with bounded backoff unless the response requires a corrected request. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # subscription_activation_failed The subscription was created or saved, but its initial activation or checkout could not be completed. - **Error type:** `internal_error` - **`code`:** `subscription_activation_failed` - **API version:** `2026-08-27` ## What to do Keep the request ID and inspect the subscription and checkout state before retrying. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Preserve the Idempotency-Key. Retry only after confirming that repeating the activation will not create a second logical operation. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # subscription_cancel_failed Platform could not complete subscription cancellation. - **Error type:** `internal_error` - **`code`:** `subscription_cancel_failed` - **API version:** `2026-08-27` ## What to do Keep the request ID and read the current subscription state before attempting the cancellation again. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Preserve the Idempotency-Key and retry with bounded backoff if the subscription is still cancelable. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # subscription_uncancel_failed Platform could not remove the subscription's scheduled cancellation. - **Error type:** `internal_error` - **`code`:** `subscription_uncancel_failed` - **API version:** `2026-08-27` ## What to do Keep the request ID and read the current subscription state before attempting the operation again. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Preserve the Idempotency-Key and retry with bounded backoff if cancellation is still scheduled. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # plan_unavailable The subscription's plan is no longer available for this operation. - **Error type:** `conflict_error` - **`code`:** `plan_unavailable` - **API version:** `2026-08-27` ## What to do Select an available plan or use a supported migration path for the subscription. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry with the same unavailable plan. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # no_outstanding_invoice The subscription has no outstanding invoice to charge or recover. - **Error type:** `conflict_error` - **`code`:** `no_outstanding_invoice` - **API version:** `2026-08-27` ## What to do Read the current invoices and subscription state before requesting another payment attempt. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry unless an outstanding invoice exists. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # charge_in_progress A charge or payment retry is already running for the subscription. - **Error type:** `conflict_error` - **`code`:** `charge_in_progress` - **API version:** `2026-08-27` ## What to do Wait for the current attempt to finish and read the resulting subscription or invoice state. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only after the in-progress attempt has completed. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # charge_failed The charge required by the subscription operation failed. - **Error type:** `billing_error` - **`code`:** `charge_failed` - **API version:** `2026-08-27` ## What to do Use the response message and decline information to resolve the payment failure. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only after changing the payment conditions or when the response indicates that another attempt is appropriate. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # idempotency_key_mismatch The Idempotency-Key was already used with a different request payload or operation. - **Error type:** `conflict_error` - **`code`:** `idempotency_key_mismatch` - **API version:** `2026-08-27` ## What to do Reuse the key only for the original logical request. Use a new key for a genuinely different operation. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry the different request with the conflicting key. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # idempotency_in_progress A request with this Idempotency-Key is still being processed. - **Error type:** `conflict_error` - **`code`:** `idempotency_in_progress` - **API version:** `2026-08-27` ## What to do Wait for the original request to finish before checking its result with the same key. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry the same request and key after a short delay. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # idempotency_unavailable Platform could not safely establish, release, or persist the idempotent response for this operation. - **Error type:** `internal_error` - **`code`:** `idempotency_unavailable` - **API version:** `2026-08-27` ## What to do Follow the response message exactly because it indicates whether the same key or a new key is required. Keep the request ID when the outcome is ambiguous. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only as instructed by the response message; choosing the wrong key can repeat a completed operation. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # seat_limit_exceeded The requested plan change would exceed the available seat limit. - **Error type:** `billing_error` - **`code`:** `seat_limit_exceeded` - **API version:** `2026-08-27` ## What to do Reduce assigned seats or choose a target plan that supports the current seat count. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry after the seat count or target plan changes. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # no_change A client using an API version before 2026-07-24 requested a subscription update that would not change its current state. - **Error type:** `internal_error` - **`code`:** `no_change` - **API version:** `2026-07-11` ## API versions - Emitted by API versions before `2026-07-24`. ## What to do Read the current subscription and submit only when a different plan, price, or configuration is intended. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry the unchanged request. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # no_change_requested The request would not change the current subscription state. - **Error type:** `validation_error` - **`code`:** `no_change_requested` - **API version:** `2026-08-27` ## What to do Read the current subscription and submit only when a different plan, price, or configuration is intended. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry the unchanged request. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # plans_not_in_same_group The current and target plans do not belong to the same plan group. - **Error type:** `validation_error` - **`code`:** `plans_not_in_same_group` - **API version:** `2026-08-27` ## What to do Choose a target plan in the subscription's plan group or use a supported replacement flow. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry with the same target plan. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # free_plan_change_not_supported This plan-change operation does not support the selected free plan. - **Error type:** `validation_error` - **`code`:** `free_plan_change_not_supported` - **API version:** `2026-08-27` ## What to do Use the supported subscription flow for moving to or from a free plan. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry the same plan-change request. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # plan_change_scheduled The requested plan change takes effect at the end of the current billing period instead of being prorated immediately. - **Error type:** `validation_error` - **`code`:** `plan_change_scheduled` - **API version:** `2026-08-27` ## What to do Use the change-plan endpoint to schedule the change and inspect the returned schedule. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry the preview as an immediate prorated change. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # addon_not_found The requested add-on does not exist in this organization. - **Error type:** `not_found_error` - **`code`:** `addon_not_found` - **API version:** `2026-08-27` ## What to do Verify the add-on ID and organization. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only with an existing add-on. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # addon_already_active The add-on is already active on this subscription. - **Error type:** `conflict_error` - **`code`:** `addon_already_active` - **API version:** `2026-08-27` ## What to do Read the existing subscription add-on instead of activating it again. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry while the add-on remains active. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # addon_not_active The add-on is not active on this subscription. - **Error type:** `billing_error` - **`code`:** `addon_not_active` - **API version:** `2026-08-27` ## What to do Verify the subscription add-ons before attempting to update or deactivate it. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only after the add-on is active. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # addon_has_activations The add-on cannot be deleted while subscriptions use it. - **Error type:** `conflict_error` - **`code`:** `addon_has_activations` - **API version:** `2026-08-27` ## What to do Remove or migrate its active subscription assignments before deleting the add-on. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry after the add-on has no active assignments. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # feature_already_exists A feature with the same code or name already exists, or the feature is already assigned where uniqueness is required. - **Error type:** `conflict_error` - **`code`:** `feature_already_exists` - **API version:** `2026-08-27` ## What to do Reuse the existing feature or choose a unique code, name, or assignment according to the response message. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry the duplicate request unchanged. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # feature_in_use The feature cannot be deleted while a plan or add-on uses it. - **Error type:** `conflict_error` - **`code`:** `feature_in_use` - **API version:** `2026-08-27` ## What to do Remove the feature from active plans and add-ons before deleting it. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry after the feature is no longer referenced. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # model_not_found The requested AI model does not exist in the current model catalog. - **Error type:** `not_found_error` - **`code`:** `model_not_found` - **API version:** `2026-08-27` ## What to do Verify the model identifier and select a model available in the catalog. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only with an available model. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # pricing_mode_mismatch The submitted usage shape does not match the feature's configured pricing mode. - **Error type:** `billing_error` - **`code`:** `pricing_mode_mismatch` - **API version:** `2026-08-27` ## What to do Send numeric usage for fixed pricing or model-token usage for AI model pricing, as identified by the response. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry after matching the request to the feature's pricing mode. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # credit_pack_not_found The requested credit pack does not exist in this organization. - **Error type:** `not_found_error` - **`code`:** `credit_pack_not_found` - **API version:** `2026-08-27` ## What to do Verify the credit-pack ID and organization. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only with an existing credit pack. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # plan_group_not_found The requested plan group does not exist in this organization. - **Error type:** `not_found_error` - **`code`:** `plan_group_not_found` - **API version:** `2026-08-27` ## What to do Verify the plan-group ID and organization. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only with an existing plan group. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # offer_not_found The requested offer does not exist or is not compatible with the selected operation. - **Error type:** `not_found_error` - **`code`:** `offer_not_found` - **API version:** `2026-08-27` ## What to do Verify the offer ID, state, kind, and compatibility described by the response message. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only with an existing compatible offer. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # offer_invalid A client using an API version before 2026-07-24 selected an offer that does not satisfy the operation's eligibility or lifecycle conditions. - **Error type:** `internal_error` - **`code`:** `offer_invalid` - **API version:** `2026-07-11` ## API versions - Emitted by API versions before `2026-07-24`. ## What to do Use the response message to identify the failed condition, then choose an eligible offer or change the request context. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only after the offer or eligibility context changes. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # offer_not_applicable The selected offer does not satisfy the eligibility or lifecycle conditions for this operation. - **Error type:** `validation_error` - **`code`:** `offer_not_applicable` - **API version:** `2026-08-27` ## What to do Use the response message to identify the failed condition, then choose an eligible offer or change the request context. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only after the offer or eligibility context changes. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # card_promotions_not_available Card promotions are not available for this organization. - **Error type:** `conflict_error` - **`code`:** `card_promotions_not_available` - **API version:** `2026-08-27` ## What to do Remove the card-promotion selection or contact Commet if the organization is expected to have access. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only after changing the selection or organization capability. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # promo_code_not_found The requested promo code does not exist in this organization. - **Error type:** `not_found_error` - **`code`:** `promo_code_not_found` - **API version:** `2026-08-27` ## What to do Verify the promo-code ID or value and organization. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only with an existing promo code. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # promo_code_expired The promo code is no longer within its valid redemption period. - **Error type:** `conflict_error` - **`code`:** `promo_code_expired` - **API version:** `2026-08-27` ## What to do Use another active and eligible promo code. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry with the expired promo code. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # webhook_not_found The requested webhook endpoint does not exist in this organization. - **Error type:** `not_found_error` - **`code`:** `webhook_not_found` - **API version:** `2026-08-27` ## What to do Verify the webhook endpoint ID and organization. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only with an existing webhook endpoint. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # api_key_not_found The requested API key does not exist in this organization. - **Error type:** `not_found_error` - **`code`:** `api_key_not_found` - **API version:** `2026-08-27` ## What to do Verify the API-key ID and organization. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only with an existing API key. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # invoice_not_found The requested invoice does not exist in this organization. - **Error type:** `not_found_error` - **`code`:** `invoice_not_found` - **API version:** `2026-08-27` ## What to do Verify the invoice ID, organization, and sandbox or live mode. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only with an existing invoice. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # transaction_not_found The requested transaction does not exist in this organization. - **Error type:** `not_found_error` - **`code`:** `transaction_not_found` - **API version:** `2026-08-27` ## What to do Verify the transaction ID, organization, and sandbox or live mode. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only with an existing transaction. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # price_not_found The requested price does not exist on the selected plan or no price matches the operation. - **Error type:** `not_found_error` - **`code`:** `price_not_found` - **API version:** `2026-08-27` ## What to do Verify the price ID and plan, or configure a price for the requested billing context. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only with an existing matching price. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # price_in_use The price cannot be removed or changed while it is in use. - **Error type:** `conflict_error` - **`code`:** `price_in_use` - **API version:** `2026-08-27` ## What to do Read the response message and remove the dependent configuration or choose a non-destructive update. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry after the price is no longer referenced. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # market_not_found The requested market does not exist in this organization. - **Error type:** `not_found_error` - **`code`:** `market_not_found` - **API version:** `2026-08-27` ## What to do Verify the market ID and organization. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only with an existing market. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # market_in_use The market cannot be deleted while a price or subscription uses it. - **Error type:** `conflict_error` - **`code`:** `market_in_use` - **API version:** `2026-08-27` ## What to do Remove or migrate the market's dependent prices and subscriptions. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry after the market is no longer referenced. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # market_group_not_found The requested market group does not exist in this organization. - **Error type:** `not_found_error` - **`code`:** `market_group_not_found` - **API version:** `2026-08-27` ## What to do Verify the market-group ID and organization. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only with an existing market group. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # market_group_in_use The market group cannot be deleted while a price or subscription uses it. - **Error type:** `conflict_error` - **`code`:** `market_group_in_use` - **API version:** `2026-08-27` ## What to do Remove or migrate the market group's dependent prices and subscriptions. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry after the market group is no longer referenced. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # endpoint_not_available This endpoint is not available in the resolved API version. - **Error type:** `not_found_error` - **`code`:** `endpoint_not_available` - **API version:** `2026-08-27` ## What to do Use an API version that contains the endpoint or migrate to the operation available in the pinned version. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry with the same endpoint and API version. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # endpoint_deprecated The endpoint has been retired and returns HTTP 410 Gone. - **Error type:** `not_found_error` - **`code`:** `endpoint_deprecated` - **API version:** `2026-08-27` ## What to do Follow the replacement operation or workflow identified by the response message. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry the deprecated endpoint. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # organization_already_claimed The organization has already completed its claim flow. - **Error type:** `conflict_error` - **`code`:** `organization_already_claimed` - **API version:** `2026-08-27` ## What to do Use the existing organization access instead of claiming it again. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry the same claim request. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # claim_not_pending The organization has no pending claim to complete. - **Error type:** `not_found_error` - **`code`:** `claim_not_pending` - **API version:** `2026-08-27` ## What to do Verify the organization state and initiate the required provisioning flow before requesting a claim link. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only after the organization has a pending claim. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # CUSTOMER_NOT_FOUND A subscription-creation request using an API version before 2026-07-24 referenced a customer that does not exist in the organization. - **Error type:** `internal_error` - **`code`:** `CUSTOMER_NOT_FOUND` - **API version:** `2026-07-11` ## API versions - Emitted by API versions before `2026-07-24`. ## What to do Verify the customer identifier, organization, and sandbox or live mode. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only with an existing customer. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # PLAN_NOT_FOUND A subscription-creation request using an API version before 2026-07-24 referenced a plan that does not exist in the organization. - **Error type:** `internal_error` - **`code`:** `PLAN_NOT_FOUND` - **API version:** `2026-07-11` ## API versions - Emitted by API versions before `2026-07-24`. ## What to do Verify the plan identifier, organization, and mode. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only with an existing plan. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # NO_PRICES_CONFIGURED The selected plan has no price available for this legacy subscription-creation request. - **Error type:** `internal_error` - **`code`:** `NO_PRICES_CONFIGURED` - **API version:** `2026-07-11` ## API versions - Emitted by API versions before `2026-07-24`. ## What to do Configure a plan price that matches the requested billing interval and context. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry after a matching price is configured. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # PRICE_NOT_FOUND The selected price does not exist on the plan or does not match this legacy subscription-creation request. - **Error type:** `internal_error` - **`code`:** `PRICE_NOT_FOUND` - **API version:** `2026-07-11` ## API versions - Emitted by API versions before `2026-07-24`. ## What to do Verify the price and plan identifiers, billing interval, organization, and mode. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only with a matching price. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # ALREADY_HAS_ACTIVE The customer already has a draft, trialing, active, or past-due subscription that blocks this legacy creation request. - **Error type:** `internal_error` - **`code`:** `ALREADY_HAS_ACTIVE` - **API version:** `2026-07-11` ## API versions - Emitted by API versions before `2026-07-24`. ## What to do Inspect the existing subscription and continue, recover, cancel, or change it instead of creating a conflicting subscription. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry the same creation request. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # CREATION_FAILED Platform could not complete this legacy subscription-creation request. - **Error type:** `internal_error` - **`code`:** `CREATION_FAILED` - **API version:** `2026-07-11` ## API versions - Emitted by API versions before `2026-07-24`. ## What to do Keep the x-request-id and inspect whether a subscription was created before repeating the write. Contact support with the request ID if the failure persists. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry with bounded backoff and the same idempotency key for the same logical request. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # SUBSCRIPTION_NOT_FOUND A subscription lifecycle request using an API version before 2026-07-24 referenced a subscription that does not exist in the organization. - **Error type:** `internal_error` - **`code`:** `SUBSCRIPTION_NOT_FOUND` - **API version:** `2026-07-11` ## API versions - Emitted by API versions before `2026-07-24`. ## What to do Verify the subscription identifier, organization, and sandbox or live mode. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry only with an existing subscription. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # ALREADY_SCHEDULED The subscription cancellation is already scheduled, so the same legacy cancellation request cannot be applied again. - **Error type:** `internal_error` - **`code`:** `ALREADY_SCHEDULED` - **API version:** `2026-07-11` ## API versions - Emitted by API versions before `2026-07-24`. ## What to do Read the subscription's existing cancellation schedule. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry while the schedule remains active. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # ONE_TIME_NOT_CANCELABLE A one-time or lifetime subscription cannot be canceled through this legacy endpoint. - **Error type:** `internal_error` - **`code`:** `ONE_TIME_NOT_CANCELABLE` - **API version:** `2026-07-11` ## API versions - Emitted by API versions before `2026-07-24`. ## What to do Inspect the subscription billing interval and use the lifecycle operation supported for that product. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry the same cancellation request. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # CANCEL_FAILED Platform could not complete this legacy subscription cancellation. - **Error type:** `internal_error` - **`code`:** `CANCEL_FAILED` - **API version:** `2026-07-11` ## API versions - Emitted by API versions before `2026-07-24`. ## What to do Keep the x-request-id, inspect the current subscription state, and contact support with the request ID if the failure persists. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry with bounded backoff only after confirming that cancellation was not applied. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # NOT_SCHEDULED_FOR_CANCELLATION The subscription is not scheduled for cancellation, so this legacy uncancel request has nothing to reverse. - **Error type:** `internal_error` - **`code`:** `NOT_SCHEDULED_FOR_CANCELLATION` - **API version:** `2026-07-11` ## API versions - Emitted by API versions before `2026-07-24`. ## What to do Read the current subscription state and cancellation schedule. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry unless a cancellation is later scheduled. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # ALREADY_CANCELED The subscription is already canceled and cannot be restored through this legacy uncancel operation. - **Error type:** `internal_error` - **`code`:** `ALREADY_CANCELED` - **API version:** `2026-07-11` ## API versions - Emitted by API versions before `2026-07-24`. ## What to do Use the supported subscription creation or recovery flow for the customer's desired plan. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Do not retry the same uncancel request. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # UNCANCEL_FAILED Platform could not complete this legacy request to remove a scheduled cancellation. - **Error type:** `internal_error` - **`code`:** `UNCANCEL_FAILED` - **API version:** `2026-07-11` ## API versions - Emitted by API versions before `2026-07-24`. ## What to do Keep the x-request-id, inspect the current subscription state, and contact support with the request ID if the failure persists. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry with bounded backoff only after confirming that the cancellation schedule remains unchanged. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # rate_limited The caller exceeded the request allowance for the current window. - **Error type:** `rate_limit_error` - **`code`:** `rate_limited` - **API version:** `2026-08-27` ## What to do Read Retry-After and the RateLimit-* response headers, then reduce request concurrency when necessary. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry after the Retry-After interval. Do not automatically retry a 429 response that omits Retry-After. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials. # internal_error Platform could not complete the operation because an internal execution path or dependency failed. - **Error type:** `internal_error` - **`code`:** `internal_error` - **API version:** `2026-08-27` ## What to do Keep the x-request-id and inspect the resource state before repeating a write. Contact support with the request ID if the failure persists. The response `message`, `param`, and `details` fields describe the condition observed by the specific operation. ## Retry behavior Retry with bounded backoff. Preserve the Idempotency-Key for the same logical write unless the response explicitly requires a new key. ## Correlate the request Keep the `x-request-id` response header when reporting or investigating this error. Platform records the same identifier in its request event, so Commet can locate the exact execution without customer data or credentials.