Skip to content
Closed
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
11 changes: 11 additions & 0 deletions .changeset/hosted-webhook-ingress.md
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.
10 changes: 10 additions & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,16 @@
}
]
},
{
"group": "Webhooks",
"pages": [
"webhooks/overview",
"webhooks/sources",
"webhooks/connect",
"webhooks/deliveries",
"webhooks/filters"
]
},
{
"group": "Configuration",
"pages": [
Expand Down
35 changes: 35 additions & 0 deletions docs/webhooks/connect.mdx
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.
48 changes: 48 additions & 0 deletions docs/webhooks/deliveries.mdx
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.
Comment on lines +22 to +24

Copy link
Copy Markdown
Contributor

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-25 hashes 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 idempotencyField when the provider supplies one.

</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>
95 changes: 95 additions & 0 deletions docs/webhooks/filters.mdx
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 }) => {},
});
```
84 changes: 84 additions & 0 deletions docs/webhooks/overview.mdx
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>
Loading
Loading