rename auto-topup webhook to billing.auto_topup_succeeded and update docs

This commit is contained in:
Owen Greenhalgh
2026-05-01 10:06:59 +01:00
parent 096edfd158
commit 27bcdb79d1
10 changed files with 154 additions and 27 deletions

View File

@@ -0,0 +1,56 @@
---
title: "Auto Top-Up Succeeded"
openapi: "api/openapi.yml webhook billing.auto_topup_succeeded"
---
### Payload Fields
<ParamField body="customer_id" type="string" required>
The ID of the customer whose balance was topped up.
</ParamField>
<ParamField body="feature_id" type="string" required>
The feature ID that was automatically topped up.
</ParamField>
<ParamField body="quantity_granted" type="number" required>
The normalized amount of balance granted by the top-up.
</ParamField>
<ParamField body="threshold" type="number" required>
The configured balance threshold that triggered the top-up.
</ParamField>
<ParamField body="balance_after" type="number" required>
The customer's remaining balance for the feature after the top-up.
</ParamField>
<ParamField body="invoice_mode" type="boolean" required>
Whether the auto top-up created a `send_invoice` invoice instead of auto-charging the saved payment method.
</ParamField>
<ParamField body="invoice" type="object" required>
The invoice created for the auto top-up.
<Expandable title="properties">
<ParamField body="stripe_id" type="string" required>
The Stripe invoice ID. Use this as a stable dedupe key.
</ParamField>
<ParamField body="status" type="string">
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.
</ParamField>
<ParamField body="total" type="number" required>
The total amount of the invoice in the smallest currency unit (e.g. cents for USD), matching Stripe's `invoice.total`.
</ParamField>
<ParamField body="currency" type="string" required>
The ISO currency code for the invoice.
</ParamField>
<ParamField body="hosted_invoice_url" type="string">
URL to the hosted invoice page, if available.
</ParamField>
</Expandable>
</ParamField>

View File

@@ -271,6 +271,12 @@
"api-reference/webhooks/balancesLimitReached"
]
},
{
"group": "Billing",
"pages": [
"api-reference/webhooks/billingAutoTopupSucceeded"
]
},
{
"group": "Vercel",
"pages": [

View File

@@ -170,4 +170,8 @@ This limits the customer to 5 auto top-ups per month. Supported intervals: `hour
<Note>
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.
</Note>
</Note>
## 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.

View File

@@ -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.

View File

@@ -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(),

View File

@@ -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<AutoTopupSucceededPayload>({
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<AutoTopupSucceededPayload>({
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<AutoTopupSucceededPayload>({
token: playToken,
predicate: (payload) =>
payload.type === WebhookEventType.BalancesAutoTopupSucceeded &&
payload.type === WebhookEventType.BillingAutoTopupSucceeded &&
payload.data?.customer_id === customerId,
timeoutMs: 10_000,
});

View File

@@ -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
>;

View File

@@ -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";

View File

@@ -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",

View File

@@ -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.",
},