-
-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat(sdk,cli,core): webhook() with typed provider sources, filters, and indexing #4923
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| --- | ||
| "@trigger.dev/core": minor | ||
| "@trigger.dev/sdk": minor | ||
| "trigger.dev": minor | ||
| --- | ||
|
|
||
| Add hosted webhooks: receive and verify provider webhooks as a task, with no ingress or verification code of your own. | ||
|
|
||
| - `webhook()` declares an endpoint that routes a verified, typed event to an `onEvent` handler. Choose a source with a preset (`webhooks.stripe()`, `webhooks.github()`, and others) or `webhooks.custom<T>(config)`. Declared webhooks are discovered like tasks and synced to a hosted URL on deploy. | ||
| - `filter` gates which deliveries run, using a type-safe expression checked against the event at author time (`event.`/`header.`/`webhook.` paths, `&&`/`||`, comparison and `in`/`contains` operators, field-to-field comparison, and array quantifiers). A non-matching delivery is still recorded, not routed. | ||
| - HTTP API for listing webhook endpoints and deliveries, plus rotate-secret, enable/disable, and replay. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| --- | ||
| title: "Connecting a provider" | ||
| description: "Point a provider at the webhook URL and set the signing secret." | ||
| sidebarTitle: "Connecting a provider" | ||
| --- | ||
|
|
||
| When you deploy (or run `dev`), each webhook task gets an **endpoint** with a unique, unguessable webhook URL. Open the webhook in the dashboard, go to **Endpoints**, and open the endpoint to find its **Connect** panel. | ||
|
|
||
| <Steps> | ||
| <Step title="Copy the webhook URL"> | ||
| Copy it from the endpoint's Connect panel. On Trigger.dev Cloud it looks like | ||
| `https://webhooks.trigger.dev/webhooks/v1/ingest/<id>`. A self-hosted instance serves it from that | ||
| instance's own base URL. This is what you give the provider as its webhook destination. | ||
| </Step> | ||
| <Step title="Set the signing secret"> | ||
| A webhook can't accept deliveries until its signing secret is set. Until then every request is | ||
| rejected. There are two flows, and the Connect panel shows the right one for the provider: | ||
|
|
||
| - **The provider generates the secret** (Stripe, Svix): copy it from the provider and paste it | ||
| into **Set secret**. | ||
| - **You choose the secret** (GitHub, or a service you control): click **Generate secret** and | ||
| Trigger.dev mints a strong secret and shows it once. Paste that into the provider's webhook config. | ||
| </Step> | ||
| <Step title="Point the provider at the webhook URL"> | ||
| Add the webhook URL as the destination in your provider's dashboard. The Connect panel | ||
| shows the exact signature scheme (header, algorithm, signing string) the provider should use. | ||
| </Step> | ||
| </Steps> | ||
|
|
||
| <Warning> | ||
| The signing secret is stored encrypted and is never shown again after it's set. To rotate it, | ||
| use **Rotate secret** (or **Regenerate**) and update the provider with the new value. | ||
| </Warning> | ||
|
|
||
| Once a provider is sending events, watch them arrive on the [Deliveries](/webhooks/deliveries) page, which also explains what an [endpoint](/webhooks/deliveries#endpoints) is. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| --- | ||
| title: "Deliveries and endpoints" | ||
| description: "Observe inbound webhook requests, the runs they trigger, and their payloads in the dashboard." | ||
| sidebarTitle: "Deliveries & endpoints" | ||
| --- | ||
|
|
||
| The dashboard surfaces two concepts under the **Webhooks** section. | ||
|
|
||
| ## Deliveries | ||
|
|
||
| A delivery is a single inbound request that passed verification. The **Deliveries** page lists every | ||
| delivery across all your webhooks (much like the Runs page), and you can filter by webhook, status, | ||
| delivery id, or run id. | ||
|
|
||
| Open a delivery to see: | ||
|
|
||
| - Its **status** and the **run** it triggered (linked). | ||
| - The verified **event payload** and the inbound **request headers**, on separate tabs. | ||
| - The external delivery id, idempotency key, and timestamps. | ||
|
|
||
| <Note> | ||
| Duplicate deliveries are deduplicated automatically. The idempotency key is the provider's event id | ||
| (e.g. the Stripe event id, or GitHub's `X-GitHub-Delivery`), so a provider retry of the same event | ||
| resolves to the original delivery and won't trigger a second run. | ||
| </Note> | ||
|
|
||
| ## Endpoints | ||
|
|
||
| An endpoint is the connection instance for a webhook: its webhook URL, signing-secret state, | ||
| verification scheme, and delivery history. Each webhook's **Endpoints** tab lists its endpoints (a | ||
| declared webhook has one), and opening an endpoint shows its [Connect panel](/webhooks/connect) and | ||
| its scoped deliveries. | ||
|
|
||
| ## What happens to a request | ||
|
|
||
| <Steps> | ||
| <Step title="Verify"> | ||
| The signature, timestamp, and idempotency key are checked. A failure returns `400` and records | ||
| nothing. | ||
| </Step> | ||
| <Step title="Record"> | ||
| A verified request becomes a delivery, with its parsed event and headers stored. | ||
| </Step> | ||
| <Step title="Route"> | ||
| The delivery is routed to your webhook task, which runs and calls `onEvent`. The delivery's status | ||
| reflects that run's outcome. | ||
| </Step> | ||
| </Steps> | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| --- | ||
| title: "Filtering deliveries" | ||
| description: "Gate which verified webhook deliveries run, with a type-safe filter checked against the event." | ||
| sidebarTitle: "Filters" | ||
| --- | ||
|
|
||
| By default every verified delivery runs your `onEvent`. A **filter** is a server-side predicate that decides whether a delivery is routed at all. A delivery that does not match is still received and recorded, it just does not run anything. | ||
|
|
||
| Filtering happens at the endpoint, before any run is triggered, so a filtered-out event costs you nothing. | ||
|
|
||
| ## Adding a filter | ||
|
|
||
| Pass a `filter` string to `webhook()`. It is a small expression checked, at build time, against the event shape from your [source](/webhooks/sources#typing-the-event): | ||
|
|
||
| ```ts | ||
| import { webhook, webhooks } from "@trigger.dev/sdk"; | ||
|
|
||
| export const onOrder = webhook({ | ||
| id: "orders", | ||
| source: webhooks.stripe(), | ||
| // only route succeeded payment intents over $100 | ||
| filter: "event.type == 'payment_intent.succeeded' && event.data.object.amount >= 10000", | ||
| onEvent: async ({ event }) => { | ||
| // only runs for deliveries that matched | ||
| }, | ||
| }); | ||
| ``` | ||
|
|
||
| The filter is type-safe: referencing a field that does not exist, or comparing it to the wrong kind of literal, is a compile error, not a runtime surprise. | ||
|
|
||
| ## What a non-match does | ||
|
|
||
| A delivery that does not match is **not dropped**. It still returns `200` to the provider and is recorded as a [delivery](/webhooks/deliveries) with the status `FILTERED` and a reason naming the clause that failed (and the value it saw). It just never triggers a run. This keeps a filtered delivery auditable: you can see in the dashboard that it arrived and why it was not routed. | ||
|
|
||
| <Note> | ||
| If a filter throws while evaluating (for example, a malformed event), the delivery is routed rather | ||
| than dropped. Filters fail open so a filter bug never silently swallows real events. | ||
| </Note> | ||
|
|
||
| ## The expression language | ||
|
|
||
| A filter is one or more `path operator value` clauses combined with `&&` and `||` (use parentheses to group). | ||
|
|
||
| ### Paths | ||
|
|
||
| A path reads from one of three namespaces: | ||
|
|
||
| - `event.*`: the verified, parsed request body, for example `event.data.object.amount`. | ||
| - `header.*`: an inbound request header, matched case-insensitively, for example `header.x-github-event`. | ||
| - `webhook.*`: endpoint metadata (`webhook.source`, `webhook.id`, `webhook.deliveryId`, and for per-tenant endpoints `webhook.externalRef` / `webhook.tenantId`). | ||
|
|
||
| ### Operators | ||
|
|
||
| | Operator | Meaning | | ||
| | --- | --- | | ||
| | `==` `!=` | equality | | ||
| | `>` `<` `>=` `<=` | numeric comparison | | ||
| | `in` `not in` | membership in a list, for example `event.type in ['a','b']` | | ||
| | `startsWith` `endsWith` `contains` | string matching | | ||
|
|
||
| Values are strings in single quotes (`'created'`), numbers (`10000`), booleans (`true`), or a list for `in` / `not in`. | ||
|
|
||
| ### Comparing two fields | ||
|
|
||
| The right-hand side can be another path instead of a literal, so you can compare two fields of the same event: | ||
|
|
||
| ```ts | ||
| filter: "event.billing.country == event.shipping.country"; | ||
| ``` | ||
|
|
||
| ### Matching inside a list | ||
|
|
||
| `any` and `all` quantify over an array, testing a sub-path on each element: | ||
|
|
||
| ```ts | ||
| // route only if at least one line item has a positive quantity | ||
| filter: "event.items any ( quantity > 0 )"; | ||
| ``` | ||
|
|
||
| ### Spacing | ||
|
|
||
| The type checker reads the filter as a token stream, so a couple of spots are strict about spacing: keep `in` / `not in` lists unspaced (`['a','b']`, not `[ 'a', 'b' ]`) and put spaces around the quantifier parentheses (`any ( ... )`). | ||
|
|
||
| To match only certain event types, write a clause against the field that carries the type: `event.type` for Stripe / Svix / Square / Discord, or the `x-github-event` header for GitHub (the filter DSL can read a `header.` namespace too): | ||
|
|
||
| ```ts | ||
| import { webhook, webhooks } from "@trigger.dev/sdk"; | ||
|
|
||
| export const onGithub = webhook({ | ||
| id: "github", | ||
| source: webhooks.github(), | ||
| filter: "header.x-github-event in ['issues','pull_request']", | ||
| onEvent: async ({ event }) => {}, | ||
| }); | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| --- | ||
| title: "Webhooks overview" | ||
| description: "Receive and verify webhooks from external providers as a task, with a hosted webhook URL." | ||
| sidebarTitle: "Overview" | ||
| --- | ||
|
|
||
| A webhook is a task that runs when an external provider (Stripe, GitHub, Svix, your own service, …) sends an HTTP request. Trigger.dev gives each webhook a hosted webhook URL, verifies the incoming request's signature, and routes the verified event to your task's `onEvent` handler. | ||
|
|
||
| You don't host an endpoint yourself, and you don't write verification code: you declare which provider the webhook is from, point the provider at the webhook URL, and set the signing secret. | ||
|
|
||
| ## Defining a webhook task | ||
|
|
||
| A webhook is created with `webhook()`. It takes an `id`, a `source` (which provider, and how to verify it), and an `onEvent` handler: | ||
|
|
||
| ```ts | ||
| import { webhook, webhooks } from "@trigger.dev/sdk"; | ||
|
|
||
| export const onStripeEvent = webhook({ | ||
| id: "stripe-events", | ||
| source: webhooks.stripe(), | ||
| onEvent: async ({ event, headers, ctx }) => { | ||
| // `event` is the verified, parsed body | ||
| console.log("Received", event.type, event.id); | ||
|
|
||
| // `headers` is a standard Web Headers object | ||
| console.log(headers.get("stripe-signature")); | ||
|
|
||
| // `ctx` is the usual run context | ||
| console.log(ctx.run.id); | ||
| }, | ||
| }); | ||
| ``` | ||
|
|
||
| `onEvent` receives: | ||
|
|
||
| - **`event`**: the verified request body, parsed from JSON and typed by the source (see [Typing the event](/webhooks/sources#typing-the-event)). | ||
| - **`headers`**: the inbound request headers as a Web [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) object (case-insensitive `.get()` / `.has()`). | ||
| - **`ctx`**: the run context, the same one regular tasks receive. | ||
|
|
||
| <Note> | ||
| A webhook is a first-class task kind. It runs on a real run (with retries, logs, and everything else | ||
| tasks get), and shows up in the dashboard alongside your other tasks. | ||
| </Note> | ||
|
|
||
| ## How it works | ||
|
|
||
| <Steps> | ||
| <Step title="Declare the webhook"> | ||
| Define a `webhook()` with a `source`. The source is a provider preset (like `webhooks.stripe()`) | ||
| or a `webhooks.custom()` config. See [Sources and verification](/webhooks/sources). | ||
| </Step> | ||
| <Step title="Connect a provider"> | ||
| Deploying the webhook creates an endpoint with a hosted webhook URL. Set its signing secret and | ||
| point your provider at the URL. See [Connecting a provider](/webhooks/connect). | ||
| </Step> | ||
| <Step title="Receive verified events"> | ||
| Each inbound request is verified, recorded as a delivery, and routed to a run that calls your | ||
| `onEvent`. See [Deliveries and endpoints](/webhooks/deliveries). | ||
| </Step> | ||
| </Steps> | ||
|
|
||
| ## Beyond fan-out | ||
|
|
||
| A few things build on the basic model: | ||
|
|
||
| - **[Filters](/webhooks/filters)** gate which deliveries run. A non-matching delivery is recorded but never triggers a run. | ||
|
|
||
| <CardGroup cols={2}> | ||
| <Card title="Sources and verification" icon="shield-check" href="/webhooks/sources"> | ||
| Provider presets, custom verification, and typing the event. | ||
| </Card> | ||
| <Card title="Connecting a provider" icon="plug" href="/webhooks/connect"> | ||
| The webhook URL and signing secret. | ||
| </Card> | ||
| <Card title="Deliveries and endpoints" icon="inbox" href="/webhooks/deliveries"> | ||
| Observe inbound requests in the dashboard. | ||
| </Card> | ||
| <Card title="Filters" icon="filter" href="/webhooks/filters"> | ||
| Route only the deliveries you care about. | ||
| </Card> | ||
| <Card title="Scheduled tasks" icon="clock" href="/tasks/scheduled"> | ||
| The other declarative task trigger. | ||
| </Card> | ||
| </CardGroup> |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Qualify the duplicate-delivery guarantee.
If a source has no configured idempotency field and a provider retry has a new timestamp or signature, the fallback key changes.
internal-packages/webhook-engine/src/engine/verification/derive.ts:3-25hashes the raw body, timestamp, and signature in that case. The retry can create another delivery and run.State that automatic retry deduplication requires a stable provider event ID. Tell custom-source users to configure
idempotencyFieldwhen the provider supplies one.