@usebillow/sdk 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/dist/{billing-B-VyZCXV.d.cts → billing-B16zL5xU.d.cts} +5 -1
- package/dist/{billing-CgjcWmvb.d.ts → billing-BF4OGaLI.d.ts} +5 -1
- package/dist/{chunk-JG2OOAMX.js → chunk-GT5VBLN5.js} +104 -2
- package/dist/chunk-GT5VBLN5.js.map +1 -0
- package/dist/config.d.cts +3 -3
- package/dist/config.d.ts +3 -3
- package/dist/{hosted-domains-z_I0edmM.d.ts → hosted-domains-DSTQ1GZz.d.ts} +161 -16
- package/dist/{hosted-domains-BYYl2DSa.d.cts → hosted-domains-DwakPG4J.d.cts} +161 -16
- package/dist/index.cjs +102 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +103 -14
- package/dist/index.d.ts +103 -14
- package/dist/index.js +1 -1
- package/dist/ingestion.d.cts +3 -3
- package/dist/ingestion.d.ts +3 -3
- package/dist/react.d.cts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/server.d.cts +3 -3
- package/dist/server.d.ts +3 -3
- package/dist/webhooks.cjs.map +1 -1
- package/dist/webhooks.d.cts +10 -2
- package/dist/webhooks.d.ts +10 -2
- package/dist/webhooks.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-JG2OOAMX.js.map +0 -1
- package/dist/{index.d-CKQAhQfJ.d.ts → index.d-DSEYhV2c.d.cts} +2 -2
- package/dist/{index.d-CKQAhQfJ.d.cts → index.d-DSEYhV2c.d.ts} +2 -2
package/dist/webhooks.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/webhook-event-types.ts","../src/webhooks.ts"],"names":[],"mappings":";;;AASO,IAAM,mBAAA,GAAsB;AAAA,EACjC,kBAAA;AAAA,EACA,eAAA;AAAA,EACA,iBAAA;AAAA,EACA,iBAAA;AAAA,EACA,yBAAA;AAAA,EACA,cAAA;AAAA,EACA,wBAAA;AAAA,EACA,4BAAA;AAAA,EACA,wBAAA;AAAA,EACA,gCAAA;AAAA,EACA,sBAAA;AAAA,EACA,uBAAA;AAAA,EACA,6BAAA;AAAA,EACA,sCAAA;AAAA,EACA,qBAAA;AAAA,EACA,qBAAA;AAAA,EACA,sBAAA;AAAA,EACA,sBAAA;AAAA,EACA,+BAAA;AAAA,EACA,6BAAA;AAAA,EACA,kCAAA;AAAA,EACA,iCAAA;AAAA,EACA,uBAAA;AAAA,EACA,oBAAA;AAAA,EACA,qBAAA;AAAA,EACA,mBAAA;AAAA,EACA,yBAAA;AAAA,EACA,aAAA;AAAA,EACA,mBAAA;AAAA,EACA,kCAAA;AAAA,EACA,sBAAA;AAAA,EACA,sBAAA;AAAA,EACA,4BAAA;AAAA,EACA,yBAAA;AAAA,EACA,sBAAA;AAAA,EACA,wBAAA;AAAA,EACA,2BAAA;AAAA,EACA,yBAAA;AAAA,EACA,2BAAA;AAAA,EACA,oCAAA;AAAA,EACA;AACF;AAqBA,SAAS,qBACP,MAAA,EACsD;AACtD,EAAA,OAAO,mBAAA,CAAoB,MAAA;AAAA,IAAO,CAAC,IAAA,KACjC,IAAA,CAAK,UAAA,CAAW,MAAM;AAAA,GACxB;AACF;AAGO,IAAM,kBAAA,GAAiD,qBAAqB,SAAS;AAErF,IAAM,mBAAA,GAAmD,qBAAqB,UAAU;AAMxF,IAAM,wBAAA,GACX,qBAAqB,eAAe;AAE/B,IAAM,uBAAA,GACX,qBAAqB,cAAc;AAE9B,IAAM,iBAAA,GAA+C,qBAAqB,QAAQ;AAElF,IAAM,kBAAA,GAAiD,qBAAqB,SAAS;AAErF,IAAM,yBAAA,GACX,qBAAqB,gBAAgB;AAGhC,SAAS,oBAAoB,IAAA,EAA6C;AAC/E,EAAA,OAAO,IAAA,CAAK,WAAW,eAAe,CAAA;AACxC;;;ACwMO,IAAM,wBAAA,GAAN,cAAuC,KAAA,CAAM;AAAA,EAClD,YAAY,OAAA,EAAiB;AAC3B,IAAA,KAAA,CAAM,OAAO,CAAA;AACb,IAAA,IAAA,CAAK,IAAA,GAAO,0BAAA;AAAA,EACd;AACF;AAEA,IAAM,yBAAA,GAA4B,GAAA;AAClC,IAAM,OAAA,GAAU,IAAI,WAAA,EAAY;AAQhC,SAAS,YAAY,MAAA,EAAqC;AACxD,EAAA,IAAI,YAAY,MAAA,CAAO,GAAA;AACvB,EAAA,MAAM,aAAuB,EAAC;AAC9B,EAAA,KAAA,MAAW,IAAA,IAAQ,MAAA,CAAO,KAAA,CAAM,GAAG,CAAA,EAAG;AACpC,IAAA,MAAM,EAAA,GAAK,IAAA,CAAK,OAAA,CAAQ,GAAG,CAAA;AAC3B,IAAA,IAAI,OAAO,EAAA,EAAI;AACf,IAAA,MAAM,MAAM,IAAA,CAAK,KAAA,CAAM,CAAA,EAAG,EAAE,EAAE,IAAA,EAAK;AACnC,IAAA,MAAM,QAAQ,IAAA,CAAK,KAAA,CAAM,EAAA,GAAK,CAAC,EAAE,IAAA,EAAK;AACtC,IAAA,IAAI,GAAA,KAAQ,GAAA,EAAK,SAAA,GAAY,MAAA,CAAO,KAAK,CAAA;AAAA,SAAA,IAChC,GAAA,KAAQ,IAAA,EAAM,UAAA,CAAW,IAAA,CAAK,KAAK,CAAA;AAAA,EAC9C;AACA,EAAA,IAAI,CAAC,OAAO,QAAA,CAAS,SAAS,KAAK,UAAA,CAAW,MAAA,KAAW,GAAG,OAAO,IAAA;AACnE,EAAA,OAAO,EAAE,WAAW,UAAA,EAAW;AACjC;AAEA,eAAe,OAAA,CAAQ,QAAgB,OAAA,EAAkC;AACvE,EAAA,MAAM,GAAA,GAAM,MAAM,MAAA,CAAO,MAAA,CAAO,SAAA;AAAA,IAC9B,KAAA;AAAA,IACA,OAAA,CAAQ,OAAO,MAAM,CAAA;AAAA,IACrB,EAAE,IAAA,EAAM,MAAA,EAAQ,IAAA,EAAM,SAAA,EAAU;AAAA,IAChC,KAAA;AAAA,IACA,CAAC,MAAM;AAAA,GACT;AACA,EAAA,MAAM,GAAA,GAAM,MAAM,MAAA,CAAO,MAAA,CAAO,IAAA,CAAK,QAAQ,GAAA,EAAK,OAAA,CAAQ,MAAA,CAAO,OAAO,CAAC,CAAA;AACzE,EAAA,OAAO,MAAM,IAAA,CAAK,IAAI,WAAW,GAAG,CAAA,EAAG,CAAC,CAAA,KAAM,CAAA,CAAE,QAAA,CAAS,EAAE,EAAE,QAAA,CAAS,CAAA,EAAG,GAAG,CAAC,CAAA,CAAE,KAAK,EAAE,CAAA;AACxF;AAGA,SAAS,eAAA,CAAgB,GAAW,CAAA,EAAoB;AACtD,EAAA,IAAI,CAAA,CAAE,MAAA,KAAW,CAAA,CAAE,MAAA,EAAQ,OAAO,KAAA;AAClC,EAAA,IAAI,QAAA,GAAW,CAAA;AACf,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,CAAA,CAAE,MAAA,EAAQ,CAAA,EAAA,EAAK,QAAA,IAAY,CAAA,CAAE,UAAA,CAAW,CAAC,CAAA,GAAI,CAAA,CAAE,WAAW,CAAC,CAAA;AAC/E,EAAA,OAAO,QAAA,KAAa,CAAA;AACtB;AAOA,eAAsB,gBACpB,OAAA,EACA,MAAA,EACA,MAAA,EACA,IAAA,GAAsB,EAAC,EACL;AAClB,EAAA,IAAI,CAAC,QAAQ,OAAO,KAAA;AACpB,EAAA,MAAM,MAAA,GAAS,YAAY,MAAM,CAAA;AACjC,EAAA,IAAI,CAAC,QAAQ,OAAO,KAAA;AAEpB,EAAA,MAAM,SAAA,GAAY,KAAK,gBAAA,IAAoB,yBAAA;AAC3C,EAAA,MAAM,MAAM,IAAA,CAAK,KAAA,CAAM,IAAA,CAAK,GAAA,KAAQ,GAAI,CAAA;AACxC,EAAA,IAAI,KAAK,GAAA,CAAI,GAAA,GAAM,OAAO,SAAS,CAAA,GAAI,WAAW,OAAO,KAAA;AAEzD,EAAA,MAAM,QAAA,GAAW,MAAM,OAAA,CAAQ,MAAA,EAAQ,GAAG,MAAA,CAAO,SAAS,CAAA,CAAA,EAAI,OAAO,CAAA,CAAE,CAAA;AACvE,EAAA,OAAO,MAAA,CAAO,WAAW,IAAA,CAAK,CAAC,QAAQ,eAAA,CAAgB,GAAA,EAAK,QAAQ,CAAC,CAAA;AACvE;AAYA,eAAsB,WAAA,CACpB,OAAA,EACA,MAAA,EACA,IAAA,GAA+B,EAAC,EACf;AACjB,EAAA,MAAM,SAAA,GAAY,KAAK,SAAA,IAAa,IAAA,CAAK,MAAM,IAAA,CAAK,GAAA,KAAQ,GAAI,CAAA;AAChE,EAAA,MAAM,SAAA,GAAY,MAAM,OAAA,CAAQ,MAAA,EAAQ,GAAG,SAAS,CAAA,CAAA,EAAI,OAAO,CAAA,CAAE,CAAA;AACjE,EAAA,OAAO,CAAA,EAAA,EAAK,SAAS,CAAA,IAAA,EAAO,SAAS,CAAA,CAAA;AACvC;AASA,eAAsB,eACpB,OAAA,EACA,MAAA,EACA,MAAA,EACA,IAAA,GAAsB,EAAC,EACA;AACvB,EAAA,IAAI,CAAE,MAAM,eAAA,CAAgB,SAAS,MAAA,EAAQ,MAAA,EAAQ,IAAI,CAAA,EAAI;AAC3D,IAAA,MAAM,IAAI,yBAAyB,8CAA8C,CAAA;AAAA,EACnF;AACA,EAAA,IAAI,MAAA;AACJ,EAAA,IAAI;AACF,IAAA,MAAA,GAAS,IAAA,CAAK,MAAM,OAAO,CAAA;AAAA,EAC7B,CAAA,CAAA,MAAQ;AACN,IAAA,MAAM,IAAI,yBAAyB,0CAA0C,CAAA;AAAA,EAC/E;AACA,EAAA,IAAI,CAAC,cAAA,CAAe,MAAM,CAAA,EAAG;AAC3B,IAAA,MAAM,IAAI,yBAAyB,sDAAsD,CAAA;AAAA,EAC3F;AACA,EAAA,OAAO,MAAA;AACT;AAsCO,SAAS,wBAAwB,MAAA,EAImD;AACzF,EAAA,MAAM,EAAE,MAAA,EAAQ,QAAA,EAAU,gBAAA,EAAiB,GAAI,MAAA;AAC/C,EAAA,MAAM,IAAA,GAAO,gBAAA,KAAqB,MAAA,GAAY,MAAA,GAAY,EAAE,gBAAA,EAAiB;AAC7E,EAAA,OAAO,OAAO,SAAS,eAAA,KAAoB;AACzC,IAAA,MAAM,QAAQ,MAAM,cAAA,CAAe,OAAA,EAAS,eAAA,EAAiB,QAAQ,IAAI,CAAA;AAUzE,IAAA,MAAM,GAAA,GAAM,MAAA,CAAO,MAAA,CAAO,QAAA,EAAU,KAAA,CAAM,IAAI,CAAA,GACzC,QAAA,CAAS,KAAA,CAAM,IAAI,CAAA,GACpB,MAAA;AACJ,IAAA,MAAM,OAAA,GAAU,OAAO,QAAA,CAAS,OAAA;AAChC,IAAA,IAAI,OAAA,EAAS,MAAM,OAAA,CAAQ,KAAK,CAAA;AAChC,IAAA,OAAO,KAAA;AAAA,EACT,CAAA;AACF;AAEA,SAAS,eAAe,KAAA,EAAuC;AAC7D,EAAA,IAAI,OAAO,KAAA,KAAU,QAAA,IAAY,KAAA,KAAU,MAAM,OAAO,KAAA;AACxD,EAAA,MAAM,CAAA,GAAI,KAAA;AAOV,EAAA,OACE,OAAO,EAAE,EAAA,KAAO,QAAA,IAChB,OAAO,CAAA,CAAE,OAAA,KAAY,YACrB,OAAO,CAAA,CAAE,SAAS,QAAA,IAClB,OAAO,EAAE,SAAA,KAAc,QAAA,IACvB,OAAO,CAAA,CAAE,IAAA,KAAS,QAAA,IAClB,CAAA,CAAE,IAAA,KAAS,IAAA;AAEf","file":"webhooks.cjs","sourcesContent":["/**\n * The webhook event catalog: every event type billow emits, and its families as types and runtime\n * arrays (for subscription pickers and exhaustive routing). Re-exported by ./webhooks.ts.\n */\n\n/**\n * Every event type billow emits, in contract order. Mirrors `@billow/core`'s\n * EVENT_TYPES - exported as a runtime array so UIs can render a subscription picker.\n */\nexport const WEBHOOK_EVENT_TYPES = [\n \"charge.succeeded\",\n \"charge.failed\",\n \"charge.refunded\",\n \"charge.disputed\",\n \"charge.dispute_resolved\",\n \"invoice.paid\",\n \"invoice.payment_failed\",\n \"subscription.trial_started\",\n \"subscription.activated\",\n \"subscription.activation_failed\",\n \"subscription.renewed\",\n \"subscription.past_due\",\n \"subscription.payment_failed\",\n \"subscription.requires_payment_method\",\n \"subscription.unpaid\",\n \"subscription.paused\",\n \"subscription.resumed\",\n \"subscription.updated\",\n \"subscription.cancel_scheduled\",\n \"subscription.cancel_resumed\",\n \"subscription.downgrade_scheduled\",\n \"subscription.downgrade_canceled\",\n \"subscription.canceled\",\n \"subscription.ended\",\n \"entitlement.granted\",\n \"entitlement.reset\",\n \"usage.threshold_reached\",\n \"usage.alert\",\n \"usage.cap_reached\",\n \"credit_balance.threshold_crossed\",\n \"credit_grant.created\",\n \"credit_grant.expired\",\n \"credit_reservation.expired\",\n \"credit_top_up.succeeded\",\n \"credit_top_up.failed\",\n \"credit_top_up.refunded\",\n \"credit_top_up.unfulfilled\",\n \"hosted_domain.activated\",\n \"hosted_domain.dns_failing\",\n \"hosted_domain.reassignment_pending\",\n \"hosted_domain.disabled\",\n] as const;\n\n/** A valid billow event type (the compile-time freeze derived from the array above). */\nexport type BillowEventType = (typeof WEBHOOK_EVENT_TYPES)[number];\n\n/** The `charge.*` subset of {@link BillowEventType}. */\nexport type ChargeEventType = Extract<BillowEventType, `charge.${string}`>;\n/** The `invoice.*` subset of {@link BillowEventType}. */\nexport type InvoiceEventType = Extract<BillowEventType, `invoice.${string}`>;\n/** The `subscription.*` subset of {@link BillowEventType} - the events that change a subscription. */\nexport type SubscriptionEventType = Extract<BillowEventType, `subscription.${string}`>;\n/** The `entitlement.*` subset of {@link BillowEventType}. */\nexport type EntitlementEventType = Extract<BillowEventType, `entitlement.${string}`>;\n/** The `usage.*` subset of {@link BillowEventType}. */\nexport type UsageEventType = Extract<BillowEventType, `usage.${string}`>;\n/** The prepaid credits subset of {@link BillowEventType} (`credit_balance.*`, `credit_grant.*`, ...). */\nexport type CreditEventType = Extract<BillowEventType, `credit_${string}`>;\n/** The `hosted_domain.*` subset of {@link BillowEventType}: custom portal domains. */\nexport type HostedDomainEventType = Extract<BillowEventType, `hosted_domain.${string}`>;\n\n/** The subset of {@link WEBHOOK_EVENT_TYPES} sharing `prefix`, as a runtime array (order preserved). */\nfunction eventTypesWithPrefix<P extends string>(\n prefix: P,\n): readonly Extract<BillowEventType, `${P}${string}`>[] {\n return WEBHOOK_EVENT_TYPES.filter((type): type is Extract<BillowEventType, `${P}${string}`> =>\n type.startsWith(prefix),\n );\n}\n\n/** The `charge.*` event types, as a runtime array (for pickers / exhaustive routing). */\nexport const CHARGE_EVENT_TYPES: readonly ChargeEventType[] = eventTypesWithPrefix(\"charge.\");\n/** The `invoice.*` event types, as a runtime array. */\nexport const INVOICE_EVENT_TYPES: readonly InvoiceEventType[] = eventTypesWithPrefix(\"invoice.\");\n/**\n * The subscription lifecycle event types - the `subscription.*` subset of\n * {@link WEBHOOK_EVENT_TYPES}, as a runtime array. Use it (or {@link isSubscriptionEvent})\n * to select the events that affect a subscription mirror without a brittle string match.\n */\nexport const SUBSCRIPTION_EVENT_TYPES: readonly SubscriptionEventType[] =\n eventTypesWithPrefix(\"subscription.\");\n/** The `entitlement.*` event types, as a runtime array. */\nexport const ENTITLEMENT_EVENT_TYPES: readonly EntitlementEventType[] =\n eventTypesWithPrefix(\"entitlement.\");\n/** The `usage.*` event types, as a runtime array. */\nexport const USAGE_EVENT_TYPES: readonly UsageEventType[] = eventTypesWithPrefix(\"usage.\");\n/** The prepaid credits event types, as a runtime array. */\nexport const CREDIT_EVENT_TYPES: readonly CreditEventType[] = eventTypesWithPrefix(\"credit_\");\n/** The `hosted_domain.*` event types, as a runtime array. */\nexport const HOSTED_DOMAIN_EVENT_TYPES: readonly HostedDomainEventType[] =\n eventTypesWithPrefix(\"hosted_domain.\");\n\n/** Whether `type` is a subscription lifecycle event (`subscription.*`); narrows the type. */\nexport function isSubscriptionEvent(type: string): type is SubscriptionEventType {\n return type.startsWith(\"subscription.\");\n}\n","/**\n * Verify and parse billow webhook deliveries.\n *\n * billow signs each delivery with HMAC-SHA256 over `\"<timestamp>.<rawBody>\"` and\n * sends it in the `billow-signature` header as `t=<unix-seconds>,v1=<hex>`. The\n * timestamp lets you reject replays outside a freshness window.\n *\n * Verification is async and uses the Web Crypto API (`globalThis.crypto.subtle`),\n * so the same code runs in Node 20+, edge runtimes, Cloudflare Workers, Deno, and\n * Bun — it never imports `node:crypto`.\n *\n * import { constructEvent } from \"@usebillow/sdk/webhooks\";\n *\n * const event = await constructEvent(rawBody, req.headers[\"billow-signature\"], secret);\n * switch (event.type) {\n * case \"invoice.paid\": // …\n * }\n */\n\nimport type { CreditGrantKind, CreditThresholdLevel } from \"./types/credits\";\nimport type {\n HostedDomainActivatedData,\n HostedDomainDisabledData,\n HostedDomainDnsFailingData,\n HostedDomainReassignmentPendingData,\n} from \"./types/hosted-domains\";\nimport type {\n BillowEventType,\n EntitlementEventType,\n InvoiceEventType,\n SubscriptionEventType,\n} from \"./webhook-event-types\";\n\nexport * from \"./webhook-event-types\";\n\n/**\n * The fields every webhook envelope carries. Each {@link WebhookEvent} variant intersects\n * this with its own `type` + payload-typed `data`.\n */\nexport interface WebhookEventEnvelope {\n /** Stable event id — your idempotency key (delivery is at-least-once). */\n id: string;\n /** Envelope schema version. */\n version: number;\n /** ISO-8601 timestamp of when the event was recorded. */\n createdAt: string;\n}\n\n// --- Per-event-group payloads -------------------------------------------------------------\n// Shapes mirror what the server emits (packages/server/src/services: charges.ts, billing.ts,\n// renewal/*, subscriptions/*, entitlements/*). Grouped where a group's emitters are uniform;\n// `usage.*` is split per event because its emitters diverge. `customerExternalId` is added to\n// every customer-scoped payload by the server's outbox emitter (events.ts).\n\n/** `charge.succeeded` / `charge.failed`. */\nexport interface ChargeEventData {\n chargeId: string;\n /**\n * `false` on a `charge.failed` that closed a checkout its customer abandoned long before: no\n * \"payment failed\" email was sent to the customer. Absent otherwise.\n */\n notifyCustomer?: false;\n}\n/** `charge.refunded`, emitted once for each succeeded refund transition. */\nexport interface ChargeRefundedEventData {\n chargeId: string;\n refundId: string;\n amount: number;\n cumulativeAmount: number;\n currency: string;\n}\n/** Stable dispute facts shared by the opened and resolved events. */\nexport interface ChargeDisputeEventData {\n chargeId: string;\n disputeId: string;\n amount: number;\n currency: string;\n reason: string | null;\n state: \"open\" | \"won\" | \"lost\";\n}\n/** `invoice.paid` / `invoice.payment_failed`. */\nexport interface InvoiceEventData {\n invoiceId: string;\n chargeId: string;\n /**\n * `false` on an `invoice.payment_failed` that closed a checkout its customer abandoned long\n * before: no \"payment failed\" email was sent to the customer. Absent otherwise.\n */\n notifyCustomer?: false;\n}\n/** Any `subscription.*` lifecycle event. */\nexport interface SubscriptionEventData {\n subscriptionId: string;\n customerId: string;\n customerExternalId: string;\n /** The subscription's plan — so a consumer knows which product the event is about. */\n productId: string;\n productSlug: string;\n /** The product's metadata map (Stripe-style); `{}` when the plan carries none. */\n productMetadata: Record<string, string>;\n /** Set on dunning events that reference the unpaid invoice (`unpaid`, `requires_payment_method`). */\n invoiceId?: string;\n /** Set on lifecycle-ending events (`ended`, downgrade-driven `updated`). */\n reason?: string;\n /** Present when a downgrade is scheduled; null when that schedule is cleared. */\n scheduledChange?: {\n productVersionId: string;\n productId: string;\n productSlug: string;\n productName: string;\n effectiveAt: string | null;\n } | null;\n}\n/** `entitlement.granted` / `entitlement.reset`. */\nexport interface EntitlementEventData {\n subscriptionId: string;\n customerId: string;\n customerExternalId: string;\n featureId: string;\n /** Remaining allowance; `null` when the feature is unlimited. */\n balance: number | null;\n /** Whether the feature grants unlimited usage. */\n unlimited: boolean;\n}\n/** Fields shared by every `usage.*` event. */\nexport interface UsageEventBase {\n subscriptionId: string;\n customerId: string;\n customerExternalId: string;\n /** Feature slug. */\n feature: string;\n featureName: string;\n /** Aggregate usage after this step. */\n used: number;\n /** Included allowance; `null` when unlimited. */\n allowance: number | null;\n /** Remaining allowance; `null` when unlimited. */\n balance: number | null;\n}\n/** `usage.threshold_reached` — a built-in usage threshold was crossed. */\nexport interface UsageThresholdReachedData extends UsageEventBase {\n featureId: string;\n /** The threshold crossed (currently only the included allowance being exhausted). */\n level: \"exhausted\";\n}\n/** `usage.alert` — a configured usage alert fired. */\nexport interface UsageAlertData extends UsageEventBase {\n metric: string;\n threshold: number;\n recurring: boolean;\n notifyCustomer: boolean;\n}\n/** `usage.cap_reached` — a configured spend/usage cap was reached. */\nexport interface UsageCapReachedData extends UsageEventBase {\n metric: string;\n cap: number;\n capAt: number;\n}\n\n// Prepaid credits: every credit quantity is a decimal string of microcredits, never a number.\n// Payloads carry ids, codes and quantities, never a grant's, reservation's or top-up's reason or\n// metadata.\n\n/** Fields shared by every prepaid credits event. */\nexport interface CreditEventBase {\n customerId: string;\n customerExternalId: string;\n}\n/**\n * `credit_balance.threshold_crossed` - the balance moved to another level, in either direction\n * (raise a low-balance banner, or clear it). `exhausted` when nothing is spendable; otherwise\n * `critical` / `low` at or below the percentages of `thresholds.reference`, else `normal`.\n */\nexport interface CreditThresholdCrossedData extends CreditEventBase {\n level: CreditThresholdLevel;\n previousLevel: CreditThresholdLevel;\n /** The balance the level was judged on, as of `asOf`; re-read the balance for truth. */\n balance: { spendable: string; held: string; asOf: string };\n thresholds: { lowPercent: number; criticalPercent: number; reference: string | null };\n}\n/** `credit_grant.created` - credits were granted. */\nexport interface CreditGrantCreatedData extends CreditEventBase {\n grantId: string;\n kind: CreditGrantKind;\n amount: string;\n expiresAt: string | null;\n eligibility: string[] | null;\n topUpId: string | null;\n}\n/**\n * `credit_grant.expired` - one expiry took `expired` credits off a grant: its unheld credits when\n * it expired, or a held part that came back to it afterwards (one more event each).\n */\nexport interface CreditGrantExpiredData extends CreditEventBase {\n grantId: string;\n kind: CreditGrantKind;\n expiresAt: string | null;\n expired: string;\n /** The `expire` ledger transaction. */\n transactionId: string;\n}\n/** `credit_reservation.expired` - a hold passed its expiry unsettled and was released. */\nexport interface CreditReservationExpiredData extends CreditEventBase {\n reservationId: string;\n operationKey: string;\n category: string;\n amount: string;\n expiresAt: string;\n}\n/** What every `credit_top_up.*` event says about the purchase and its payment. */\nexport interface CreditTopUpEventBase extends CreditEventBase {\n topUpId: string;\n packId: string;\n chargeId: string;\n /** What the payment collects, in minor units of `currency`. */\n amount: number;\n currency: string;\n /** The terms bought. */\n credits: string;\n bonusCredits: string;\n}\n/** `credit_top_up.succeeded` - the payment succeeded and its credits (and any bonus) were granted. */\nexport interface CreditTopUpSucceededData extends CreditTopUpEventBase {\n purchasedGrantId: string | null;\n bonusGrantId: string | null;\n}\n/**\n * `credit_top_up.failed` - the payment failed. A later payment on the same checkout can still\n * succeed it: `credit_top_up.succeeded` may follow for the same top-up.\n */\nexport type CreditTopUpFailedData = CreditTopUpEventBase;\n/** `credit_top_up.unfulfilled` - paid after the customer was erased: nothing granted, refund it. */\nexport type CreditTopUpUnfulfilledData = CreditTopUpEventBase;\n/**\n * `credit_top_up.refunded` - a refund or lost chargeback revoked the top-up's credits in\n * proportion to the money returned.\n */\nexport interface CreditTopUpRefundedData extends CreditTopUpEventBase {\n cause: \"refund\" | \"chargeback\";\n /** Money returned so far (refunds plus lost disputes, at most what was charged), minor units. */\n amountReturned: number;\n /** Microcredits this refund or chargeback revoked. */\n revokedNow: string;\n /** Microcredits revoked so far in all. */\n revoked: string;\n /** Of them, what the customer had already spent and nothing has paid back. */\n shortfall: string;\n}\n\n/**\n * The verified webhook envelope billow POSTs to your endpoint — a discriminated union on\n * {@link WebhookEvent.type}. `switch` (or a {@link WebhookHandlers} map) on `type` and `data`\n * narrows to the exact payload for that event.\n *\n * switch (event.type) {\n * case \"invoice.paid\": settle(event.data.invoiceId); break; // InvoiceEventData\n * case \"subscription.canceled\": revoke(event.data.customerExternalId); break;\n * }\n */\nexport type WebhookEventDataMap = {\n [K in \"charge.succeeded\" | \"charge.failed\"]: ChargeEventData;\n} & {\n \"charge.refunded\": ChargeRefundedEventData;\n} & {\n [K in \"charge.disputed\" | \"charge.dispute_resolved\"]: ChargeDisputeEventData;\n} & {\n [K in InvoiceEventType]: InvoiceEventData;\n} & {\n [K in SubscriptionEventType]: SubscriptionEventData;\n} & {\n [K in EntitlementEventType]: EntitlementEventData;\n} & {\n \"usage.threshold_reached\": UsageThresholdReachedData;\n \"usage.alert\": UsageAlertData;\n \"usage.cap_reached\": UsageCapReachedData;\n} & {\n \"credit_balance.threshold_crossed\": CreditThresholdCrossedData;\n \"credit_grant.created\": CreditGrantCreatedData;\n \"credit_grant.expired\": CreditGrantExpiredData;\n \"credit_reservation.expired\": CreditReservationExpiredData;\n \"credit_top_up.succeeded\": CreditTopUpSucceededData;\n \"credit_top_up.failed\": CreditTopUpFailedData;\n \"credit_top_up.refunded\": CreditTopUpRefundedData;\n \"credit_top_up.unfulfilled\": CreditTopUpUnfulfilledData;\n} & {\n \"hosted_domain.activated\": HostedDomainActivatedData;\n \"hosted_domain.dns_failing\": HostedDomainDnsFailingData;\n \"hosted_domain.reassignment_pending\": HostedDomainReassignmentPendingData;\n \"hosted_domain.disabled\": HostedDomainDisabledData;\n};\n\n/** One distributive union member per event literal, derived from the payload map. */\nexport type WebhookEvent = {\n [K in BillowEventType]: WebhookEventEnvelope & { type: K; data: WebhookEventDataMap[K] };\n}[BillowEventType];\n\nexport interface VerifyOptions {\n /**\n * Max age (seconds) of a delivery's signature timestamp before it's rejected as\n * a possible replay. Default 300 (5 minutes).\n */\n toleranceSeconds?: number;\n}\n\n/** Thrown by {@link constructEvent} when a delivery can't be trusted. */\nexport class WebhookVerificationError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"WebhookVerificationError\";\n }\n}\n\nconst DEFAULT_TOLERANCE_SECONDS = 300;\nconst encoder = new TextEncoder();\n\ninterface ParsedHeader {\n timestamp: number;\n signatures: string[];\n}\n\n/** Parse `t=<unix>,v1=<hex>[,v1=<hex>…]` — multiple v1 supports key rotation. */\nfunction parseHeader(header: string): ParsedHeader | null {\n let timestamp = Number.NaN;\n const signatures: string[] = [];\n for (const part of header.split(\",\")) {\n const eq = part.indexOf(\"=\");\n if (eq === -1) continue;\n const key = part.slice(0, eq).trim();\n const value = part.slice(eq + 1).trim();\n if (key === \"t\") timestamp = Number(value);\n else if (key === \"v1\") signatures.push(value);\n }\n if (!Number.isFinite(timestamp) || signatures.length === 0) return null;\n return { timestamp, signatures };\n}\n\nasync function hmacHex(secret: string, payload: string): Promise<string> {\n const key = await crypto.subtle.importKey(\n \"raw\",\n encoder.encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n const sig = await crypto.subtle.sign(\"HMAC\", key, encoder.encode(payload));\n return Array.from(new Uint8Array(sig), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\n/** Constant-time compare of two equal-length hex strings (no early exit). */\nfunction timingSafeEqual(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n let mismatch = 0;\n for (let i = 0; i < a.length; i++) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);\n return mismatch === 0;\n}\n\n/**\n * Return `true` if `header` is a valid signature for `payload` under `secret` and\n * within the freshness window. Never throws — use {@link constructEvent} when you\n * want the parsed event and a thrown error on failure. Pass the RAW request body.\n */\nexport async function verifySignature(\n payload: string,\n header: string | null | undefined,\n secret: string,\n opts: VerifyOptions = {},\n): Promise<boolean> {\n if (!header) return false;\n const parsed = parseHeader(header);\n if (!parsed) return false;\n\n const tolerance = opts.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;\n const now = Math.floor(Date.now() / 1000);\n if (Math.abs(now - parsed.timestamp) > tolerance) return false;\n\n const expected = await hmacHex(secret, `${parsed.timestamp}.${payload}`);\n return parsed.signatures.some((sig) => timingSafeEqual(sig, expected));\n}\n\n/**\n * Produce a valid `billow-signature` header for `payload` under `secret` - the inverse of\n * {@link verifySignature}. Use it to exercise your webhook endpoint in tests without\n * reverse-engineering the signing scheme. Pass the RAW body you will POST. `timestamp` (unix\n * seconds) defaults to now; pass a fixed value for a deterministic - or intentionally stale\n * (replay) - signature.\n *\n * const header = await signWebhook(rawBody, endpointSecret);\n * await fetch(url, { method: \"POST\", headers: { \"billow-signature\": header }, body: rawBody });\n */\nexport async function signWebhook(\n payload: string,\n secret: string,\n opts: { timestamp?: number } = {},\n): Promise<string> {\n const timestamp = opts.timestamp ?? Math.floor(Date.now() / 1000);\n const signature = await hmacHex(secret, `${timestamp}.${payload}`);\n return `t=${timestamp},v1=${signature}`;\n}\n\n/**\n * Verify `payload` against the `billow-signature` header and return the parsed,\n * typed {@link WebhookEvent}. Throws {@link WebhookVerificationError} if the\n * signature is invalid, the timestamp is outside the freshness window, or the body\n * isn't a billow envelope. Always pass the RAW request body — a re-serialized\n * parsed body will not match.\n */\nexport async function constructEvent(\n payload: string,\n header: string | null | undefined,\n secret: string,\n opts: VerifyOptions = {},\n): Promise<WebhookEvent> {\n if (!(await verifySignature(payload, header, secret, opts))) {\n throw new WebhookVerificationError(\"billow webhook signature verification failed\");\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(payload);\n } catch {\n throw new WebhookVerificationError(\"billow webhook payload is not valid JSON\");\n }\n if (!isWebhookEvent(parsed)) {\n throw new WebhookVerificationError(\"billow webhook payload is not a valid event envelope\");\n }\n return parsed;\n}\n\n/** A handler for one verified webhook event. May be async; a throw propagates to the caller. */\nexport type WebhookHandler = (event: WebhookEvent) => void | Promise<void>;\n\n/**\n * A map of per-event-type handlers plus an optional `onEvent` fallback. Each handler receives\n * the event NARROWED to its type, so `data` is the exact payload for that event\n * (e.g. `\"invoice.paid\": (e) => settle(e.data.invoiceId)`). Keyed by the literal event type so\n * it stays in lock-step with the contract — a new event type is dispatchable with no parallel\n * `onFooBar` name registry to keep in sync.\n */\nexport type WebhookHandlers = {\n [K in BillowEventType]?: (event: Extract<WebhookEvent, { type: K }>) => void | Promise<void>;\n} & {\n /** Called for any event that has no specific handler (receives the full union). */\n onEvent?: WebhookHandler;\n};\n\n/**\n * Build a reusable dispatcher bound to your endpoint secret + handlers: it verifies a\n * delivery (via {@link constructEvent}) then routes the typed event to the matching\n * handler, falling back to `onEvent`. Returns the verified event so the caller can log\n * or ack it. Throws {@link WebhookVerificationError} on an untrusted delivery (return a\n * 4xx) and propagates any error a handler throws (return a 5xx so billow retries).\n * Pass the RAW request body — a re-serialized parsed body won't match the signature.\n *\n * const dispatch = createWebhookDispatcher({\n * secret: process.env.BILLOW_WEBHOOK_SECRET,\n * handlers: {\n * \"invoice.paid\": (e) => fulfil(e.data.invoiceId),\n * \"subscription.canceled\": (e) => revoke(e.data),\n * onEvent: (e) => log(e.type),\n * },\n * });\n * // per request:\n * const event = await dispatch(rawBody, req.headers[\"billow-signature\"]);\n */\nexport function createWebhookDispatcher(config: {\n secret: string;\n handlers: WebhookHandlers;\n toleranceSeconds?: number;\n}): (payload: string, signatureHeader: string | null | undefined) => Promise<WebhookEvent> {\n const { secret, handlers, toleranceSeconds } = config;\n const opts = toleranceSeconds === undefined ? undefined : { toleranceSeconds };\n return async (payload, signatureHeader) => {\n const event = await constructEvent(payload, signatureHeader, secret, opts);\n // Own-property lookup, NOT `handlers[event.type]` directly: `event.type` is only\n // runtime-typed as a string, so a value colliding with an inherited key\n // (\"toString\"/\"constructor\"/\"__proto__\") would otherwise resolve to a prototype\n // method and be invoked instead of routing to `onEvent`. (Not reachable via a\n // trusted delivery — the type is HMAC-verified above and billow only emits fixed\n // event types — but the fallback contract must hold for any signature-valid type.)\n // The looked-up handler expects only its own narrowed variant; cast it to accept the full\n // union so we can invoke it with `event` — safe because the handler registered under key\n // `event.type` only ever receives the matching variant at runtime.\n const own = Object.hasOwn(handlers, event.type)\n ? (handlers[event.type] as WebhookHandler | undefined)\n : undefined;\n const handler = own ?? handlers.onEvent;\n if (handler) await handler(event);\n return event;\n };\n}\n\nfunction isWebhookEvent(value: unknown): value is WebhookEvent {\n if (typeof value !== \"object\" || value === null) return false;\n const e = value as {\n id?: unknown;\n version?: unknown;\n type?: unknown;\n createdAt?: unknown;\n data?: unknown;\n };\n return (\n typeof e.id === \"string\" &&\n typeof e.version === \"number\" &&\n typeof e.type === \"string\" &&\n typeof e.createdAt === \"string\" &&\n typeof e.data === \"object\" &&\n e.data !== null\n );\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/webhook-event-types.ts","../src/webhooks.ts"],"names":[],"mappings":"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","file":"webhooks.cjs","sourcesContent":["/**\n * The webhook event catalog: every event type billow emits, and its families as types and runtime\n * arrays (for subscription pickers and exhaustive routing). Re-exported by ./webhooks.ts.\n */\n\n/**\n * Every event type billow emits, in contract order. Mirrors `@billow/core`'s\n * EVENT_TYPES - exported as a runtime array so UIs can render a subscription picker.\n */\nexport const WEBHOOK_EVENT_TYPES = [\n \"charge.succeeded\",\n \"charge.failed\",\n \"charge.refunded\",\n \"charge.disputed\",\n \"charge.dispute_resolved\",\n \"invoice.paid\",\n \"invoice.payment_failed\",\n \"subscription.trial_started\",\n \"subscription.activated\",\n \"subscription.activation_failed\",\n \"subscription.renewed\",\n \"subscription.past_due\",\n \"subscription.payment_failed\",\n \"subscription.requires_payment_method\",\n \"subscription.unpaid\",\n \"subscription.paused\",\n \"subscription.resumed\",\n \"subscription.updated\",\n \"subscription.cancel_scheduled\",\n \"subscription.cancel_resumed\",\n \"subscription.downgrade_scheduled\",\n \"subscription.downgrade_canceled\",\n \"subscription.canceled\",\n \"subscription.ended\",\n \"entitlement.granted\",\n \"entitlement.reset\",\n \"usage.threshold_reached\",\n \"usage.alert\",\n \"usage.cap_reached\",\n \"credit_balance.threshold_crossed\",\n \"credit_grant.created\",\n \"credit_grant.expired\",\n \"credit_reservation.expired\",\n \"credit_top_up.succeeded\",\n \"credit_top_up.failed\",\n \"credit_top_up.refunded\",\n \"credit_top_up.unfulfilled\",\n \"hosted_domain.activated\",\n \"hosted_domain.dns_failing\",\n \"hosted_domain.reassignment_pending\",\n \"hosted_domain.disabled\",\n] as const;\n\n/** A valid billow event type (the compile-time freeze derived from the array above). */\nexport type BillowEventType = (typeof WEBHOOK_EVENT_TYPES)[number];\n\n/** The `charge.*` subset of {@link BillowEventType}. */\nexport type ChargeEventType = Extract<BillowEventType, `charge.${string}`>;\n/** The `invoice.*` subset of {@link BillowEventType}. */\nexport type InvoiceEventType = Extract<BillowEventType, `invoice.${string}`>;\n/** The `subscription.*` subset of {@link BillowEventType} - the events that change a subscription. */\nexport type SubscriptionEventType = Extract<BillowEventType, `subscription.${string}`>;\n/** The `entitlement.*` subset of {@link BillowEventType}. */\nexport type EntitlementEventType = Extract<BillowEventType, `entitlement.${string}`>;\n/** The `usage.*` subset of {@link BillowEventType}. */\nexport type UsageEventType = Extract<BillowEventType, `usage.${string}`>;\n/** The prepaid credits subset of {@link BillowEventType} (`credit_balance.*`, `credit_grant.*`, ...). */\nexport type CreditEventType = Extract<BillowEventType, `credit_${string}`>;\n/** The `hosted_domain.*` subset of {@link BillowEventType}: custom portal domains. */\nexport type HostedDomainEventType = Extract<BillowEventType, `hosted_domain.${string}`>;\n\n/** The subset of {@link WEBHOOK_EVENT_TYPES} sharing `prefix`, as a runtime array (order preserved). */\nfunction eventTypesWithPrefix<P extends string>(\n prefix: P,\n): readonly Extract<BillowEventType, `${P}${string}`>[] {\n return WEBHOOK_EVENT_TYPES.filter((type): type is Extract<BillowEventType, `${P}${string}`> =>\n type.startsWith(prefix),\n );\n}\n\n/** The `charge.*` event types, as a runtime array (for pickers / exhaustive routing). */\nexport const CHARGE_EVENT_TYPES: readonly ChargeEventType[] = eventTypesWithPrefix(\"charge.\");\n/** The `invoice.*` event types, as a runtime array. */\nexport const INVOICE_EVENT_TYPES: readonly InvoiceEventType[] = eventTypesWithPrefix(\"invoice.\");\n/**\n * The subscription lifecycle event types - the `subscription.*` subset of\n * {@link WEBHOOK_EVENT_TYPES}, as a runtime array. Use it (or {@link isSubscriptionEvent})\n * to select the events that affect a subscription mirror without a brittle string match.\n */\nexport const SUBSCRIPTION_EVENT_TYPES: readonly SubscriptionEventType[] =\n eventTypesWithPrefix(\"subscription.\");\n/** The `entitlement.*` event types, as a runtime array. */\nexport const ENTITLEMENT_EVENT_TYPES: readonly EntitlementEventType[] =\n eventTypesWithPrefix(\"entitlement.\");\n/** The `usage.*` event types, as a runtime array. */\nexport const USAGE_EVENT_TYPES: readonly UsageEventType[] = eventTypesWithPrefix(\"usage.\");\n/** The prepaid credits event types, as a runtime array. */\nexport const CREDIT_EVENT_TYPES: readonly CreditEventType[] = eventTypesWithPrefix(\"credit_\");\n/** The `hosted_domain.*` event types, as a runtime array. */\nexport const HOSTED_DOMAIN_EVENT_TYPES: readonly HostedDomainEventType[] =\n eventTypesWithPrefix(\"hosted_domain.\");\n\n/** Whether `type` is a subscription lifecycle event (`subscription.*`); narrows the type. */\nexport function isSubscriptionEvent(type: string): type is SubscriptionEventType {\n return type.startsWith(\"subscription.\");\n}\n","/**\n * Verify and parse billow webhook deliveries.\n *\n * billow signs each delivery with HMAC-SHA256 over `\"<timestamp>.<rawBody>\"` and\n * sends it in the `billow-signature` header as `t=<unix-seconds>,v1=<hex>`. The\n * timestamp lets you reject replays outside a freshness window.\n *\n * Verification is async and uses the Web Crypto API (`globalThis.crypto.subtle`),\n * so the same code runs in Node 20+, edge runtimes, Cloudflare Workers, Deno, and\n * Bun — it never imports `node:crypto`.\n *\n * import { constructEvent } from \"@usebillow/sdk/webhooks\";\n *\n * const event = await constructEvent(rawBody, req.headers[\"billow-signature\"], secret);\n * switch (event.type) {\n * case \"invoice.paid\": // …\n * }\n */\n\nimport type { CreditGrantKind, CreditThresholdLevel } from \"./types/credits\";\nimport type {\n HostedDomainActivatedData,\n HostedDomainDisabledData,\n HostedDomainDnsFailingData,\n HostedDomainReassignmentPendingData,\n} from \"./types/hosted-domains\";\nimport type {\n BillowEventType,\n EntitlementEventType,\n InvoiceEventType,\n SubscriptionEventType,\n} from \"./webhook-event-types\";\n\nexport * from \"./webhook-event-types\";\n\n/**\n * The fields every webhook envelope carries. Each {@link WebhookEvent} variant intersects\n * this with its own `type` + payload-typed `data`.\n */\nexport interface WebhookEventEnvelope {\n /** Stable event id — your idempotency key (delivery is at-least-once). */\n id: string;\n /** Envelope schema version. */\n version: number;\n /** ISO-8601 timestamp of when the event was recorded. */\n createdAt: string;\n}\n\n// --- Per-event-group payloads -------------------------------------------------------------\n// Shapes mirror what the server emits (packages/server/src/services: charges.ts, billing.ts,\n// renewal/*, subscriptions/*, entitlements/*). Grouped where a group's emitters are uniform;\n// `usage.*` is split per event because its emitters diverge. `customerExternalId` is added to\n// every customer-scoped payload by the server's outbox emitter (events.ts).\n\n/** `charge.succeeded` / `charge.failed`. */\nexport interface ChargeEventData {\n chargeId: string;\n /**\n * `false` on a `charge.failed` that closed a checkout its customer abandoned long before: no\n * \"payment failed\" email was sent to the customer. Absent otherwise.\n */\n notifyCustomer?: false;\n}\n/** `charge.refunded`, emitted once for each succeeded refund transition. */\nexport interface ChargeRefundedEventData {\n chargeId: string;\n refundId: string;\n amount: number;\n cumulativeAmount: number;\n currency: string;\n}\n/** Stable dispute facts shared by the opened and resolved events. */\nexport interface ChargeDisputeEventData {\n chargeId: string;\n disputeId: string;\n amount: number;\n currency: string;\n reason: string | null;\n state: \"open\" | \"won\" | \"lost\";\n}\n/** `invoice.paid` / `invoice.payment_failed`. */\nexport interface InvoiceEventData {\n invoiceId: string;\n chargeId: string;\n /**\n * `false` on an `invoice.payment_failed` that closed a checkout its customer abandoned long\n * before: no \"payment failed\" email was sent to the customer. Absent otherwise.\n */\n notifyCustomer?: false;\n}\n/** Any `subscription.*` lifecycle event. */\nexport interface SubscriptionEventData {\n subscriptionId: string;\n customerId: string;\n customerExternalId: string;\n /** The subscription's plan — so a consumer knows which product the event is about. */\n productId: string;\n productSlug: string;\n /** The product's metadata map (Stripe-style); `{}` when the plan carries none. */\n productMetadata: Record<string, string>;\n /** Set on dunning events that reference the unpaid invoice (`unpaid`, `requires_payment_method`). */\n invoiceId?: string;\n /** Set on lifecycle-ending events (`ended`, downgrade-driven `updated`). */\n reason?: string;\n /** Present when a downgrade is scheduled; null when that schedule is cleared. */\n scheduledChange?: {\n productVersionId: string;\n productId: string;\n productSlug: string;\n productName: string;\n effectiveAt: string | null;\n } | null;\n}\n/** `entitlement.granted` / `entitlement.reset`. */\nexport interface EntitlementEventData {\n subscriptionId: string;\n customerId: string;\n customerExternalId: string;\n featureId: string;\n /** Remaining allowance; `null` when the feature is unlimited. */\n balance: number | null;\n /** Whether the feature grants unlimited usage. */\n unlimited: boolean;\n}\n/** Fields shared by every `usage.*` event. */\nexport interface UsageEventBase {\n subscriptionId: string;\n customerId: string;\n customerExternalId: string;\n /** Feature slug. */\n feature: string;\n featureName: string;\n /** Aggregate usage after this step. */\n used: number;\n /** Included allowance; `null` when unlimited. */\n allowance: number | null;\n /** Remaining allowance; `null` when unlimited. */\n balance: number | null;\n}\n/** `usage.threshold_reached` — a built-in usage threshold was crossed. */\nexport interface UsageThresholdReachedData extends UsageEventBase {\n featureId: string;\n /** The threshold crossed (currently only the included allowance being exhausted). */\n level: \"exhausted\";\n}\n/** `usage.alert` — a configured usage alert fired. */\nexport interface UsageAlertData extends UsageEventBase {\n metric: string;\n threshold: number;\n recurring: boolean;\n notifyCustomer: boolean;\n}\n/** `usage.cap_reached` — a configured spend/usage cap was reached. */\nexport interface UsageCapReachedData extends UsageEventBase {\n metric: string;\n cap: number;\n capAt: number;\n}\n\n// Prepaid credits: every credit quantity is a decimal string of microcredits, never a number.\n// Payloads carry ids, codes and quantities, never a grant's, reservation's or top-up's reason or\n// metadata.\n\n/** Fields shared by every prepaid credits event. */\nexport interface CreditEventBase {\n customerId: string;\n customerExternalId: string;\n}\n/**\n * `credit_balance.threshold_crossed` - the balance moved to another level, in either direction\n * (raise a low-balance banner, or clear it). `exhausted` when nothing is spendable; otherwise\n * `critical` / `low` at or below the percentages of `thresholds.reference`, else `normal`.\n */\nexport interface CreditThresholdCrossedData extends CreditEventBase {\n level: CreditThresholdLevel;\n previousLevel: CreditThresholdLevel;\n /** The balance the level was judged on, as of `asOf`; re-read the balance for truth. */\n balance: { spendable: string; held: string; asOf: string };\n thresholds: { lowPercent: number; criticalPercent: number; reference: string | null };\n}\n/** `credit_grant.created` - credits were granted. */\nexport interface CreditGrantCreatedData extends CreditEventBase {\n grantId: string;\n kind: CreditGrantKind;\n amount: string;\n /** For an `included` grant, its window's end. */\n expiresAt: string | null;\n eligibility: string[] | null;\n topUpId: string | null;\n /** An `included` grant's subscription; null for every other kind. */\n subscriptionId: string | null;\n /** The billing period an `included` grant's window belongs to; null for every other kind. */\n periodStart: string | null;\n periodEnd: string | null;\n}\n/**\n * `credit_grant.expired` - one expiry took `expired` credits off a grant: its unheld credits when\n * it expired, or a held part that came back to it afterwards (one more event each).\n */\nexport interface CreditGrantExpiredData extends CreditEventBase {\n grantId: string;\n kind: CreditGrantKind;\n expiresAt: string | null;\n expired: string;\n /** Why it expired: `grant_expired` - the grant reached its expiry. */\n reason: \"grant_expired\";\n /** The `expire` ledger transaction. */\n transactionId: string;\n}\n/** `credit_reservation.expired` - a hold passed its expiry unsettled and was released. */\nexport interface CreditReservationExpiredData extends CreditEventBase {\n reservationId: string;\n operationKey: string;\n category: string;\n amount: string;\n expiresAt: string;\n}\n/** What every `credit_top_up.*` event says about the purchase and its payment. */\nexport interface CreditTopUpEventBase extends CreditEventBase {\n topUpId: string;\n packId: string;\n chargeId: string;\n /** What the payment collects, in minor units of `currency`. */\n amount: number;\n currency: string;\n /** The terms bought. */\n credits: string;\n bonusCredits: string;\n}\n/** `credit_top_up.succeeded` - the payment succeeded and its credits (and any bonus) were granted. */\nexport interface CreditTopUpSucceededData extends CreditTopUpEventBase {\n purchasedGrantId: string | null;\n bonusGrantId: string | null;\n}\n/**\n * `credit_top_up.failed` - the payment failed. A later payment on the same checkout can still\n * succeed it: `credit_top_up.succeeded` may follow for the same top-up.\n */\nexport type CreditTopUpFailedData = CreditTopUpEventBase;\n/** `credit_top_up.unfulfilled` - paid after the customer was erased: nothing granted, refund it. */\nexport type CreditTopUpUnfulfilledData = CreditTopUpEventBase;\n/**\n * `credit_top_up.refunded` - a refund or lost chargeback revoked the top-up's credits in\n * proportion to the money returned.\n */\nexport interface CreditTopUpRefundedData extends CreditTopUpEventBase {\n cause: \"refund\" | \"chargeback\";\n /** Money returned so far (refunds plus lost disputes, at most what was charged), minor units. */\n amountReturned: number;\n /** Microcredits this refund or chargeback revoked. */\n revokedNow: string;\n /** Microcredits revoked so far in all. */\n revoked: string;\n /** Of them, what the customer had already spent and nothing has paid back. */\n shortfall: string;\n}\n\n/**\n * The verified webhook envelope billow POSTs to your endpoint — a discriminated union on\n * {@link WebhookEvent.type}. `switch` (or a {@link WebhookHandlers} map) on `type` and `data`\n * narrows to the exact payload for that event.\n *\n * switch (event.type) {\n * case \"invoice.paid\": settle(event.data.invoiceId); break; // InvoiceEventData\n * case \"subscription.canceled\": revoke(event.data.customerExternalId); break;\n * }\n */\nexport type WebhookEventDataMap = {\n [K in \"charge.succeeded\" | \"charge.failed\"]: ChargeEventData;\n} & {\n \"charge.refunded\": ChargeRefundedEventData;\n} & {\n [K in \"charge.disputed\" | \"charge.dispute_resolved\"]: ChargeDisputeEventData;\n} & {\n [K in InvoiceEventType]: InvoiceEventData;\n} & {\n [K in SubscriptionEventType]: SubscriptionEventData;\n} & {\n [K in EntitlementEventType]: EntitlementEventData;\n} & {\n \"usage.threshold_reached\": UsageThresholdReachedData;\n \"usage.alert\": UsageAlertData;\n \"usage.cap_reached\": UsageCapReachedData;\n} & {\n \"credit_balance.threshold_crossed\": CreditThresholdCrossedData;\n \"credit_grant.created\": CreditGrantCreatedData;\n \"credit_grant.expired\": CreditGrantExpiredData;\n \"credit_reservation.expired\": CreditReservationExpiredData;\n \"credit_top_up.succeeded\": CreditTopUpSucceededData;\n \"credit_top_up.failed\": CreditTopUpFailedData;\n \"credit_top_up.refunded\": CreditTopUpRefundedData;\n \"credit_top_up.unfulfilled\": CreditTopUpUnfulfilledData;\n} & {\n \"hosted_domain.activated\": HostedDomainActivatedData;\n \"hosted_domain.dns_failing\": HostedDomainDnsFailingData;\n \"hosted_domain.reassignment_pending\": HostedDomainReassignmentPendingData;\n \"hosted_domain.disabled\": HostedDomainDisabledData;\n};\n\n/** One distributive union member per event literal, derived from the payload map. */\nexport type WebhookEvent = {\n [K in BillowEventType]: WebhookEventEnvelope & { type: K; data: WebhookEventDataMap[K] };\n}[BillowEventType];\n\nexport interface VerifyOptions {\n /**\n * Max age (seconds) of a delivery's signature timestamp before it's rejected as\n * a possible replay. Default 300 (5 minutes).\n */\n toleranceSeconds?: number;\n}\n\n/** Thrown by {@link constructEvent} when a delivery can't be trusted. */\nexport class WebhookVerificationError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"WebhookVerificationError\";\n }\n}\n\nconst DEFAULT_TOLERANCE_SECONDS = 300;\nconst encoder = new TextEncoder();\n\ninterface ParsedHeader {\n timestamp: number;\n signatures: string[];\n}\n\n/** Parse `t=<unix>,v1=<hex>[,v1=<hex>…]` — multiple v1 supports key rotation. */\nfunction parseHeader(header: string): ParsedHeader | null {\n let timestamp = Number.NaN;\n const signatures: string[] = [];\n for (const part of header.split(\",\")) {\n const eq = part.indexOf(\"=\");\n if (eq === -1) continue;\n const key = part.slice(0, eq).trim();\n const value = part.slice(eq + 1).trim();\n if (key === \"t\") timestamp = Number(value);\n else if (key === \"v1\") signatures.push(value);\n }\n if (!Number.isFinite(timestamp) || signatures.length === 0) return null;\n return { timestamp, signatures };\n}\n\nasync function hmacHex(secret: string, payload: string): Promise<string> {\n const key = await crypto.subtle.importKey(\n \"raw\",\n encoder.encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n const sig = await crypto.subtle.sign(\"HMAC\", key, encoder.encode(payload));\n return Array.from(new Uint8Array(sig), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\n/** Constant-time compare of two equal-length hex strings (no early exit). */\nfunction timingSafeEqual(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n let mismatch = 0;\n for (let i = 0; i < a.length; i++) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);\n return mismatch === 0;\n}\n\n/**\n * Return `true` if `header` is a valid signature for `payload` under `secret` and\n * within the freshness window. Never throws — use {@link constructEvent} when you\n * want the parsed event and a thrown error on failure. Pass the RAW request body.\n */\nexport async function verifySignature(\n payload: string,\n header: string | null | undefined,\n secret: string,\n opts: VerifyOptions = {},\n): Promise<boolean> {\n if (!header) return false;\n const parsed = parseHeader(header);\n if (!parsed) return false;\n\n const tolerance = opts.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;\n const now = Math.floor(Date.now() / 1000);\n if (Math.abs(now - parsed.timestamp) > tolerance) return false;\n\n const expected = await hmacHex(secret, `${parsed.timestamp}.${payload}`);\n return parsed.signatures.some((sig) => timingSafeEqual(sig, expected));\n}\n\n/**\n * Produce a valid `billow-signature` header for `payload` under `secret` - the inverse of\n * {@link verifySignature}. Use it to exercise your webhook endpoint in tests without\n * reverse-engineering the signing scheme. Pass the RAW body you will POST. `timestamp` (unix\n * seconds) defaults to now; pass a fixed value for a deterministic - or intentionally stale\n * (replay) - signature.\n *\n * const header = await signWebhook(rawBody, endpointSecret);\n * await fetch(url, { method: \"POST\", headers: { \"billow-signature\": header }, body: rawBody });\n */\nexport async function signWebhook(\n payload: string,\n secret: string,\n opts: { timestamp?: number } = {},\n): Promise<string> {\n const timestamp = opts.timestamp ?? Math.floor(Date.now() / 1000);\n const signature = await hmacHex(secret, `${timestamp}.${payload}`);\n return `t=${timestamp},v1=${signature}`;\n}\n\n/**\n * Verify `payload` against the `billow-signature` header and return the parsed,\n * typed {@link WebhookEvent}. Throws {@link WebhookVerificationError} if the\n * signature is invalid, the timestamp is outside the freshness window, or the body\n * isn't a billow envelope. Always pass the RAW request body — a re-serialized\n * parsed body will not match.\n */\nexport async function constructEvent(\n payload: string,\n header: string | null | undefined,\n secret: string,\n opts: VerifyOptions = {},\n): Promise<WebhookEvent> {\n if (!(await verifySignature(payload, header, secret, opts))) {\n throw new WebhookVerificationError(\"billow webhook signature verification failed\");\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(payload);\n } catch {\n throw new WebhookVerificationError(\"billow webhook payload is not valid JSON\");\n }\n if (!isWebhookEvent(parsed)) {\n throw new WebhookVerificationError(\"billow webhook payload is not a valid event envelope\");\n }\n return parsed;\n}\n\n/** A handler for one verified webhook event. May be async; a throw propagates to the caller. */\nexport type WebhookHandler = (event: WebhookEvent) => void | Promise<void>;\n\n/**\n * A map of per-event-type handlers plus an optional `onEvent` fallback. Each handler receives\n * the event NARROWED to its type, so `data` is the exact payload for that event\n * (e.g. `\"invoice.paid\": (e) => settle(e.data.invoiceId)`). Keyed by the literal event type so\n * it stays in lock-step with the contract — a new event type is dispatchable with no parallel\n * `onFooBar` name registry to keep in sync.\n */\nexport type WebhookHandlers = {\n [K in BillowEventType]?: (event: Extract<WebhookEvent, { type: K }>) => void | Promise<void>;\n} & {\n /** Called for any event that has no specific handler (receives the full union). */\n onEvent?: WebhookHandler;\n};\n\n/**\n * Build a reusable dispatcher bound to your endpoint secret + handlers: it verifies a\n * delivery (via {@link constructEvent}) then routes the typed event to the matching\n * handler, falling back to `onEvent`. Returns the verified event so the caller can log\n * or ack it. Throws {@link WebhookVerificationError} on an untrusted delivery (return a\n * 4xx) and propagates any error a handler throws (return a 5xx so billow retries).\n * Pass the RAW request body — a re-serialized parsed body won't match the signature.\n *\n * const dispatch = createWebhookDispatcher({\n * secret: process.env.BILLOW_WEBHOOK_SECRET,\n * handlers: {\n * \"invoice.paid\": (e) => fulfil(e.data.invoiceId),\n * \"subscription.canceled\": (e) => revoke(e.data),\n * onEvent: (e) => log(e.type),\n * },\n * });\n * // per request:\n * const event = await dispatch(rawBody, req.headers[\"billow-signature\"]);\n */\nexport function createWebhookDispatcher(config: {\n secret: string;\n handlers: WebhookHandlers;\n toleranceSeconds?: number;\n}): (payload: string, signatureHeader: string | null | undefined) => Promise<WebhookEvent> {\n const { secret, handlers, toleranceSeconds } = config;\n const opts = toleranceSeconds === undefined ? undefined : { toleranceSeconds };\n return async (payload, signatureHeader) => {\n const event = await constructEvent(payload, signatureHeader, secret, opts);\n // Own-property lookup, NOT `handlers[event.type]` directly: `event.type` is only\n // runtime-typed as a string, so a value colliding with an inherited key\n // (\"toString\"/\"constructor\"/\"__proto__\") would otherwise resolve to a prototype\n // method and be invoked instead of routing to `onEvent`. (Not reachable via a\n // trusted delivery — the type is HMAC-verified above and billow only emits fixed\n // event types — but the fallback contract must hold for any signature-valid type.)\n // The looked-up handler expects only its own narrowed variant; cast it to accept the full\n // union so we can invoke it with `event` — safe because the handler registered under key\n // `event.type` only ever receives the matching variant at runtime.\n const own = Object.hasOwn(handlers, event.type)\n ? (handlers[event.type] as WebhookHandler | undefined)\n : undefined;\n const handler = own ?? handlers.onEvent;\n if (handler) await handler(event);\n return event;\n };\n}\n\nfunction isWebhookEvent(value: unknown): value is WebhookEvent {\n if (typeof value !== \"object\" || value === null) return false;\n const e = value as {\n id?: unknown;\n version?: unknown;\n type?: unknown;\n createdAt?: unknown;\n data?: unknown;\n };\n return (\n typeof e.id === \"string\" &&\n typeof e.version === \"number\" &&\n typeof e.type === \"string\" &&\n typeof e.createdAt === \"string\" &&\n typeof e.data === \"object\" &&\n e.data !== null\n );\n}\n"]}
|
package/dist/webhooks.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { C as CreditGrantKind, a as CreditThresholdLevel, H as HostedDomainActivatedData, b as HostedDomainDnsFailingData, c as HostedDomainReassignmentPendingData, d as HostedDomainDisabledData } from './hosted-domains-
|
|
2
|
-
import './index.d-
|
|
1
|
+
import { C as CreditGrantKind, a as CreditThresholdLevel, H as HostedDomainActivatedData, b as HostedDomainDnsFailingData, c as HostedDomainReassignmentPendingData, d as HostedDomainDisabledData } from './hosted-domains-DwakPG4J.cjs';
|
|
2
|
+
import './index.d-DSEYhV2c.cjs';
|
|
3
3
|
import 'zod';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -213,9 +213,15 @@ interface CreditGrantCreatedData extends CreditEventBase {
|
|
|
213
213
|
grantId: string;
|
|
214
214
|
kind: CreditGrantKind;
|
|
215
215
|
amount: string;
|
|
216
|
+
/** For an `included` grant, its window's end. */
|
|
216
217
|
expiresAt: string | null;
|
|
217
218
|
eligibility: string[] | null;
|
|
218
219
|
topUpId: string | null;
|
|
220
|
+
/** An `included` grant's subscription; null for every other kind. */
|
|
221
|
+
subscriptionId: string | null;
|
|
222
|
+
/** The billing period an `included` grant's window belongs to; null for every other kind. */
|
|
223
|
+
periodStart: string | null;
|
|
224
|
+
periodEnd: string | null;
|
|
219
225
|
}
|
|
220
226
|
/**
|
|
221
227
|
* `credit_grant.expired` - one expiry took `expired` credits off a grant: its unheld credits when
|
|
@@ -226,6 +232,8 @@ interface CreditGrantExpiredData extends CreditEventBase {
|
|
|
226
232
|
kind: CreditGrantKind;
|
|
227
233
|
expiresAt: string | null;
|
|
228
234
|
expired: string;
|
|
235
|
+
/** Why it expired: `grant_expired` - the grant reached its expiry. */
|
|
236
|
+
reason: "grant_expired";
|
|
229
237
|
/** The `expire` ledger transaction. */
|
|
230
238
|
transactionId: string;
|
|
231
239
|
}
|
package/dist/webhooks.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { C as CreditGrantKind, a as CreditThresholdLevel, H as HostedDomainActivatedData, b as HostedDomainDnsFailingData, c as HostedDomainReassignmentPendingData, d as HostedDomainDisabledData } from './hosted-domains-
|
|
2
|
-
import './index.d-
|
|
1
|
+
import { C as CreditGrantKind, a as CreditThresholdLevel, H as HostedDomainActivatedData, b as HostedDomainDnsFailingData, c as HostedDomainReassignmentPendingData, d as HostedDomainDisabledData } from './hosted-domains-DSTQ1GZz.js';
|
|
2
|
+
import './index.d-DSEYhV2c.js';
|
|
3
3
|
import 'zod';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -213,9 +213,15 @@ interface CreditGrantCreatedData extends CreditEventBase {
|
|
|
213
213
|
grantId: string;
|
|
214
214
|
kind: CreditGrantKind;
|
|
215
215
|
amount: string;
|
|
216
|
+
/** For an `included` grant, its window's end. */
|
|
216
217
|
expiresAt: string | null;
|
|
217
218
|
eligibility: string[] | null;
|
|
218
219
|
topUpId: string | null;
|
|
220
|
+
/** An `included` grant's subscription; null for every other kind. */
|
|
221
|
+
subscriptionId: string | null;
|
|
222
|
+
/** The billing period an `included` grant's window belongs to; null for every other kind. */
|
|
223
|
+
periodStart: string | null;
|
|
224
|
+
periodEnd: string | null;
|
|
219
225
|
}
|
|
220
226
|
/**
|
|
221
227
|
* `credit_grant.expired` - one expiry took `expired` credits off a grant: its unheld credits when
|
|
@@ -226,6 +232,8 @@ interface CreditGrantExpiredData extends CreditEventBase {
|
|
|
226
232
|
kind: CreditGrantKind;
|
|
227
233
|
expiresAt: string | null;
|
|
228
234
|
expired: string;
|
|
235
|
+
/** Why it expired: `grant_expired` - the grant reached its expiry. */
|
|
236
|
+
reason: "grant_expired";
|
|
229
237
|
/** The `expire` ledger transaction. */
|
|
230
238
|
transactionId: string;
|
|
231
239
|
}
|
package/dist/webhooks.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/webhook-event-types.ts","../src/webhooks.ts"],"names":[],"mappings":"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","file":"webhooks.js","sourcesContent":["/**\n * The webhook event catalog: every event type billow emits, and its families as types and runtime\n * arrays (for subscription pickers and exhaustive routing). Re-exported by ./webhooks.ts.\n */\n\n/**\n * Every event type billow emits, in contract order. Mirrors `@billow/core`'s\n * EVENT_TYPES - exported as a runtime array so UIs can render a subscription picker.\n */\nexport const WEBHOOK_EVENT_TYPES = [\n \"charge.succeeded\",\n \"charge.failed\",\n \"charge.refunded\",\n \"charge.disputed\",\n \"charge.dispute_resolved\",\n \"invoice.paid\",\n \"invoice.payment_failed\",\n \"subscription.trial_started\",\n \"subscription.activated\",\n \"subscription.activation_failed\",\n \"subscription.renewed\",\n \"subscription.past_due\",\n \"subscription.payment_failed\",\n \"subscription.requires_payment_method\",\n \"subscription.unpaid\",\n \"subscription.paused\",\n \"subscription.resumed\",\n \"subscription.updated\",\n \"subscription.cancel_scheduled\",\n \"subscription.cancel_resumed\",\n \"subscription.downgrade_scheduled\",\n \"subscription.downgrade_canceled\",\n \"subscription.canceled\",\n \"subscription.ended\",\n \"entitlement.granted\",\n \"entitlement.reset\",\n \"usage.threshold_reached\",\n \"usage.alert\",\n \"usage.cap_reached\",\n \"credit_balance.threshold_crossed\",\n \"credit_grant.created\",\n \"credit_grant.expired\",\n \"credit_reservation.expired\",\n \"credit_top_up.succeeded\",\n \"credit_top_up.failed\",\n \"credit_top_up.refunded\",\n \"credit_top_up.unfulfilled\",\n \"hosted_domain.activated\",\n \"hosted_domain.dns_failing\",\n \"hosted_domain.reassignment_pending\",\n \"hosted_domain.disabled\",\n] as const;\n\n/** A valid billow event type (the compile-time freeze derived from the array above). */\nexport type BillowEventType = (typeof WEBHOOK_EVENT_TYPES)[number];\n\n/** The `charge.*` subset of {@link BillowEventType}. */\nexport type ChargeEventType = Extract<BillowEventType, `charge.${string}`>;\n/** The `invoice.*` subset of {@link BillowEventType}. */\nexport type InvoiceEventType = Extract<BillowEventType, `invoice.${string}`>;\n/** The `subscription.*` subset of {@link BillowEventType} - the events that change a subscription. */\nexport type SubscriptionEventType = Extract<BillowEventType, `subscription.${string}`>;\n/** The `entitlement.*` subset of {@link BillowEventType}. */\nexport type EntitlementEventType = Extract<BillowEventType, `entitlement.${string}`>;\n/** The `usage.*` subset of {@link BillowEventType}. */\nexport type UsageEventType = Extract<BillowEventType, `usage.${string}`>;\n/** The prepaid credits subset of {@link BillowEventType} (`credit_balance.*`, `credit_grant.*`, ...). */\nexport type CreditEventType = Extract<BillowEventType, `credit_${string}`>;\n/** The `hosted_domain.*` subset of {@link BillowEventType}: custom portal domains. */\nexport type HostedDomainEventType = Extract<BillowEventType, `hosted_domain.${string}`>;\n\n/** The subset of {@link WEBHOOK_EVENT_TYPES} sharing `prefix`, as a runtime array (order preserved). */\nfunction eventTypesWithPrefix<P extends string>(\n prefix: P,\n): readonly Extract<BillowEventType, `${P}${string}`>[] {\n return WEBHOOK_EVENT_TYPES.filter((type): type is Extract<BillowEventType, `${P}${string}`> =>\n type.startsWith(prefix),\n );\n}\n\n/** The `charge.*` event types, as a runtime array (for pickers / exhaustive routing). */\nexport const CHARGE_EVENT_TYPES: readonly ChargeEventType[] = eventTypesWithPrefix(\"charge.\");\n/** The `invoice.*` event types, as a runtime array. */\nexport const INVOICE_EVENT_TYPES: readonly InvoiceEventType[] = eventTypesWithPrefix(\"invoice.\");\n/**\n * The subscription lifecycle event types - the `subscription.*` subset of\n * {@link WEBHOOK_EVENT_TYPES}, as a runtime array. Use it (or {@link isSubscriptionEvent})\n * to select the events that affect a subscription mirror without a brittle string match.\n */\nexport const SUBSCRIPTION_EVENT_TYPES: readonly SubscriptionEventType[] =\n eventTypesWithPrefix(\"subscription.\");\n/** The `entitlement.*` event types, as a runtime array. */\nexport const ENTITLEMENT_EVENT_TYPES: readonly EntitlementEventType[] =\n eventTypesWithPrefix(\"entitlement.\");\n/** The `usage.*` event types, as a runtime array. */\nexport const USAGE_EVENT_TYPES: readonly UsageEventType[] = eventTypesWithPrefix(\"usage.\");\n/** The prepaid credits event types, as a runtime array. */\nexport const CREDIT_EVENT_TYPES: readonly CreditEventType[] = eventTypesWithPrefix(\"credit_\");\n/** The `hosted_domain.*` event types, as a runtime array. */\nexport const HOSTED_DOMAIN_EVENT_TYPES: readonly HostedDomainEventType[] =\n eventTypesWithPrefix(\"hosted_domain.\");\n\n/** Whether `type` is a subscription lifecycle event (`subscription.*`); narrows the type. */\nexport function isSubscriptionEvent(type: string): type is SubscriptionEventType {\n return type.startsWith(\"subscription.\");\n}\n","/**\n * Verify and parse billow webhook deliveries.\n *\n * billow signs each delivery with HMAC-SHA256 over `\"<timestamp>.<rawBody>\"` and\n * sends it in the `billow-signature` header as `t=<unix-seconds>,v1=<hex>`. The\n * timestamp lets you reject replays outside a freshness window.\n *\n * Verification is async and uses the Web Crypto API (`globalThis.crypto.subtle`),\n * so the same code runs in Node 20+, edge runtimes, Cloudflare Workers, Deno, and\n * Bun — it never imports `node:crypto`.\n *\n * import { constructEvent } from \"@usebillow/sdk/webhooks\";\n *\n * const event = await constructEvent(rawBody, req.headers[\"billow-signature\"], secret);\n * switch (event.type) {\n * case \"invoice.paid\": // …\n * }\n */\n\nimport type { CreditGrantKind, CreditThresholdLevel } from \"./types/credits\";\nimport type {\n HostedDomainActivatedData,\n HostedDomainDisabledData,\n HostedDomainDnsFailingData,\n HostedDomainReassignmentPendingData,\n} from \"./types/hosted-domains\";\nimport type {\n BillowEventType,\n EntitlementEventType,\n InvoiceEventType,\n SubscriptionEventType,\n} from \"./webhook-event-types\";\n\nexport * from \"./webhook-event-types\";\n\n/**\n * The fields every webhook envelope carries. Each {@link WebhookEvent} variant intersects\n * this with its own `type` + payload-typed `data`.\n */\nexport interface WebhookEventEnvelope {\n /** Stable event id — your idempotency key (delivery is at-least-once). */\n id: string;\n /** Envelope schema version. */\n version: number;\n /** ISO-8601 timestamp of when the event was recorded. */\n createdAt: string;\n}\n\n// --- Per-event-group payloads -------------------------------------------------------------\n// Shapes mirror what the server emits (packages/server/src/services: charges.ts, billing.ts,\n// renewal/*, subscriptions/*, entitlements/*). Grouped where a group's emitters are uniform;\n// `usage.*` is split per event because its emitters diverge. `customerExternalId` is added to\n// every customer-scoped payload by the server's outbox emitter (events.ts).\n\n/** `charge.succeeded` / `charge.failed`. */\nexport interface ChargeEventData {\n chargeId: string;\n /**\n * `false` on a `charge.failed` that closed a checkout its customer abandoned long before: no\n * \"payment failed\" email was sent to the customer. Absent otherwise.\n */\n notifyCustomer?: false;\n}\n/** `charge.refunded`, emitted once for each succeeded refund transition. */\nexport interface ChargeRefundedEventData {\n chargeId: string;\n refundId: string;\n amount: number;\n cumulativeAmount: number;\n currency: string;\n}\n/** Stable dispute facts shared by the opened and resolved events. */\nexport interface ChargeDisputeEventData {\n chargeId: string;\n disputeId: string;\n amount: number;\n currency: string;\n reason: string | null;\n state: \"open\" | \"won\" | \"lost\";\n}\n/** `invoice.paid` / `invoice.payment_failed`. */\nexport interface InvoiceEventData {\n invoiceId: string;\n chargeId: string;\n /**\n * `false` on an `invoice.payment_failed` that closed a checkout its customer abandoned long\n * before: no \"payment failed\" email was sent to the customer. Absent otherwise.\n */\n notifyCustomer?: false;\n}\n/** Any `subscription.*` lifecycle event. */\nexport interface SubscriptionEventData {\n subscriptionId: string;\n customerId: string;\n customerExternalId: string;\n /** The subscription's plan — so a consumer knows which product the event is about. */\n productId: string;\n productSlug: string;\n /** The product's metadata map (Stripe-style); `{}` when the plan carries none. */\n productMetadata: Record<string, string>;\n /** Set on dunning events that reference the unpaid invoice (`unpaid`, `requires_payment_method`). */\n invoiceId?: string;\n /** Set on lifecycle-ending events (`ended`, downgrade-driven `updated`). */\n reason?: string;\n /** Present when a downgrade is scheduled; null when that schedule is cleared. */\n scheduledChange?: {\n productVersionId: string;\n productId: string;\n productSlug: string;\n productName: string;\n effectiveAt: string | null;\n } | null;\n}\n/** `entitlement.granted` / `entitlement.reset`. */\nexport interface EntitlementEventData {\n subscriptionId: string;\n customerId: string;\n customerExternalId: string;\n featureId: string;\n /** Remaining allowance; `null` when the feature is unlimited. */\n balance: number | null;\n /** Whether the feature grants unlimited usage. */\n unlimited: boolean;\n}\n/** Fields shared by every `usage.*` event. */\nexport interface UsageEventBase {\n subscriptionId: string;\n customerId: string;\n customerExternalId: string;\n /** Feature slug. */\n feature: string;\n featureName: string;\n /** Aggregate usage after this step. */\n used: number;\n /** Included allowance; `null` when unlimited. */\n allowance: number | null;\n /** Remaining allowance; `null` when unlimited. */\n balance: number | null;\n}\n/** `usage.threshold_reached` — a built-in usage threshold was crossed. */\nexport interface UsageThresholdReachedData extends UsageEventBase {\n featureId: string;\n /** The threshold crossed (currently only the included allowance being exhausted). */\n level: \"exhausted\";\n}\n/** `usage.alert` — a configured usage alert fired. */\nexport interface UsageAlertData extends UsageEventBase {\n metric: string;\n threshold: number;\n recurring: boolean;\n notifyCustomer: boolean;\n}\n/** `usage.cap_reached` — a configured spend/usage cap was reached. */\nexport interface UsageCapReachedData extends UsageEventBase {\n metric: string;\n cap: number;\n capAt: number;\n}\n\n// Prepaid credits: every credit quantity is a decimal string of microcredits, never a number.\n// Payloads carry ids, codes and quantities, never a grant's, reservation's or top-up's reason or\n// metadata.\n\n/** Fields shared by every prepaid credits event. */\nexport interface CreditEventBase {\n customerId: string;\n customerExternalId: string;\n}\n/**\n * `credit_balance.threshold_crossed` - the balance moved to another level, in either direction\n * (raise a low-balance banner, or clear it). `exhausted` when nothing is spendable; otherwise\n * `critical` / `low` at or below the percentages of `thresholds.reference`, else `normal`.\n */\nexport interface CreditThresholdCrossedData extends CreditEventBase {\n level: CreditThresholdLevel;\n previousLevel: CreditThresholdLevel;\n /** The balance the level was judged on, as of `asOf`; re-read the balance for truth. */\n balance: { spendable: string; held: string; asOf: string };\n thresholds: { lowPercent: number; criticalPercent: number; reference: string | null };\n}\n/** `credit_grant.created` - credits were granted. */\nexport interface CreditGrantCreatedData extends CreditEventBase {\n grantId: string;\n kind: CreditGrantKind;\n amount: string;\n expiresAt: string | null;\n eligibility: string[] | null;\n topUpId: string | null;\n}\n/**\n * `credit_grant.expired` - one expiry took `expired` credits off a grant: its unheld credits when\n * it expired, or a held part that came back to it afterwards (one more event each).\n */\nexport interface CreditGrantExpiredData extends CreditEventBase {\n grantId: string;\n kind: CreditGrantKind;\n expiresAt: string | null;\n expired: string;\n /** The `expire` ledger transaction. */\n transactionId: string;\n}\n/** `credit_reservation.expired` - a hold passed its expiry unsettled and was released. */\nexport interface CreditReservationExpiredData extends CreditEventBase {\n reservationId: string;\n operationKey: string;\n category: string;\n amount: string;\n expiresAt: string;\n}\n/** What every `credit_top_up.*` event says about the purchase and its payment. */\nexport interface CreditTopUpEventBase extends CreditEventBase {\n topUpId: string;\n packId: string;\n chargeId: string;\n /** What the payment collects, in minor units of `currency`. */\n amount: number;\n currency: string;\n /** The terms bought. */\n credits: string;\n bonusCredits: string;\n}\n/** `credit_top_up.succeeded` - the payment succeeded and its credits (and any bonus) were granted. */\nexport interface CreditTopUpSucceededData extends CreditTopUpEventBase {\n purchasedGrantId: string | null;\n bonusGrantId: string | null;\n}\n/**\n * `credit_top_up.failed` - the payment failed. A later payment on the same checkout can still\n * succeed it: `credit_top_up.succeeded` may follow for the same top-up.\n */\nexport type CreditTopUpFailedData = CreditTopUpEventBase;\n/** `credit_top_up.unfulfilled` - paid after the customer was erased: nothing granted, refund it. */\nexport type CreditTopUpUnfulfilledData = CreditTopUpEventBase;\n/**\n * `credit_top_up.refunded` - a refund or lost chargeback revoked the top-up's credits in\n * proportion to the money returned.\n */\nexport interface CreditTopUpRefundedData extends CreditTopUpEventBase {\n cause: \"refund\" | \"chargeback\";\n /** Money returned so far (refunds plus lost disputes, at most what was charged), minor units. */\n amountReturned: number;\n /** Microcredits this refund or chargeback revoked. */\n revokedNow: string;\n /** Microcredits revoked so far in all. */\n revoked: string;\n /** Of them, what the customer had already spent and nothing has paid back. */\n shortfall: string;\n}\n\n/**\n * The verified webhook envelope billow POSTs to your endpoint — a discriminated union on\n * {@link WebhookEvent.type}. `switch` (or a {@link WebhookHandlers} map) on `type` and `data`\n * narrows to the exact payload for that event.\n *\n * switch (event.type) {\n * case \"invoice.paid\": settle(event.data.invoiceId); break; // InvoiceEventData\n * case \"subscription.canceled\": revoke(event.data.customerExternalId); break;\n * }\n */\nexport type WebhookEventDataMap = {\n [K in \"charge.succeeded\" | \"charge.failed\"]: ChargeEventData;\n} & {\n \"charge.refunded\": ChargeRefundedEventData;\n} & {\n [K in \"charge.disputed\" | \"charge.dispute_resolved\"]: ChargeDisputeEventData;\n} & {\n [K in InvoiceEventType]: InvoiceEventData;\n} & {\n [K in SubscriptionEventType]: SubscriptionEventData;\n} & {\n [K in EntitlementEventType]: EntitlementEventData;\n} & {\n \"usage.threshold_reached\": UsageThresholdReachedData;\n \"usage.alert\": UsageAlertData;\n \"usage.cap_reached\": UsageCapReachedData;\n} & {\n \"credit_balance.threshold_crossed\": CreditThresholdCrossedData;\n \"credit_grant.created\": CreditGrantCreatedData;\n \"credit_grant.expired\": CreditGrantExpiredData;\n \"credit_reservation.expired\": CreditReservationExpiredData;\n \"credit_top_up.succeeded\": CreditTopUpSucceededData;\n \"credit_top_up.failed\": CreditTopUpFailedData;\n \"credit_top_up.refunded\": CreditTopUpRefundedData;\n \"credit_top_up.unfulfilled\": CreditTopUpUnfulfilledData;\n} & {\n \"hosted_domain.activated\": HostedDomainActivatedData;\n \"hosted_domain.dns_failing\": HostedDomainDnsFailingData;\n \"hosted_domain.reassignment_pending\": HostedDomainReassignmentPendingData;\n \"hosted_domain.disabled\": HostedDomainDisabledData;\n};\n\n/** One distributive union member per event literal, derived from the payload map. */\nexport type WebhookEvent = {\n [K in BillowEventType]: WebhookEventEnvelope & { type: K; data: WebhookEventDataMap[K] };\n}[BillowEventType];\n\nexport interface VerifyOptions {\n /**\n * Max age (seconds) of a delivery's signature timestamp before it's rejected as\n * a possible replay. Default 300 (5 minutes).\n */\n toleranceSeconds?: number;\n}\n\n/** Thrown by {@link constructEvent} when a delivery can't be trusted. */\nexport class WebhookVerificationError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"WebhookVerificationError\";\n }\n}\n\nconst DEFAULT_TOLERANCE_SECONDS = 300;\nconst encoder = new TextEncoder();\n\ninterface ParsedHeader {\n timestamp: number;\n signatures: string[];\n}\n\n/** Parse `t=<unix>,v1=<hex>[,v1=<hex>…]` — multiple v1 supports key rotation. */\nfunction parseHeader(header: string): ParsedHeader | null {\n let timestamp = Number.NaN;\n const signatures: string[] = [];\n for (const part of header.split(\",\")) {\n const eq = part.indexOf(\"=\");\n if (eq === -1) continue;\n const key = part.slice(0, eq).trim();\n const value = part.slice(eq + 1).trim();\n if (key === \"t\") timestamp = Number(value);\n else if (key === \"v1\") signatures.push(value);\n }\n if (!Number.isFinite(timestamp) || signatures.length === 0) return null;\n return { timestamp, signatures };\n}\n\nasync function hmacHex(secret: string, payload: string): Promise<string> {\n const key = await crypto.subtle.importKey(\n \"raw\",\n encoder.encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n const sig = await crypto.subtle.sign(\"HMAC\", key, encoder.encode(payload));\n return Array.from(new Uint8Array(sig), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\n/** Constant-time compare of two equal-length hex strings (no early exit). */\nfunction timingSafeEqual(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n let mismatch = 0;\n for (let i = 0; i < a.length; i++) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);\n return mismatch === 0;\n}\n\n/**\n * Return `true` if `header` is a valid signature for `payload` under `secret` and\n * within the freshness window. Never throws — use {@link constructEvent} when you\n * want the parsed event and a thrown error on failure. Pass the RAW request body.\n */\nexport async function verifySignature(\n payload: string,\n header: string | null | undefined,\n secret: string,\n opts: VerifyOptions = {},\n): Promise<boolean> {\n if (!header) return false;\n const parsed = parseHeader(header);\n if (!parsed) return false;\n\n const tolerance = opts.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;\n const now = Math.floor(Date.now() / 1000);\n if (Math.abs(now - parsed.timestamp) > tolerance) return false;\n\n const expected = await hmacHex(secret, `${parsed.timestamp}.${payload}`);\n return parsed.signatures.some((sig) => timingSafeEqual(sig, expected));\n}\n\n/**\n * Produce a valid `billow-signature` header for `payload` under `secret` - the inverse of\n * {@link verifySignature}. Use it to exercise your webhook endpoint in tests without\n * reverse-engineering the signing scheme. Pass the RAW body you will POST. `timestamp` (unix\n * seconds) defaults to now; pass a fixed value for a deterministic - or intentionally stale\n * (replay) - signature.\n *\n * const header = await signWebhook(rawBody, endpointSecret);\n * await fetch(url, { method: \"POST\", headers: { \"billow-signature\": header }, body: rawBody });\n */\nexport async function signWebhook(\n payload: string,\n secret: string,\n opts: { timestamp?: number } = {},\n): Promise<string> {\n const timestamp = opts.timestamp ?? Math.floor(Date.now() / 1000);\n const signature = await hmacHex(secret, `${timestamp}.${payload}`);\n return `t=${timestamp},v1=${signature}`;\n}\n\n/**\n * Verify `payload` against the `billow-signature` header and return the parsed,\n * typed {@link WebhookEvent}. Throws {@link WebhookVerificationError} if the\n * signature is invalid, the timestamp is outside the freshness window, or the body\n * isn't a billow envelope. Always pass the RAW request body — a re-serialized\n * parsed body will not match.\n */\nexport async function constructEvent(\n payload: string,\n header: string | null | undefined,\n secret: string,\n opts: VerifyOptions = {},\n): Promise<WebhookEvent> {\n if (!(await verifySignature(payload, header, secret, opts))) {\n throw new WebhookVerificationError(\"billow webhook signature verification failed\");\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(payload);\n } catch {\n throw new WebhookVerificationError(\"billow webhook payload is not valid JSON\");\n }\n if (!isWebhookEvent(parsed)) {\n throw new WebhookVerificationError(\"billow webhook payload is not a valid event envelope\");\n }\n return parsed;\n}\n\n/** A handler for one verified webhook event. May be async; a throw propagates to the caller. */\nexport type WebhookHandler = (event: WebhookEvent) => void | Promise<void>;\n\n/**\n * A map of per-event-type handlers plus an optional `onEvent` fallback. Each handler receives\n * the event NARROWED to its type, so `data` is the exact payload for that event\n * (e.g. `\"invoice.paid\": (e) => settle(e.data.invoiceId)`). Keyed by the literal event type so\n * it stays in lock-step with the contract — a new event type is dispatchable with no parallel\n * `onFooBar` name registry to keep in sync.\n */\nexport type WebhookHandlers = {\n [K in BillowEventType]?: (event: Extract<WebhookEvent, { type: K }>) => void | Promise<void>;\n} & {\n /** Called for any event that has no specific handler (receives the full union). */\n onEvent?: WebhookHandler;\n};\n\n/**\n * Build a reusable dispatcher bound to your endpoint secret + handlers: it verifies a\n * delivery (via {@link constructEvent}) then routes the typed event to the matching\n * handler, falling back to `onEvent`. Returns the verified event so the caller can log\n * or ack it. Throws {@link WebhookVerificationError} on an untrusted delivery (return a\n * 4xx) and propagates any error a handler throws (return a 5xx so billow retries).\n * Pass the RAW request body — a re-serialized parsed body won't match the signature.\n *\n * const dispatch = createWebhookDispatcher({\n * secret: process.env.BILLOW_WEBHOOK_SECRET,\n * handlers: {\n * \"invoice.paid\": (e) => fulfil(e.data.invoiceId),\n * \"subscription.canceled\": (e) => revoke(e.data),\n * onEvent: (e) => log(e.type),\n * },\n * });\n * // per request:\n * const event = await dispatch(rawBody, req.headers[\"billow-signature\"]);\n */\nexport function createWebhookDispatcher(config: {\n secret: string;\n handlers: WebhookHandlers;\n toleranceSeconds?: number;\n}): (payload: string, signatureHeader: string | null | undefined) => Promise<WebhookEvent> {\n const { secret, handlers, toleranceSeconds } = config;\n const opts = toleranceSeconds === undefined ? undefined : { toleranceSeconds };\n return async (payload, signatureHeader) => {\n const event = await constructEvent(payload, signatureHeader, secret, opts);\n // Own-property lookup, NOT `handlers[event.type]` directly: `event.type` is only\n // runtime-typed as a string, so a value colliding with an inherited key\n // (\"toString\"/\"constructor\"/\"__proto__\") would otherwise resolve to a prototype\n // method and be invoked instead of routing to `onEvent`. (Not reachable via a\n // trusted delivery — the type is HMAC-verified above and billow only emits fixed\n // event types — but the fallback contract must hold for any signature-valid type.)\n // The looked-up handler expects only its own narrowed variant; cast it to accept the full\n // union so we can invoke it with `event` — safe because the handler registered under key\n // `event.type` only ever receives the matching variant at runtime.\n const own = Object.hasOwn(handlers, event.type)\n ? (handlers[event.type] as WebhookHandler | undefined)\n : undefined;\n const handler = own ?? handlers.onEvent;\n if (handler) await handler(event);\n return event;\n };\n}\n\nfunction isWebhookEvent(value: unknown): value is WebhookEvent {\n if (typeof value !== \"object\" || value === null) return false;\n const e = value as {\n id?: unknown;\n version?: unknown;\n type?: unknown;\n createdAt?: unknown;\n data?: unknown;\n };\n return (\n typeof e.id === \"string\" &&\n typeof e.version === \"number\" &&\n typeof e.type === \"string\" &&\n typeof e.createdAt === \"string\" &&\n typeof e.data === \"object\" &&\n e.data !== null\n );\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/webhook-event-types.ts","../src/webhooks.ts"],"names":[],"mappings":"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","file":"webhooks.js","sourcesContent":["/**\n * The webhook event catalog: every event type billow emits, and its families as types and runtime\n * arrays (for subscription pickers and exhaustive routing). Re-exported by ./webhooks.ts.\n */\n\n/**\n * Every event type billow emits, in contract order. Mirrors `@billow/core`'s\n * EVENT_TYPES - exported as a runtime array so UIs can render a subscription picker.\n */\nexport const WEBHOOK_EVENT_TYPES = [\n \"charge.succeeded\",\n \"charge.failed\",\n \"charge.refunded\",\n \"charge.disputed\",\n \"charge.dispute_resolved\",\n \"invoice.paid\",\n \"invoice.payment_failed\",\n \"subscription.trial_started\",\n \"subscription.activated\",\n \"subscription.activation_failed\",\n \"subscription.renewed\",\n \"subscription.past_due\",\n \"subscription.payment_failed\",\n \"subscription.requires_payment_method\",\n \"subscription.unpaid\",\n \"subscription.paused\",\n \"subscription.resumed\",\n \"subscription.updated\",\n \"subscription.cancel_scheduled\",\n \"subscription.cancel_resumed\",\n \"subscription.downgrade_scheduled\",\n \"subscription.downgrade_canceled\",\n \"subscription.canceled\",\n \"subscription.ended\",\n \"entitlement.granted\",\n \"entitlement.reset\",\n \"usage.threshold_reached\",\n \"usage.alert\",\n \"usage.cap_reached\",\n \"credit_balance.threshold_crossed\",\n \"credit_grant.created\",\n \"credit_grant.expired\",\n \"credit_reservation.expired\",\n \"credit_top_up.succeeded\",\n \"credit_top_up.failed\",\n \"credit_top_up.refunded\",\n \"credit_top_up.unfulfilled\",\n \"hosted_domain.activated\",\n \"hosted_domain.dns_failing\",\n \"hosted_domain.reassignment_pending\",\n \"hosted_domain.disabled\",\n] as const;\n\n/** A valid billow event type (the compile-time freeze derived from the array above). */\nexport type BillowEventType = (typeof WEBHOOK_EVENT_TYPES)[number];\n\n/** The `charge.*` subset of {@link BillowEventType}. */\nexport type ChargeEventType = Extract<BillowEventType, `charge.${string}`>;\n/** The `invoice.*` subset of {@link BillowEventType}. */\nexport type InvoiceEventType = Extract<BillowEventType, `invoice.${string}`>;\n/** The `subscription.*` subset of {@link BillowEventType} - the events that change a subscription. */\nexport type SubscriptionEventType = Extract<BillowEventType, `subscription.${string}`>;\n/** The `entitlement.*` subset of {@link BillowEventType}. */\nexport type EntitlementEventType = Extract<BillowEventType, `entitlement.${string}`>;\n/** The `usage.*` subset of {@link BillowEventType}. */\nexport type UsageEventType = Extract<BillowEventType, `usage.${string}`>;\n/** The prepaid credits subset of {@link BillowEventType} (`credit_balance.*`, `credit_grant.*`, ...). */\nexport type CreditEventType = Extract<BillowEventType, `credit_${string}`>;\n/** The `hosted_domain.*` subset of {@link BillowEventType}: custom portal domains. */\nexport type HostedDomainEventType = Extract<BillowEventType, `hosted_domain.${string}`>;\n\n/** The subset of {@link WEBHOOK_EVENT_TYPES} sharing `prefix`, as a runtime array (order preserved). */\nfunction eventTypesWithPrefix<P extends string>(\n prefix: P,\n): readonly Extract<BillowEventType, `${P}${string}`>[] {\n return WEBHOOK_EVENT_TYPES.filter((type): type is Extract<BillowEventType, `${P}${string}`> =>\n type.startsWith(prefix),\n );\n}\n\n/** The `charge.*` event types, as a runtime array (for pickers / exhaustive routing). */\nexport const CHARGE_EVENT_TYPES: readonly ChargeEventType[] = eventTypesWithPrefix(\"charge.\");\n/** The `invoice.*` event types, as a runtime array. */\nexport const INVOICE_EVENT_TYPES: readonly InvoiceEventType[] = eventTypesWithPrefix(\"invoice.\");\n/**\n * The subscription lifecycle event types - the `subscription.*` subset of\n * {@link WEBHOOK_EVENT_TYPES}, as a runtime array. Use it (or {@link isSubscriptionEvent})\n * to select the events that affect a subscription mirror without a brittle string match.\n */\nexport const SUBSCRIPTION_EVENT_TYPES: readonly SubscriptionEventType[] =\n eventTypesWithPrefix(\"subscription.\");\n/** The `entitlement.*` event types, as a runtime array. */\nexport const ENTITLEMENT_EVENT_TYPES: readonly EntitlementEventType[] =\n eventTypesWithPrefix(\"entitlement.\");\n/** The `usage.*` event types, as a runtime array. */\nexport const USAGE_EVENT_TYPES: readonly UsageEventType[] = eventTypesWithPrefix(\"usage.\");\n/** The prepaid credits event types, as a runtime array. */\nexport const CREDIT_EVENT_TYPES: readonly CreditEventType[] = eventTypesWithPrefix(\"credit_\");\n/** The `hosted_domain.*` event types, as a runtime array. */\nexport const HOSTED_DOMAIN_EVENT_TYPES: readonly HostedDomainEventType[] =\n eventTypesWithPrefix(\"hosted_domain.\");\n\n/** Whether `type` is a subscription lifecycle event (`subscription.*`); narrows the type. */\nexport function isSubscriptionEvent(type: string): type is SubscriptionEventType {\n return type.startsWith(\"subscription.\");\n}\n","/**\n * Verify and parse billow webhook deliveries.\n *\n * billow signs each delivery with HMAC-SHA256 over `\"<timestamp>.<rawBody>\"` and\n * sends it in the `billow-signature` header as `t=<unix-seconds>,v1=<hex>`. The\n * timestamp lets you reject replays outside a freshness window.\n *\n * Verification is async and uses the Web Crypto API (`globalThis.crypto.subtle`),\n * so the same code runs in Node 20+, edge runtimes, Cloudflare Workers, Deno, and\n * Bun — it never imports `node:crypto`.\n *\n * import { constructEvent } from \"@usebillow/sdk/webhooks\";\n *\n * const event = await constructEvent(rawBody, req.headers[\"billow-signature\"], secret);\n * switch (event.type) {\n * case \"invoice.paid\": // …\n * }\n */\n\nimport type { CreditGrantKind, CreditThresholdLevel } from \"./types/credits\";\nimport type {\n HostedDomainActivatedData,\n HostedDomainDisabledData,\n HostedDomainDnsFailingData,\n HostedDomainReassignmentPendingData,\n} from \"./types/hosted-domains\";\nimport type {\n BillowEventType,\n EntitlementEventType,\n InvoiceEventType,\n SubscriptionEventType,\n} from \"./webhook-event-types\";\n\nexport * from \"./webhook-event-types\";\n\n/**\n * The fields every webhook envelope carries. Each {@link WebhookEvent} variant intersects\n * this with its own `type` + payload-typed `data`.\n */\nexport interface WebhookEventEnvelope {\n /** Stable event id — your idempotency key (delivery is at-least-once). */\n id: string;\n /** Envelope schema version. */\n version: number;\n /** ISO-8601 timestamp of when the event was recorded. */\n createdAt: string;\n}\n\n// --- Per-event-group payloads -------------------------------------------------------------\n// Shapes mirror what the server emits (packages/server/src/services: charges.ts, billing.ts,\n// renewal/*, subscriptions/*, entitlements/*). Grouped where a group's emitters are uniform;\n// `usage.*` is split per event because its emitters diverge. `customerExternalId` is added to\n// every customer-scoped payload by the server's outbox emitter (events.ts).\n\n/** `charge.succeeded` / `charge.failed`. */\nexport interface ChargeEventData {\n chargeId: string;\n /**\n * `false` on a `charge.failed` that closed a checkout its customer abandoned long before: no\n * \"payment failed\" email was sent to the customer. Absent otherwise.\n */\n notifyCustomer?: false;\n}\n/** `charge.refunded`, emitted once for each succeeded refund transition. */\nexport interface ChargeRefundedEventData {\n chargeId: string;\n refundId: string;\n amount: number;\n cumulativeAmount: number;\n currency: string;\n}\n/** Stable dispute facts shared by the opened and resolved events. */\nexport interface ChargeDisputeEventData {\n chargeId: string;\n disputeId: string;\n amount: number;\n currency: string;\n reason: string | null;\n state: \"open\" | \"won\" | \"lost\";\n}\n/** `invoice.paid` / `invoice.payment_failed`. */\nexport interface InvoiceEventData {\n invoiceId: string;\n chargeId: string;\n /**\n * `false` on an `invoice.payment_failed` that closed a checkout its customer abandoned long\n * before: no \"payment failed\" email was sent to the customer. Absent otherwise.\n */\n notifyCustomer?: false;\n}\n/** Any `subscription.*` lifecycle event. */\nexport interface SubscriptionEventData {\n subscriptionId: string;\n customerId: string;\n customerExternalId: string;\n /** The subscription's plan — so a consumer knows which product the event is about. */\n productId: string;\n productSlug: string;\n /** The product's metadata map (Stripe-style); `{}` when the plan carries none. */\n productMetadata: Record<string, string>;\n /** Set on dunning events that reference the unpaid invoice (`unpaid`, `requires_payment_method`). */\n invoiceId?: string;\n /** Set on lifecycle-ending events (`ended`, downgrade-driven `updated`). */\n reason?: string;\n /** Present when a downgrade is scheduled; null when that schedule is cleared. */\n scheduledChange?: {\n productVersionId: string;\n productId: string;\n productSlug: string;\n productName: string;\n effectiveAt: string | null;\n } | null;\n}\n/** `entitlement.granted` / `entitlement.reset`. */\nexport interface EntitlementEventData {\n subscriptionId: string;\n customerId: string;\n customerExternalId: string;\n featureId: string;\n /** Remaining allowance; `null` when the feature is unlimited. */\n balance: number | null;\n /** Whether the feature grants unlimited usage. */\n unlimited: boolean;\n}\n/** Fields shared by every `usage.*` event. */\nexport interface UsageEventBase {\n subscriptionId: string;\n customerId: string;\n customerExternalId: string;\n /** Feature slug. */\n feature: string;\n featureName: string;\n /** Aggregate usage after this step. */\n used: number;\n /** Included allowance; `null` when unlimited. */\n allowance: number | null;\n /** Remaining allowance; `null` when unlimited. */\n balance: number | null;\n}\n/** `usage.threshold_reached` — a built-in usage threshold was crossed. */\nexport interface UsageThresholdReachedData extends UsageEventBase {\n featureId: string;\n /** The threshold crossed (currently only the included allowance being exhausted). */\n level: \"exhausted\";\n}\n/** `usage.alert` — a configured usage alert fired. */\nexport interface UsageAlertData extends UsageEventBase {\n metric: string;\n threshold: number;\n recurring: boolean;\n notifyCustomer: boolean;\n}\n/** `usage.cap_reached` — a configured spend/usage cap was reached. */\nexport interface UsageCapReachedData extends UsageEventBase {\n metric: string;\n cap: number;\n capAt: number;\n}\n\n// Prepaid credits: every credit quantity is a decimal string of microcredits, never a number.\n// Payloads carry ids, codes and quantities, never a grant's, reservation's or top-up's reason or\n// metadata.\n\n/** Fields shared by every prepaid credits event. */\nexport interface CreditEventBase {\n customerId: string;\n customerExternalId: string;\n}\n/**\n * `credit_balance.threshold_crossed` - the balance moved to another level, in either direction\n * (raise a low-balance banner, or clear it). `exhausted` when nothing is spendable; otherwise\n * `critical` / `low` at or below the percentages of `thresholds.reference`, else `normal`.\n */\nexport interface CreditThresholdCrossedData extends CreditEventBase {\n level: CreditThresholdLevel;\n previousLevel: CreditThresholdLevel;\n /** The balance the level was judged on, as of `asOf`; re-read the balance for truth. */\n balance: { spendable: string; held: string; asOf: string };\n thresholds: { lowPercent: number; criticalPercent: number; reference: string | null };\n}\n/** `credit_grant.created` - credits were granted. */\nexport interface CreditGrantCreatedData extends CreditEventBase {\n grantId: string;\n kind: CreditGrantKind;\n amount: string;\n /** For an `included` grant, its window's end. */\n expiresAt: string | null;\n eligibility: string[] | null;\n topUpId: string | null;\n /** An `included` grant's subscription; null for every other kind. */\n subscriptionId: string | null;\n /** The billing period an `included` grant's window belongs to; null for every other kind. */\n periodStart: string | null;\n periodEnd: string | null;\n}\n/**\n * `credit_grant.expired` - one expiry took `expired` credits off a grant: its unheld credits when\n * it expired, or a held part that came back to it afterwards (one more event each).\n */\nexport interface CreditGrantExpiredData extends CreditEventBase {\n grantId: string;\n kind: CreditGrantKind;\n expiresAt: string | null;\n expired: string;\n /** Why it expired: `grant_expired` - the grant reached its expiry. */\n reason: \"grant_expired\";\n /** The `expire` ledger transaction. */\n transactionId: string;\n}\n/** `credit_reservation.expired` - a hold passed its expiry unsettled and was released. */\nexport interface CreditReservationExpiredData extends CreditEventBase {\n reservationId: string;\n operationKey: string;\n category: string;\n amount: string;\n expiresAt: string;\n}\n/** What every `credit_top_up.*` event says about the purchase and its payment. */\nexport interface CreditTopUpEventBase extends CreditEventBase {\n topUpId: string;\n packId: string;\n chargeId: string;\n /** What the payment collects, in minor units of `currency`. */\n amount: number;\n currency: string;\n /** The terms bought. */\n credits: string;\n bonusCredits: string;\n}\n/** `credit_top_up.succeeded` - the payment succeeded and its credits (and any bonus) were granted. */\nexport interface CreditTopUpSucceededData extends CreditTopUpEventBase {\n purchasedGrantId: string | null;\n bonusGrantId: string | null;\n}\n/**\n * `credit_top_up.failed` - the payment failed. A later payment on the same checkout can still\n * succeed it: `credit_top_up.succeeded` may follow for the same top-up.\n */\nexport type CreditTopUpFailedData = CreditTopUpEventBase;\n/** `credit_top_up.unfulfilled` - paid after the customer was erased: nothing granted, refund it. */\nexport type CreditTopUpUnfulfilledData = CreditTopUpEventBase;\n/**\n * `credit_top_up.refunded` - a refund or lost chargeback revoked the top-up's credits in\n * proportion to the money returned.\n */\nexport interface CreditTopUpRefundedData extends CreditTopUpEventBase {\n cause: \"refund\" | \"chargeback\";\n /** Money returned so far (refunds plus lost disputes, at most what was charged), minor units. */\n amountReturned: number;\n /** Microcredits this refund or chargeback revoked. */\n revokedNow: string;\n /** Microcredits revoked so far in all. */\n revoked: string;\n /** Of them, what the customer had already spent and nothing has paid back. */\n shortfall: string;\n}\n\n/**\n * The verified webhook envelope billow POSTs to your endpoint — a discriminated union on\n * {@link WebhookEvent.type}. `switch` (or a {@link WebhookHandlers} map) on `type` and `data`\n * narrows to the exact payload for that event.\n *\n * switch (event.type) {\n * case \"invoice.paid\": settle(event.data.invoiceId); break; // InvoiceEventData\n * case \"subscription.canceled\": revoke(event.data.customerExternalId); break;\n * }\n */\nexport type WebhookEventDataMap = {\n [K in \"charge.succeeded\" | \"charge.failed\"]: ChargeEventData;\n} & {\n \"charge.refunded\": ChargeRefundedEventData;\n} & {\n [K in \"charge.disputed\" | \"charge.dispute_resolved\"]: ChargeDisputeEventData;\n} & {\n [K in InvoiceEventType]: InvoiceEventData;\n} & {\n [K in SubscriptionEventType]: SubscriptionEventData;\n} & {\n [K in EntitlementEventType]: EntitlementEventData;\n} & {\n \"usage.threshold_reached\": UsageThresholdReachedData;\n \"usage.alert\": UsageAlertData;\n \"usage.cap_reached\": UsageCapReachedData;\n} & {\n \"credit_balance.threshold_crossed\": CreditThresholdCrossedData;\n \"credit_grant.created\": CreditGrantCreatedData;\n \"credit_grant.expired\": CreditGrantExpiredData;\n \"credit_reservation.expired\": CreditReservationExpiredData;\n \"credit_top_up.succeeded\": CreditTopUpSucceededData;\n \"credit_top_up.failed\": CreditTopUpFailedData;\n \"credit_top_up.refunded\": CreditTopUpRefundedData;\n \"credit_top_up.unfulfilled\": CreditTopUpUnfulfilledData;\n} & {\n \"hosted_domain.activated\": HostedDomainActivatedData;\n \"hosted_domain.dns_failing\": HostedDomainDnsFailingData;\n \"hosted_domain.reassignment_pending\": HostedDomainReassignmentPendingData;\n \"hosted_domain.disabled\": HostedDomainDisabledData;\n};\n\n/** One distributive union member per event literal, derived from the payload map. */\nexport type WebhookEvent = {\n [K in BillowEventType]: WebhookEventEnvelope & { type: K; data: WebhookEventDataMap[K] };\n}[BillowEventType];\n\nexport interface VerifyOptions {\n /**\n * Max age (seconds) of a delivery's signature timestamp before it's rejected as\n * a possible replay. Default 300 (5 minutes).\n */\n toleranceSeconds?: number;\n}\n\n/** Thrown by {@link constructEvent} when a delivery can't be trusted. */\nexport class WebhookVerificationError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"WebhookVerificationError\";\n }\n}\n\nconst DEFAULT_TOLERANCE_SECONDS = 300;\nconst encoder = new TextEncoder();\n\ninterface ParsedHeader {\n timestamp: number;\n signatures: string[];\n}\n\n/** Parse `t=<unix>,v1=<hex>[,v1=<hex>…]` — multiple v1 supports key rotation. */\nfunction parseHeader(header: string): ParsedHeader | null {\n let timestamp = Number.NaN;\n const signatures: string[] = [];\n for (const part of header.split(\",\")) {\n const eq = part.indexOf(\"=\");\n if (eq === -1) continue;\n const key = part.slice(0, eq).trim();\n const value = part.slice(eq + 1).trim();\n if (key === \"t\") timestamp = Number(value);\n else if (key === \"v1\") signatures.push(value);\n }\n if (!Number.isFinite(timestamp) || signatures.length === 0) return null;\n return { timestamp, signatures };\n}\n\nasync function hmacHex(secret: string, payload: string): Promise<string> {\n const key = await crypto.subtle.importKey(\n \"raw\",\n encoder.encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n const sig = await crypto.subtle.sign(\"HMAC\", key, encoder.encode(payload));\n return Array.from(new Uint8Array(sig), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\n/** Constant-time compare of two equal-length hex strings (no early exit). */\nfunction timingSafeEqual(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n let mismatch = 0;\n for (let i = 0; i < a.length; i++) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);\n return mismatch === 0;\n}\n\n/**\n * Return `true` if `header` is a valid signature for `payload` under `secret` and\n * within the freshness window. Never throws — use {@link constructEvent} when you\n * want the parsed event and a thrown error on failure. Pass the RAW request body.\n */\nexport async function verifySignature(\n payload: string,\n header: string | null | undefined,\n secret: string,\n opts: VerifyOptions = {},\n): Promise<boolean> {\n if (!header) return false;\n const parsed = parseHeader(header);\n if (!parsed) return false;\n\n const tolerance = opts.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;\n const now = Math.floor(Date.now() / 1000);\n if (Math.abs(now - parsed.timestamp) > tolerance) return false;\n\n const expected = await hmacHex(secret, `${parsed.timestamp}.${payload}`);\n return parsed.signatures.some((sig) => timingSafeEqual(sig, expected));\n}\n\n/**\n * Produce a valid `billow-signature` header for `payload` under `secret` - the inverse of\n * {@link verifySignature}. Use it to exercise your webhook endpoint in tests without\n * reverse-engineering the signing scheme. Pass the RAW body you will POST. `timestamp` (unix\n * seconds) defaults to now; pass a fixed value for a deterministic - or intentionally stale\n * (replay) - signature.\n *\n * const header = await signWebhook(rawBody, endpointSecret);\n * await fetch(url, { method: \"POST\", headers: { \"billow-signature\": header }, body: rawBody });\n */\nexport async function signWebhook(\n payload: string,\n secret: string,\n opts: { timestamp?: number } = {},\n): Promise<string> {\n const timestamp = opts.timestamp ?? Math.floor(Date.now() / 1000);\n const signature = await hmacHex(secret, `${timestamp}.${payload}`);\n return `t=${timestamp},v1=${signature}`;\n}\n\n/**\n * Verify `payload` against the `billow-signature` header and return the parsed,\n * typed {@link WebhookEvent}. Throws {@link WebhookVerificationError} if the\n * signature is invalid, the timestamp is outside the freshness window, or the body\n * isn't a billow envelope. Always pass the RAW request body — a re-serialized\n * parsed body will not match.\n */\nexport async function constructEvent(\n payload: string,\n header: string | null | undefined,\n secret: string,\n opts: VerifyOptions = {},\n): Promise<WebhookEvent> {\n if (!(await verifySignature(payload, header, secret, opts))) {\n throw new WebhookVerificationError(\"billow webhook signature verification failed\");\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(payload);\n } catch {\n throw new WebhookVerificationError(\"billow webhook payload is not valid JSON\");\n }\n if (!isWebhookEvent(parsed)) {\n throw new WebhookVerificationError(\"billow webhook payload is not a valid event envelope\");\n }\n return parsed;\n}\n\n/** A handler for one verified webhook event. May be async; a throw propagates to the caller. */\nexport type WebhookHandler = (event: WebhookEvent) => void | Promise<void>;\n\n/**\n * A map of per-event-type handlers plus an optional `onEvent` fallback. Each handler receives\n * the event NARROWED to its type, so `data` is the exact payload for that event\n * (e.g. `\"invoice.paid\": (e) => settle(e.data.invoiceId)`). Keyed by the literal event type so\n * it stays in lock-step with the contract — a new event type is dispatchable with no parallel\n * `onFooBar` name registry to keep in sync.\n */\nexport type WebhookHandlers = {\n [K in BillowEventType]?: (event: Extract<WebhookEvent, { type: K }>) => void | Promise<void>;\n} & {\n /** Called for any event that has no specific handler (receives the full union). */\n onEvent?: WebhookHandler;\n};\n\n/**\n * Build a reusable dispatcher bound to your endpoint secret + handlers: it verifies a\n * delivery (via {@link constructEvent}) then routes the typed event to the matching\n * handler, falling back to `onEvent`. Returns the verified event so the caller can log\n * or ack it. Throws {@link WebhookVerificationError} on an untrusted delivery (return a\n * 4xx) and propagates any error a handler throws (return a 5xx so billow retries).\n * Pass the RAW request body — a re-serialized parsed body won't match the signature.\n *\n * const dispatch = createWebhookDispatcher({\n * secret: process.env.BILLOW_WEBHOOK_SECRET,\n * handlers: {\n * \"invoice.paid\": (e) => fulfil(e.data.invoiceId),\n * \"subscription.canceled\": (e) => revoke(e.data),\n * onEvent: (e) => log(e.type),\n * },\n * });\n * // per request:\n * const event = await dispatch(rawBody, req.headers[\"billow-signature\"]);\n */\nexport function createWebhookDispatcher(config: {\n secret: string;\n handlers: WebhookHandlers;\n toleranceSeconds?: number;\n}): (payload: string, signatureHeader: string | null | undefined) => Promise<WebhookEvent> {\n const { secret, handlers, toleranceSeconds } = config;\n const opts = toleranceSeconds === undefined ? undefined : { toleranceSeconds };\n return async (payload, signatureHeader) => {\n const event = await constructEvent(payload, signatureHeader, secret, opts);\n // Own-property lookup, NOT `handlers[event.type]` directly: `event.type` is only\n // runtime-typed as a string, so a value colliding with an inherited key\n // (\"toString\"/\"constructor\"/\"__proto__\") would otherwise resolve to a prototype\n // method and be invoked instead of routing to `onEvent`. (Not reachable via a\n // trusted delivery — the type is HMAC-verified above and billow only emits fixed\n // event types — but the fallback contract must hold for any signature-valid type.)\n // The looked-up handler expects only its own narrowed variant; cast it to accept the full\n // union so we can invoke it with `event` — safe because the handler registered under key\n // `event.type` only ever receives the matching variant at runtime.\n const own = Object.hasOwn(handlers, event.type)\n ? (handlers[event.type] as WebhookHandler | undefined)\n : undefined;\n const handler = own ?? handlers.onEvent;\n if (handler) await handler(event);\n return event;\n };\n}\n\nfunction isWebhookEvent(value: unknown): value is WebhookEvent {\n if (typeof value !== \"object\" || value === null) return false;\n const e = value as {\n id?: unknown;\n version?: unknown;\n type?: unknown;\n createdAt?: unknown;\n data?: unknown;\n };\n return (\n typeof e.id === \"string\" &&\n typeof e.version === \"number\" &&\n typeof e.type === \"string\" &&\n typeof e.createdAt === \"string\" &&\n typeof e.data === \"object\" &&\n e.data !== null\n );\n}\n"]}
|
package/package.json
CHANGED