Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@ jobs:
deno-version: v1.x
- name: Build project
run: deno task build
- name: Typecheck webhook types
run: deno task typecheck
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,44 @@ try {
}
```

### Webhook payload types

The SDK exposes TypeScript types for every webhook event Lago emits. The types are generated from the OpenAPI spec, so they stay in sync with the API.

Indexed lookup by `webhook_type`:

```typescript
import type { LagoWebhookPayloads } from 'lago-javascript-client';

app.post('/webhooks', (req, res) => {
const event = req.body as LagoWebhookPayloads['alert.triggered'];
// event.triggered_alert is fully typed
console.log(event.triggered_alert.external_customer_id);
res.sendStatus(200);
});
```

Discriminated union for handlers that cover multiple events:

```typescript
import type { LagoWebhookPayload } from 'lago-javascript-client';

function handle(event: LagoWebhookPayload) {
switch (event.webhook_type) {
case 'alert.triggered':
// event.triggered_alert is typed
return event.triggered_alert;
case 'invoice.created':
// event.invoice is typed
return event.invoice;
case 'subscription.terminated':
return event.subscription;
}
}
```

A `WebhookOf<T>` helper is also exported for picking a single payload type by name, and `LagoWebhookType` gives the union of all event-type strings.

## Development

Uses [dnt](https://github.com/denoland/dnt) to build and test for Deno and Node.
Expand Down
6 changes: 5 additions & 1 deletion deno.jsonc
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
{
"tasks": {
"build": "deno task generate:openapi && deno task generate:npm-package",
"build": "deno task generate:openapi && deno task generate:webhooks && deno task generate:npm-package",
"generate:npm-package": "deno run -A scripts/build_npm.ts",
"generate:openapi": "npx -y swagger-typescript-api@13.0.2 -p https://swagger.getlago.com/openapi.yaml --union-enums -o ./openapi -n client.ts",
// Generates ./openapi/webhooks.ts containing typed webhook payload schemas from the OpenAPI 3.1 `webhooks:` key. swagger-typescript-api does not handle that key, so we use openapi-typescript here instead. The output is consumed by ../webhook_types.ts (hand-written) which exposes the user-facing helpers.
"generate:webhooks": "npx -y openapi-typescript@7.13.0 https://swagger.getlago.com/openapi.yaml -o ./openapi/webhooks.ts",
// Typechecks the public surface and the webhook type tests. Cheap (no runtime, no network), suitable for CI. Run after `generate:openapi` and `generate:webhooks` so the generated files exist on disk.
"typecheck": "deno check mod.ts webhook_types.ts tests/webhook_types.test.ts",
"test": "deno test ./tests --parallel"
}
}
8 changes: 8 additions & 0 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,11 @@ export {
} from "./logging_rate_limit_observer.ts";

export * from "./openapi/client.ts";

// Webhook payload types (see webhook_types.ts and openapi/webhooks.ts)
export type {
LagoWebhookPayload,
LagoWebhookPayloads,
LagoWebhookType,
WebhookOf,
} from "./webhook_types.ts";
2 changes: 1 addition & 1 deletion scripts/build_npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ await build({
// package.json properties
name: "lago-javascript-client",
sideEffects: false,
version: "v1.47.0",
version: "v1.47.1",
description: "Lago JavaScript API Client",
repository: {
type: "git",
Expand Down
195 changes: 195 additions & 0 deletions tests/webhook_types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// Type-level tests for the webhook payload types exported from mod.ts.
//
// These assertions run at compile time. The Deno.test() wrappers exist so
// the file is picked up by `deno task test` (which typechecks the file by
// default) and any compile-time failure surfaces as a Deno typecheck error.
// The runtime bodies are deliberately trivial.
//
// Scope: we verify the envelope shape (webhook_type, object_type,
// organization_id), the presence of the nested object on each event, and
// the discriminated-union narrowing. We deliberately do NOT assert
// structural equality between a webhook's nested object and the SDK's
// component types (e.g. `CustomerObject`), because those come from a
// different generator that handles nullability slightly differently. See
// the note in ../webhook_types.ts for details.

import { assertEquals } from "../dev_deps.ts";
import type {
LagoWebhookPayload,
LagoWebhookPayloads,
LagoWebhookType,
WebhookOf,
} from "../mod.ts";

// ---------------------------------------------------------------------------
// Type-level test helpers
// ---------------------------------------------------------------------------

/** Compiles only when T is `true`. Use to anchor an Equal<> assertion. */
type Expect<T extends true> = T;

/** Strict structural equality (catches optional/readonly differences). */
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2) ? true : false;

/** Looser check: A is assignable to B. */
type Extends<A, B> = A extends B ? true : false;

// ---------------------------------------------------------------------------
// 1. Indexed lookup keyed by the dotted webhook_type
// ---------------------------------------------------------------------------

Deno.test("LagoWebhookPayloads keys events by dotted webhook_type", () => {
// Each of these resolves to a concrete payload type; a missing key is a compile error.
type _Alert = LagoWebhookPayloads["alert.triggered"];
type _Invoice = LagoWebhookPayloads["invoice.created"];
type _Customer = LagoWebhookPayloads["customer.created"];
type _Subscription = LagoWebhookPayloads["subscription.terminated"];
type _Wallet = LagoWebhookPayloads["wallet.depleted_ongoing_balance"];
type _Payment = LagoWebhookPayloads["payment.succeeded"];
type _CreditNote = LagoWebhookPayloads["credit_note.created"];

assertEquals(true, true);
});

Deno.test("alert.triggered envelope is correctly typed", () => {
type Payload = LagoWebhookPayloads["alert.triggered"];
type _C1 = Expect<Equal<Payload["webhook_type"], "alert.triggered">>;
type _C2 = Expect<Equal<Payload["object_type"], "triggered_alert">>;
type _C3 = Expect<Extends<Payload["organization_id"], string>>;
// `triggered_alert` is the nested object; we only check it exists and is non-nullable.
type _C4 = Expect<Extends<Payload["triggered_alert"], object>>;

assertEquals(true, true);
});

Deno.test("invoice.created envelope is correctly typed", () => {
type Payload = LagoWebhookPayloads["invoice.created"];
type _C1 = Expect<Equal<Payload["webhook_type"], "invoice.created">>;
type _C2 = Expect<Equal<Payload["object_type"], "invoice">>;
type _C3 = Expect<Extends<Payload["organization_id"], string>>;
type _C4 = Expect<Extends<Payload["invoice"], object>>;

assertEquals(true, true);
});

Deno.test("customer.created envelope is correctly typed", () => {
type Payload = LagoWebhookPayloads["customer.created"];
type _C1 = Expect<Equal<Payload["webhook_type"], "customer.created">>;
type _C2 = Expect<Equal<Payload["object_type"], "customer">>;
type _C3 = Expect<Extends<Payload["customer"], object>>;

assertEquals(true, true);
});

Deno.test("subscription.terminated envelope is correctly typed", () => {
type Payload = LagoWebhookPayloads["subscription.terminated"];
type _C1 = Expect<Equal<Payload["webhook_type"], "subscription.terminated">>;
type _C2 = Expect<Equal<Payload["object_type"], "subscription">>;
type _C3 = Expect<Extends<Payload["subscription"], object>>;

assertEquals(true, true);
});

Deno.test("wallet.depleted_ongoing_balance envelope is correctly typed", () => {
type Payload = LagoWebhookPayloads["wallet.depleted_ongoing_balance"];
type _C1 = Expect<Equal<Payload["webhook_type"], "wallet.depleted_ongoing_balance">>;
type _C2 = Expect<Equal<Payload["object_type"], "wallet">>;
type _C3 = Expect<Extends<Payload["wallet"], object>>;

assertEquals(true, true);
});

// ---------------------------------------------------------------------------
// 2. WebhookOf<T> helper
// ---------------------------------------------------------------------------

Deno.test("WebhookOf<T> matches LagoWebhookPayloads[T]", () => {
type _C1 = Expect<
Equal<WebhookOf<"alert.triggered">, LagoWebhookPayloads["alert.triggered"]>
>;
type _C2 = Expect<
Equal<WebhookOf<"invoice.created">, LagoWebhookPayloads["invoice.created"]>
>;
type _C3 = Expect<
Equal<
WebhookOf<"subscription.terminated">,
LagoWebhookPayloads["subscription.terminated"]
>
>;

assertEquals(true, true);
});

// ---------------------------------------------------------------------------
// 3. LagoWebhookType union
// ---------------------------------------------------------------------------

Deno.test("LagoWebhookType union contains documented events", () => {
const _v1: LagoWebhookType = "alert.triggered";
const _v2: LagoWebhookType = "invoice.created";
const _v3: LagoWebhookType = "customer.created";
const _v4: LagoWebhookType = "subscription.terminated";
const _v5: LagoWebhookType = "wallet.depleted_ongoing_balance";
const _v6: LagoWebhookType = "payment.succeeded";
const _v7: LagoWebhookType = "credit_note.created";

assertEquals(true, true);
});

// ---------------------------------------------------------------------------
// 4. LagoWebhookPayload discriminated-union narrowing
// ---------------------------------------------------------------------------

Deno.test("LagoWebhookPayload narrows correctly via webhook_type", () => {
function handle(event: LagoWebhookPayload): unknown {
switch (event.webhook_type) {
case "alert.triggered":
// After narrowing, the alert-specific field is accessible.
return event.triggered_alert;
case "invoice.created":
return event.invoice;
case "customer.created":
return event.customer;
case "subscription.terminated":
return event.subscription;
default:
return null;
}
}
// Reference the function so TS does not elide it.
assertEquals(typeof handle, "function");
});

// ---------------------------------------------------------------------------
// 5. Negative tests — these MUST fail to compile if @ts-expect-error is removed
// ---------------------------------------------------------------------------

Deno.test("Unknown webhook event names are rejected at compile time", () => {
// @ts-expect-error - "not.a.real.event" is not a key of LagoWebhookPayloads
type _Bad1 = LagoWebhookPayloads["not.a.real.event"];

// @ts-expect-error - WebhookOf only accepts LagoWebhookType keys
type _Bad2 = WebhookOf<"not.a.real.event">;

// @ts-expect-error - "definitely.not.a.lago.event" is not in the union
const _bad: LagoWebhookType = "definitely.not.a.lago.event";

assertEquals(true, true);
});

Deno.test("Narrowed branches reject fields belonging to other events", () => {
function _check(event: LagoWebhookPayload) {
if (event.webhook_type === "alert.triggered") {
// @ts-expect-error - `invoice` is not present on the alert.triggered branch
const _x = event.invoice;
return _x;
}
if (event.webhook_type === "invoice.created") {
// @ts-expect-error - `triggered_alert` is not present on the invoice.created branch
const _y = event.triggered_alert;
return _y;
}
}
assertEquals(true, true);
});
90 changes: 90 additions & 0 deletions webhook_types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// User-facing TypeScript types for Lago webhook payloads.
//
// Backed by ./openapi/webhooks.ts, which is auto-generated from the Lago
// OpenAPI 3.1 spec (`webhooks:` key) via `deno task generate:webhooks`.
// This file is hand-written and is safe across regenerations.
//
// The generator output keys events by their snake_case operationId
// (e.g. `alert_triggered`). We re-key here by the dotted `webhook_type`
// literal (e.g. `alert.triggered`) so that the public API matches the
// string customers actually see on the wire.
//
// Note on nested types: the payload types here are produced by
// `openapi-typescript`, while the SDK's component exports (e.g.
// `CustomerObject`, `InvoiceObjectExtended`) are produced by
// `swagger-typescript-api`. The two generators interpret the same schema
// slightly differently for nullable fields, so a value typed as
// `LagoWebhookPayloads['customer.created']['customer']` is not always
// structurally equal to `CustomerObject` from the main SDK exports. Both
// are valid views of the same wire shape; the openapi-typescript view is
// the more accurate one for what actually arrives over the network. If
// you pass webhook objects into helpers typed with SDK components, a
// narrowing cast may be required.

import type { webhooks } from "./openapi/webhooks.ts";

/** Extract the `application/json` body of a webhook's POST request. */
type _PayloadOf<K extends keyof webhooks> = webhooks[K] extends {
post: { requestBody?: { content: { "application/json": infer P } } };
} ? P
: never;

/** Extract the `webhook_type` literal (e.g. `"alert.triggered"`) from a payload. */
type _WebhookTypeOf<K extends keyof webhooks> = _PayloadOf<K> extends
{ webhook_type: infer T } ? T extends string ? T : never : never;

/**
* Map of webhook event name to its typed payload.
*
* @example
* ```ts
* import type { LagoWebhookPayloads } from "lago-javascript-client";
*
* app.post("/webhooks", (req, res) => {
* const event = req.body as LagoWebhookPayloads["alert.triggered"];
* console.log(event.triggered_alert.external_customer_id);
* });
* ```
*/
export type LagoWebhookPayloads = {
[K in keyof webhooks as _WebhookTypeOf<K>]: _PayloadOf<K>;
};

/**
* Discriminated union of every webhook payload. Narrow with the `webhook_type`
* field in a `switch` to get a fully typed branch.
*
* @example
* ```ts
* import type { LagoWebhookPayload } from "lago-javascript-client";
*
* function handle(event: LagoWebhookPayload) {
* switch (event.webhook_type) {
* case "alert.triggered":
* // event is narrowed; event.triggered_alert is fully typed
* break;
* case "invoice.created":
* // event.invoice is fully typed
* break;
* }
* }
* ```
*/
export type LagoWebhookPayload = LagoWebhookPayloads[keyof LagoWebhookPayloads];

/**
* Union of every `webhook_type` string emitted by Lago, e.g.
* `"alert.triggered" | "invoice.created" | ...`.
*/
export type LagoWebhookType = keyof LagoWebhookPayloads;

/**
* Helper to look up a single payload type by its `webhook_type` string.
*
* @example
* ```ts
* import type { WebhookOf } from "lago-javascript-client";
* type InvoiceCreated = WebhookOf<"invoice.created">;
* ```
*/
export type WebhookOf<T extends LagoWebhookType> = LagoWebhookPayloads[T];
Loading