From 27bcdb79d1e0297ae3b45cebb306e4ccc795f017 Mon Sep 17 00:00:00 2001 From: Owen Greenhalgh Date: Fri, 1 May 2026 10:06:59 +0100 Subject: [PATCH] rename auto-topup webhook to billing.auto_topup_succeeded and update docs --- .../webhooks/billingAutoTopupSucceeded.mdx | 56 ++++++++++++++++++ apps/docs/mintlify/docs.json | 6 ++ .../modelling-pricing/auto-top-ups.mdx | 6 +- apps/docs/mintlify/documentation/webhooks.mdx | 58 +++++++++++++++++++ .../webhooks/sendAutoTopupSucceededWebhook.ts | 6 +- .../auto-topup/auto-topup-webhook.test.ts | 14 ++--- .../billingAutoTopupSucceeded.ts} | 18 +++--- shared/api/webhooks/index.ts | 2 +- shared/api/webhooks/webhookEventType.ts | 3 +- shared/api/webhooks/webhookRegistry.ts | 12 ++-- 10 files changed, 154 insertions(+), 27 deletions(-) create mode 100644 apps/docs/mintlify/api-reference/webhooks/billingAutoTopupSucceeded.mdx rename shared/api/webhooks/{balances/balancesAutoTopupSucceeded.ts => billing/billingAutoTopupSucceeded.ts} (77%) diff --git a/apps/docs/mintlify/api-reference/webhooks/billingAutoTopupSucceeded.mdx b/apps/docs/mintlify/api-reference/webhooks/billingAutoTopupSucceeded.mdx new file mode 100644 index 000000000..21d5861a8 --- /dev/null +++ b/apps/docs/mintlify/api-reference/webhooks/billingAutoTopupSucceeded.mdx @@ -0,0 +1,56 @@ +--- +title: "Auto Top-Up Succeeded" +openapi: "api/openapi.yml webhook billing.auto_topup_succeeded" +--- + +### Payload Fields + + + The ID of the customer whose balance was topped up. + + + + The feature ID that was automatically topped up. + + + + The normalized amount of balance granted by the top-up. + + + + The configured balance threshold that triggered the top-up. + + + + The customer's remaining balance for the feature after the top-up. + + + + Whether the auto top-up created a `send_invoice` invoice instead of auto-charging the saved payment method. + + + + The invoice created for the auto top-up. + + + The Stripe invoice ID. Use this as a stable dedupe key. + + + + The status of the invoice. `"paid"` for auto-charged top-ups; `"open"` for `invoice_mode` top-ups where credits were granted but the invoice has not yet been paid. + + + + The total amount of the invoice in the smallest currency unit (e.g. cents for USD), matching Stripe's `invoice.total`. + + + + The ISO currency code for the invoice. + + + + URL to the hosted invoice page, if available. + + + + diff --git a/apps/docs/mintlify/docs.json b/apps/docs/mintlify/docs.json index 6729da7c1..f6be6636f 100644 --- a/apps/docs/mintlify/docs.json +++ b/apps/docs/mintlify/docs.json @@ -271,6 +271,12 @@ "api-reference/webhooks/balancesLimitReached" ] }, + { + "group": "Billing", + "pages": [ + "api-reference/webhooks/billingAutoTopupSucceeded" + ] + }, { "group": "Vercel", "pages": [ diff --git a/apps/docs/mintlify/documentation/modelling-pricing/auto-top-ups.mdx b/apps/docs/mintlify/documentation/modelling-pricing/auto-top-ups.mdx index d00314822..6a2b820bb 100644 --- a/apps/docs/mintlify/documentation/modelling-pricing/auto-top-ups.mdx +++ b/apps/docs/mintlify/documentation/modelling-pricing/auto-top-ups.mdx @@ -170,4 +170,8 @@ This limits the customer to 5 auto top-ups per month. Supported intervals: `hour Auto top-ups use burst suppression to prevent duplicate purchases when multiple track events happen in quick succession. There's a 30-second cooldown between top-ups for the same feature. - \ No newline at end of file + + +## Notifications + +Subscribe to the [`billing.auto_topup_succeeded`](/api-reference/webhooks/billingAutoTopupSucceeded) webhook to be notified when a top-up grants credits. The payload includes the granted quantity, the new balance, and the underlying invoice — useful for sending receipts, updating internal ledgers, or reconciling balance after a recharge. \ No newline at end of file diff --git a/apps/docs/mintlify/documentation/webhooks.mdx b/apps/docs/mintlify/documentation/webhooks.mdx index 8adf4abcd..076a5373b 100644 --- a/apps/docs/mintlify/documentation/webhooks.mdx +++ b/apps/docs/mintlify/documentation/webhooks.mdx @@ -97,6 +97,64 @@ For entity-scoped usage, the payload will also include an `entity_id`: } ``` +### billing.auto_topup_succeeded + +Fired when an [auto top-up](/documentation/modelling-pricing/auto-top-ups) successfully grants additional prepaid balance. Useful for sending receipts, updating internal ledgers, or reconciling balance after a recharge. + +For auto-charged top-ups, the event fires only after the Stripe invoice is `paid`. For `invoice_mode` top-ups, the event fires once credits are granted and the invoice is finalized — `invoice.status` will typically be `"open"` until the customer pays. + +Use `invoice.stripe_id` as a stable dedupe key. The top-level `id` field (e.g. `evt_auto_topup_...`) is a unique identifier for the event itself. + +**Example payload (auto-charge):** + +```json expandable +{ + "type": "billing.auto_topup_succeeded", + "id": "evt_auto_topup_2abc123", + "occurred_at": 1761840000000, + "data": { + "customer_id": "user_123", + "feature_id": "credits", + "quantity_granted": 1000, + "threshold": 500, + "balance_after": 1450, + "invoice_mode": false, + "invoice": { + "stripe_id": "in_1A2B3C4D5E6F", + "status": "paid", + "total": 1000, + "currency": "usd", + "hosted_invoice_url": "https://invoice.stripe.com/i/..." + } + } +} +``` + +**Example payload (invoice mode):** + +```json expandable +{ + "type": "billing.auto_topup_succeeded", + "id": "evt_auto_topup_3xyz456", + "occurred_at": 1761840000000, + "data": { + "customer_id": "user_123", + "feature_id": "credits", + "quantity_granted": 1000, + "threshold": 500, + "balance_after": 1450, + "invoice_mode": true, + "invoice": { + "stripe_id": "in_2G3H4I5J6K7L", + "status": "open", + "total": 1000, + "currency": "usd", + "hosted_invoice_url": "https://invoice.stripe.com/i/..." + } + } +} +``` + ### balances.usage_alert_triggered Fired when a customer crosses a configured usage alert threshold. Usage alerts let you monitor when customers approach or exceed specific usage levels for a feature. diff --git a/server/src/internal/balances/autoTopUp/webhooks/sendAutoTopupSucceededWebhook.ts b/server/src/internal/balances/autoTopUp/webhooks/sendAutoTopupSucceededWebhook.ts index 30d7d1d61..e1970ead8 100644 --- a/server/src/internal/balances/autoTopUp/webhooks/sendAutoTopupSucceededWebhook.ts +++ b/server/src/internal/balances/autoTopUp/webhooks/sendAutoTopupSucceededWebhook.ts @@ -1,6 +1,6 @@ import { ACTIVE_STATUSES, - type BalancesAutoTopupSucceededInvoice, + type BillingAutoTopupSucceededInvoice, type BillingResult, fullCustomerToCustomerEntitlements, getApiBalance, @@ -16,7 +16,7 @@ const getInvoicePayload = ({ billingResult, }: { billingResult: BillingResult; -}): BalancesAutoTopupSucceededInvoice | null => { +}): BillingAutoTopupSucceededInvoice | null => { const stripeInvoice = billingResult.stripe.stripeInvoice; if (!stripeInvoice) return null; @@ -91,7 +91,7 @@ const sendAutoTopupSucceededWebhookUnsafe = async ({ await sendSvixEvent({ ctx, - eventType: WebhookEventType.BalancesAutoTopupSucceeded, + eventType: WebhookEventType.BillingAutoTopupSucceeded, payloadFields: { id: generateId("evt_auto_topup"), occurred_at: Date.now(), diff --git a/server/tests/integration/balances/auto-topup/auto-topup-webhook.test.ts b/server/tests/integration/balances/auto-topup/auto-topup-webhook.test.ts index 98cd033c1..6114e12d7 100644 --- a/server/tests/integration/balances/auto-topup/auto-topup-webhook.test.ts +++ b/server/tests/integration/balances/auto-topup/auto-topup-webhook.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; import { type ApiCustomerV5, - type BalancesAutoTopupSucceeded, + type BillingAutoTopupSucceeded, WebhookEventType, } from "@autumn/shared"; import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; @@ -21,10 +21,10 @@ import { Decimal } from "decimal.js"; import { makeAutoTopupConfig } from "./utils/makeAutoTopupConfig.js"; type AutoTopupSucceededPayload = { - type: WebhookEventType.BalancesAutoTopupSucceeded; + type: WebhookEventType.BillingAutoTopupSucceeded; id: string; occurred_at: number; - data: BalancesAutoTopupSucceeded; + data: BillingAutoTopupSucceeded; }; let webhook: WebhookTestSetup; @@ -35,7 +35,7 @@ beforeAll(async () => { const appId = getTestSvixAppId({ svixConfig: ctx.org.svix_config }); webhook = await setupWebhookTest({ appId, - filterTypes: [WebhookEventType.BalancesAutoTopupSucceeded], + filterTypes: [WebhookEventType.BillingAutoTopupSucceeded], }); playToken = webhook.playToken; }); @@ -85,7 +85,7 @@ test.concurrent(`${chalk.yellowBright("auto-topup webhook: successful auto top-u const result = await waitForWebhook({ token: playToken, predicate: (payload) => - payload.type === WebhookEventType.BalancesAutoTopupSucceeded && + payload.type === WebhookEventType.BillingAutoTopupSucceeded && payload.data?.customer_id === customerId, timeoutMs: 30_000, }); @@ -156,7 +156,7 @@ test.concurrent(`${chalk.yellowBright("auto-topup webhook: invoice mode fires wi const result = await waitForWebhook({ token: playToken, predicate: (payload) => - payload.type === WebhookEventType.BalancesAutoTopupSucceeded && + payload.type === WebhookEventType.BillingAutoTopupSucceeded && payload.data?.customer_id === customerId, timeoutMs: 30_000, }); @@ -210,7 +210,7 @@ test.concurrent(`${chalk.yellowBright("auto-topup webhook: no webhook when balan const result = await waitForWebhook({ token: playToken, predicate: (payload) => - payload.type === WebhookEventType.BalancesAutoTopupSucceeded && + payload.type === WebhookEventType.BillingAutoTopupSucceeded && payload.data?.customer_id === customerId, timeoutMs: 10_000, }); diff --git a/shared/api/webhooks/balances/balancesAutoTopupSucceeded.ts b/shared/api/webhooks/billing/billingAutoTopupSucceeded.ts similarity index 77% rename from shared/api/webhooks/balances/balancesAutoTopupSucceeded.ts rename to shared/api/webhooks/billing/billingAutoTopupSucceeded.ts index b4a0f5667..e6cb7ab70 100644 --- a/shared/api/webhooks/balances/balancesAutoTopupSucceeded.ts +++ b/shared/api/webhooks/billing/billingAutoTopupSucceeded.ts @@ -1,6 +1,6 @@ import { z } from "zod/v4"; -export const BalancesAutoTopupSucceededInvoiceSchema = z.object({ +export const BillingAutoTopupSucceededInvoiceSchema = z.object({ stripe_id: z.string().meta({ description: "The Stripe invoice ID.", }), @@ -18,7 +18,7 @@ export const BalancesAutoTopupSucceededInvoiceSchema = z.object({ }), }); -export const BALANCES_AUTO_TOPUP_SUCCEEDED_EXAMPLE = { +export const BILLING_AUTO_TOPUP_SUCCEEDED_EXAMPLE = { customer_id: "cus_123", feature_id: "messages", quantity_granted: 100, @@ -34,7 +34,7 @@ export const BALANCES_AUTO_TOPUP_SUCCEEDED_EXAMPLE = { }, }; -export const BalancesAutoTopupSucceededSchema = z +export const BillingAutoTopupSucceededSchema = z .object({ customer_id: z.string().meta({ description: "The ID of the customer whose balance was topped up.", @@ -57,17 +57,17 @@ export const BalancesAutoTopupSucceededSchema = z description: "Whether the auto top-up created a send_invoice invoice instead of auto-charging.", }), - invoice: BalancesAutoTopupSucceededInvoiceSchema.meta({ + invoice: BillingAutoTopupSucceededInvoiceSchema.meta({ description: "The invoice created for the auto top-up.", }), }) .meta({ - examples: [BALANCES_AUTO_TOPUP_SUCCEEDED_EXAMPLE], + examples: [BILLING_AUTO_TOPUP_SUCCEEDED_EXAMPLE], }); -export type BalancesAutoTopupSucceeded = z.infer< - typeof BalancesAutoTopupSucceededSchema +export type BillingAutoTopupSucceeded = z.infer< + typeof BillingAutoTopupSucceededSchema >; -export type BalancesAutoTopupSucceededInvoice = z.infer< - typeof BalancesAutoTopupSucceededInvoiceSchema +export type BillingAutoTopupSucceededInvoice = z.infer< + typeof BillingAutoTopupSucceededInvoiceSchema >; diff --git a/shared/api/webhooks/index.ts b/shared/api/webhooks/index.ts index 3b4d982d0..5df30e731 100644 --- a/shared/api/webhooks/index.ts +++ b/shared/api/webhooks/index.ts @@ -1,6 +1,6 @@ -export * from "./balances/balancesAutoTopupSucceeded.js"; export * from "./balances/balancesLimitReached.js"; export * from "./balances/balancesUsageAlertTriggered.js"; +export * from "./billing/billingAutoTopupSucceeded.js"; export * from "./vercel/index.js"; export * from "./webhookEventType.js"; export * from "./webhookRegistry.js"; diff --git a/shared/api/webhooks/webhookEventType.ts b/shared/api/webhooks/webhookEventType.ts index 0406c129d..a81a05b6d 100644 --- a/shared/api/webhooks/webhookEventType.ts +++ b/shared/api/webhooks/webhookEventType.ts @@ -4,7 +4,8 @@ export enum WebhookEventType { BalancesUsageAlertTriggered = "balances.usage_alert_triggered", BalancesLimitReached = "balances.limit_reached", - BalancesAutoTopupSucceeded = "balances.auto_topup_succeeded", + + BillingAutoTopupSucceeded = "billing.auto_topup_succeeded", VercelResourcesDeleted = "vercel.resources.deleted", VercelResourcesProvisioned = "vercel.resources.provisioned", diff --git a/shared/api/webhooks/webhookRegistry.ts b/shared/api/webhooks/webhookRegistry.ts index 414fc96a9..d204d6f8e 100644 --- a/shared/api/webhooks/webhookRegistry.ts +++ b/shared/api/webhooks/webhookRegistry.ts @@ -1,7 +1,7 @@ import type { z } from "zod/v4"; -import { BalancesAutoTopupSucceededSchema } from "./balances/balancesAutoTopupSucceeded.js"; import { BalancesLimitReachedSchema } from "./balances/balancesLimitReached.js"; import { BalancesUsageAlertTriggeredSchema } from "./balances/balancesUsageAlertTriggered.js"; +import { BillingAutoTopupSucceededSchema } from "./billing/billingAutoTopupSucceeded.js"; import { VercelResourceDeletedSchema } from "./vercel/vercelResourceDeleted.js"; import { VercelResourceProvisionedSchema } from "./vercel/vercelResourceProvisioned.js"; import { VercelResourceRotateSecretsSchema } from "./vercel/vercelResourceRotateSecrets.js"; @@ -40,12 +40,14 @@ export const webhookRegistry: WebhookDefinition[] = [ description: "Fired when a customer reaches the limit for a feature (included allowance, max purchase, or spend limit).", }, + + // ── Billing ─────────────────────────────────────────────────────────── { - eventType: WebhookEventType.BalancesAutoTopupSucceeded, - operationId: "balancesAutoTopupSucceeded", + eventType: WebhookEventType.BillingAutoTopupSucceeded, + operationId: "billingAutoTopupSucceeded", title: "Auto Top-Up Succeeded", - schema: BalancesAutoTopupSucceededSchema, - group: "Balances", + schema: BillingAutoTopupSucceededSchema, + group: "Billing", description: "Fired when an automatic top-up grants additional prepaid balance.", },