Skip to content

Commit 51b39fb

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(core,sdk): webhooks.hookdeck() source
Mono-RevId: 78396c87c04a8eacf3cc6adbc823effb2aa99e8d
1 parent 96e9557 commit 51b39fb

12 files changed

Lines changed: 330 additions & 41 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"@trigger.dev/sdk": patch
4+
---
5+
6+
Add `webhooks.hookdeck()`, a source for deliveries relayed through a Hookdeck connection. It verifies the `x-hookdeck-signature` header with the project's signing secret, keeps verifying while the secret is being rolled (Hookdeck sends the previous secret's signature in `x-hookdeck-signature-2`), and deduplicates retries on `x-hookdeck-eventid`. The event is the original provider's payload, so pass its type: `webhooks.hookdeck<Stripe.Event>()`. If the Hookdeck project's header prefix was changed, pass it as `webhooks.hookdeck({ headerPrefix })` so retries keep deduplicating.
Lines changed: 68 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,102 @@
11
---
22
title: "Trigger tasks from Hookdeck webhooks"
33
sidebarTitle: "Hookdeck webhooks"
4-
description: "This example demonstrates how to use Hookdeck to receive webhooks and trigger Trigger.dev tasks."
4+
description: "Forward webhooks from a Hookdeck connection to a hosted Trigger.dev endpoint, verified with the Hookdeck project's signing secret, and run a task for each event."
55
---
66

7-
## Overview
7+
[Hookdeck](https://hookdeck.com) receives webhooks from external services, then queues, logs and forwards them to a destination. Point a Hookdeck connection at a hosted webhook endpoint and Trigger.dev verifies each forwarded request with the Hookdeck project's signing secret, deduplicates Hookdeck's retries, and runs your task with the original provider's payload.
88

9-
This example shows how to use [Hookdeck](https://hookdeck.com) as your webhook infrastructure to trigger Trigger.dev tasks. Hookdeck receives webhooks from external services, and forwards them directly to the Trigger.dev API. This gives you the best of both worlds: Hookdeck's webhook management, logging, and replay capabilities, combined with Trigger.dev's reliable task execution.
9+
## Task code
10+
11+
Declare the endpoint with `webhooks.hookdeck()` and subscribe a task to it. Hookdeck forwards the original provider's body unchanged, so pass that provider's event type. This example relays Stripe events:
12+
13+
```ts /trigger/stripe-via-hookdeck.ts
14+
import type Stripe from "stripe";
15+
import { webhook, webhooks } from "@trigger.dev/sdk";
16+
17+
export const stripeViaHookdeck = webhooks.endpoint.define({
18+
id: "stripe-via-hookdeck",
19+
source: webhooks.hookdeck<Stripe.Event>(),
20+
});
21+
22+
export const paymentSucceeded = webhook({
23+
id: "payment-succeeded",
24+
endpoint: stripeViaHookdeck,
25+
filter: "event.type == 'payment_intent.succeeded'",
26+
onEvent: async ({ event }) => {
27+
console.log("Payment succeeded", event.id);
28+
},
29+
});
30+
```
1031

11-
## Key features
32+
Run `trigger dev` or deploy, then copy the endpoint's URL from the Webhooks page in the dashboard.
1233

13-
- Use Hookdeck as your webhook endpoint for external services
14-
- Hookdeck forwards webhooks directly to Trigger.dev tasks via the API
15-
- All webhooks are logged and replayable in Hookdeck
34+
## Set up Hookdeck
1635

17-
## Setting up Hookdeck
36+
<Steps>
37+
<Step title="Create a connection">
38+
In the [Hookdeck dashboard](https://dashboard.hookdeck.com), create a [connection](https://hookdeck.com/docs/connections) from your source (for example a Stripe source) to an HTTP destination whose URL is the endpoint's URL. Keep the destination's authentication on **Hookdeck Signature**, the default for new destinations: Hookdeck only signs forwarded requests with it set.
39+
</Step>
40+
<Step title="Store the signing secret">
41+
Copy the signing secret from **Settings > Secrets** in Hookdeck and set it as the endpoint's signing secret in the Trigger.dev dashboard. It's one secret for the whole Hookdeck project.
42+
</Step>
43+
</Steps>
1844

19-
You'll configure everything in the [Hookdeck dashboard](https://dashboard.hookdeck.com). No code changes needed in your app.
45+
## Test it
2046

21-
### 1. Create a destination
47+
Send a webhook to your Hookdeck source URL, or trigger a real event in the upstream provider. In Hookdeck, the event shows a `200` response from the destination. In Trigger.dev, the endpoint shows a succeeded delivery and the `payment-succeeded` task has a completed run with the Stripe event as its payload.
2248

23-
In Hookdeck, create a new [destination](https://hookdeck.com/docs/destinations) with the following settings:
49+
A request signed with a different secret gets a `400`, which Hookdeck reports as a failed attempt.
2450

25-
- **URL**: `https://api.trigger.dev/api/v1/tasks/<task-id>/trigger` (replace `<task-id>` with your task ID)
26-
- **Method**: POST
27-
- **Authentication**: Bearer token (create a named **Trigger only** environment API key and use it as `TRIGGER_SECRET_KEY`)
51+
## Rotate the signing secret
2852

29-
### 2. Add a transformation
53+
Roll the signing secret under **Settings > Secrets** in Hookdeck with an expiry instead of expiring it immediately. Until the old secret expires, Hookdeck signs each request with both secrets, sending the previous secret's signature in `X-Hookdeck-Signature-2`. The endpoint accepts either, so deliveries keep verifying while you copy the new secret to Trigger.dev. Set a grace period when you store it to keep accepting the old secret for a while.
3054

31-
Create a [transformation](https://hookdeck.com/docs/transformations) to wrap the webhook body in the `payload` field that Trigger.dev expects:
55+
## Deduplication
3256

33-
```javascript
34-
addHandler("transform", (request, context) => {
35-
request.body = { payload: { ...request.body } };
36-
return request;
57+
Hookdeck retries and manual replays of the same event carry the same `X-Hookdeck-Eventid`, and the endpoint uses it as the [deduplication](/webhooks/deliveries#deduplication) key, so a retried event runs your task once.
58+
59+
If you changed the header prefix in the Hookdeck project's settings, the event id arrives under the new prefix. Pass the prefix so deduplication keeps working:
60+
61+
```ts /trigger/stripe-via-hookdeck.ts
62+
import type Stripe from "stripe";
63+
import { webhooks } from "@trigger.dev/sdk";
64+
65+
export const stripeViaHookdeck = webhooks.endpoint.define({
66+
id: "stripe-via-hookdeck",
67+
source: webhooks.hookdeck<Stripe.Event>({ headerPrefix: "x-acme" }),
3768
});
3869
```
3970

40-
### 3. Create a connection
71+
The signature headers keep the `X-Hookdeck-` name whatever the prefix.
4172

42-
Create a [connection](https://hookdeck.com/docs/connections) that links your source (where webhooks come from) to the destination and transformation you created above.
73+
## Trigger a task through the API instead
4374

44-
## Task code
75+
You can also point a Hookdeck destination at the [trigger task API](/management/tasks/trigger) without a hosted endpoint. Hookdeck authenticates with your API key instead of a signature, and a transformation wraps the body in the `payload` field the API expects.
76+
77+
1. Create a destination with the URL `https://api.trigger.dev/api/v1/tasks/<task-id>/trigger`, method `POST`, and **Bearer token** authentication using a **Trigger only** environment API key.
78+
2. Add a [transformation](https://hookdeck.com/docs/transformations):
4579

46-
This task will be triggered when Hookdeck forwards a webhook to the Trigger.dev API.
80+
```javascript
81+
addHandler("transform", (request, context) => {
82+
request.body = { payload: { ...request.body } };
83+
return request;
84+
});
85+
```
4786

48-
```ts trigger/webhook-handler.ts
87+
3. Create a connection from your source to that destination and transformation.
88+
89+
```ts /trigger/webhook-handler.ts
4990
import { task } from "@trigger.dev/sdk";
5091

5192
export const webhookHandler = task({
5293
id: "webhook-handler",
5394
run: async (payload: Record<string, unknown>) => {
54-
// The payload contains the original webhook data from the external service
5595
console.log("Received webhook:", payload);
56-
57-
// Add your custom logic here
5896
},
5997
});
6098
```
6199

62-
## Testing your setup
63-
64-
To test everything is working:
65-
66-
1. Set up your destination, transformation, and connection in [Hookdeck](https://dashboard.hookdeck.com)
67-
2. Send a test webhook to your Hookdeck source URL (use the Hookdeck Console or cURL)
68-
3. Check the Hookdeck dashboard to verify the webhook was received and forwarded
69-
4. Check the [Trigger.dev dashboard](https://cloud.trigger.dev) to see the successful run of your task
100+
This path has no deduplication of Hookdeck retries; each forwarded request triggers a run.
70101

71-
For more information on setting up Hookdeck, refer to the [Hookdeck Documentation](https://hookdeck.com/docs).
102+
For more on Hookdeck, see the [Hookdeck documentation](https://hookdeck.com/docs).

‎docs/webhooks/deliveries.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ When the source maps a provider event ID, that ID becomes the key, so the provid
8585

8686
| Key | Sources |
8787
| --- | --- |
88-
| A header | GitHub (`X-GitHub-Delivery`), Svix and Autumn (`svix-id`), Standard Webhooks, Loops and Granola (`webhook-id`), Linear (`Linear-Delivery`), PagerDuty (`X-Webhook-Id`), Jira (`X-Atlassian-Webhook-Identifier`), Zendesk (`X-Zendesk-Webhook-Invocation-Id`), Attio (`Idempotency-Key`), Shopify (`X-Shopify-Webhook-Id`) |
88+
| A header | GitHub (`X-GitHub-Delivery`), Svix and Autumn (`svix-id`), Standard Webhooks, Loops and Granola (`webhook-id`), Linear (`Linear-Delivery`), PagerDuty (`X-Webhook-Id`), Jira (`X-Atlassian-Webhook-Identifier`), Zendesk (`X-Zendesk-Webhook-Invocation-Id`), Attio (`Idempotency-Key`), Shopify (`X-Shopify-Webhook-Id`), Hookdeck (`X-Hookdeck-Eventid`, or `<prefix>-eventid` with a custom header prefix) |
8989
| A body field | Stripe, Notion, Vercel, Intercom, WorkOS, Metronome, Mux, Langfuse and Plain (`id`), Square, Slack and Typeform (`event_id`), Firecrawl (`webhookId`) |
9090

9191
The other named sources (Zoom, Calendly, Sentry, DocuSign, ElevenLabs, Vapi, Retell, Tailscale, Infisical, PlanetScale, Supabase, Discord) use the fallback below.

‎docs/webhooks/sources.mdx‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ Each function below returns a source with its provider's verification configurat
9797
| HMAC-SHA256 with `X-Hub-Signature-256` | `whatsapp()` |
9898
| Shared secret in `X-Gitlab-Token` | `gitlab()` |
9999
| Shared secret as `Authorization: Bearer` | `supabase()` |
100+
| Hookdeck relay (HMAC-SHA256 with `X-Hookdeck-Signature`) | `hookdeck()` |
100101
| Provider-specific HMAC | `calendly()`, `calCom()`, `sentry()`, `pagerduty()`, `vercel()`, `linear()`, `notion()`, `typeform()`, `docusign()`, `jira()`, `intercom()`, `zendesk()`, `attio()`, `workos()`, `elevenlabs()`, `vapi()`, `retell()`, `shopify()`, `metronome()`, `mux()`, `tailscale()`, `langfuse()`, `infisical()`, `planetscale()`, `plain()`, `firecrawl()` |
101102

102103
Provider IDs containing a hyphen use a camel case function name: the `cal-com` provider is `webhooks.calCom()`.
@@ -105,6 +106,35 @@ Supabase database webhooks aren't signed, so `supabase()` checks a shared secret
105106

106107
Slack also ships as a chat channel in `@trigger.dev/slack`.
107108

109+
### Hookdeck
110+
111+
`hookdeck()` verifies requests that a [Hookdeck](https://hookdeck.com) connection forwards to the endpoint. Hookdeck forwards the original provider's body unchanged, so pass that provider's event type:
112+
113+
```ts /trigger/stripe-via-hookdeck.ts
114+
import type Stripe from "stripe";
115+
import { webhook, webhooks } from "@trigger.dev/sdk";
116+
117+
export const stripeViaHookdeck = webhooks.endpoint.define({
118+
id: "stripe-via-hookdeck",
119+
source: webhooks.hookdeck<Stripe.Event>(),
120+
});
121+
122+
export const paymentSucceeded = webhook({
123+
id: "payment-succeeded",
124+
endpoint: stripeViaHookdeck,
125+
filter: "event.type == 'payment_intent.succeeded'",
126+
onEvent: async ({ event }) => {
127+
console.log(event.id);
128+
},
129+
});
130+
```
131+
132+
Use the endpoint URL as the connection's HTTP destination, keep the destination's authentication on Hookdeck Signature (the default for new destinations), and store the Hookdeck project's signing secret from **Settings > Secrets** as the endpoint's signing secret.
133+
134+
- **Secret rotation:** when you roll the signing secret in Hookdeck with an expiry, Hookdeck also signs each request with the previous secret in `X-Hookdeck-Signature-2`, so deliveries keep verifying until you store the new secret.
135+
- **Deduplication:** retries dedupe on `X-Hookdeck-Eventid`, which is the same on every attempt of a Hookdeck event.
136+
- **Custom header prefix:** if you changed the header prefix in the Hookdeck project's settings, pass it with `webhooks.hookdeck({ headerPrefix: "x-acme" })`. The prefix renames the event id header; the signature headers keep the `X-Hookdeck-` name.
137+
108138
## Endpoint validation
109139

110140
Some sources also define a response for the provider's endpoint-validation request. These requests don't create deliveries or task runs.

‎internal-packages/webhook-sources/catalog/providers.json‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1119,6 +1119,27 @@
11191119
"producer": true
11201120
},
11211121
"notes": ""
1122+
},
1123+
{
1124+
"id": "hookdeck",
1125+
"label": "Hookdeck",
1126+
"category": "hosting-infra",
1127+
"tier": "first-class",
1128+
"preset": null,
1129+
"signatureScheme": "hmac-sha256",
1130+
"aiRelevance": "medium",
1131+
"popularity": "medium",
1132+
"sampleSource": "handauthored",
1133+
"docsUrl": "https://hookdeck.com/docs/authentication",
1134+
"status": "complete",
1135+
"owner": "hookdeck-provider",
1136+
"checklist": {
1137+
"registryEntry": true,
1138+
"samples": true,
1139+
"roundTrip": true,
1140+
"producer": true
1141+
},
1142+
"notes": "relay: body is the upstream provider's payload; x-hookdeck-signature-2 carries the previous secret while a rotation is in progress"
11221143
}
11231144
]
11241145
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { type SampleRecord } from "../sampleRecord.js";
2+
3+
/**
4+
* Hookdeck samples. Hookdeck forwards the original provider's body unchanged and adds its own
5+
* headers, so each sample is an upstream payload plus informational `x-hookdeck-*` headers. The
6+
* signature headers are added by the composer at send time. `x-hookdeck-eventid` is left out: it is
7+
* the dedupe key, so a fixed value would fold every console send of a sample into one delivery.
8+
*/
9+
export const samples: SampleRecord[] = [
10+
{
11+
provider: "hookdeck",
12+
providerLabel: "Hookdeck",
13+
eventType: "payment_intent.succeeded",
14+
name: "Stripe payment relayed",
15+
description: "A Stripe `payment_intent.succeeded` event forwarded by a Hookdeck connection.",
16+
body: {
17+
id: "evt_3QhRb2LkdIwHu7ix0Wq1X9aB",
18+
object: "event",
19+
type: "payment_intent.succeeded",
20+
created: 1792569600,
21+
livemode: false,
22+
data: {
23+
object: {
24+
id: "pi_3QhRb2LkdIwHu7ix0kT2mN4p",
25+
object: "payment_intent",
26+
amount: 4900,
27+
currency: "usd",
28+
customer: "cus_R8xQ2mLkd91Hu7",
29+
status: "succeeded",
30+
},
31+
},
32+
},
33+
extraHeaders: {
34+
"x-hookdeck-source-name": "stripe-production",
35+
"x-hookdeck-attempt-count": "1",
36+
"x-hookdeck-attempt-trigger": "INITIAL",
37+
},
38+
docsUrl: "https://hookdeck.com/docs/destinations",
39+
provenance: { kind: "handauthored", snapshotDate: "2026-10" },
40+
},
41+
{
42+
provider: "hookdeck",
43+
providerLabel: "Hookdeck",
44+
eventType: "order.created",
45+
name: "Order created relayed",
46+
description:
47+
"A custom app's `order.created` event forwarded by a Hookdeck connection, on a manual retry.",
48+
body: {
49+
type: "order.created",
50+
id: "ord_7Kp2Qw9Lm3",
51+
created_at: "2026-10-21T09:00:00.000Z",
52+
data: {
53+
total: 129.5,
54+
currency: "EUR",
55+
customer: { id: "cust_48Fh2", email: "ada@example.com" },
56+
items: [{ sku: "TEE-BLK-M", quantity: 2 }],
57+
},
58+
},
59+
extraHeaders: {
60+
"x-hookdeck-source-name": "shop-orders",
61+
"x-hookdeck-attempt-count": "2",
62+
"x-hookdeck-attempt-trigger": "MANUAL",
63+
},
64+
docsUrl: "https://hookdeck.com/docs/destinations",
65+
provenance: { kind: "handauthored", snapshotDate: "2026-10" },
66+
},
67+
];

‎internal-packages/webhook-sources/src/handAuthored/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { samples as s_elevenlabs } from "./elevenlabs.js";
1717
import { samples as s_firecrawl } from "./firecrawl.js";
1818
import { samples as s_gitlab } from "./gitlab.js";
1919
import { samples as s_granola } from "./granola.js";
20+
import { samples as s_hookdeck } from "./hookdeck.js";
2021
import { samples as s_infisical } from "./infisical.js";
2122
import { samples as s_intercom } from "./intercom.js";
2223
import { samples as s_jira } from "./jira.js";
@@ -70,6 +71,7 @@ export const handAuthoredSamples: SampleRecord[] = [
7071
...s_firecrawl,
7172
...s_gitlab,
7273
...s_granola,
74+
...s_hookdeck,
7375
...s_infisical,
7476
...s_intercom,
7577
...s_jira,

‎internal-packages/webhook-sources/src/providerSignatures.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,30 @@ const fixtures: Fixture[] = [
196196
nowMs: NOW_MS,
197197
toleranceSeconds: 300,
198198
},
199+
{
200+
provider: "hookdeck",
201+
source: "https://hookdeck.com/docs/authentication",
202+
body: '{"id":"evt_hookdeck_fixture","type":"payment_intent.succeeded","note":"Zoë"}',
203+
secret: "hookdeck_fixture_secret",
204+
headers: {
205+
"x-hookdeck-signature": "3gqjNEFqbs5p4dNxA9fIzpEkE+YKzHRHZpA1gc9Z6QM=",
206+
"x-hookdeck-eventid": "evt_hookdeck_fixture_delivery",
207+
},
208+
nowMs: NOW_MS,
209+
},
210+
{
211+
provider: "hookdeck",
212+
label: "hookdeck (secret rolled with a delay: old secret in x-hookdeck-signature-2)",
213+
source: "https://hookdeck.com/docs/authentication",
214+
body: '{"id":"evt_hookdeck_fixture","type":"payment_intent.succeeded","note":"Zoë"}',
215+
secret: "hookdeck_fixture_secret",
216+
headers: {
217+
"x-hookdeck-signature": "MRtBJRv3K6j8+CUcDsJuwYudgKi52qz9yfISiM3FKCw=",
218+
"x-hookdeck-signature-2": "3gqjNEFqbs5p4dNxA9fIzpEkE+YKzHRHZpA1gc9Z6QM=",
219+
"x-hookdeck-eventid": "evt_hookdeck_fixture_delivery",
220+
},
221+
nowMs: NOW_MS,
222+
},
199223
{
200224
provider: "supabase",
201225
source: "https://supabase.com/docs/guides/database/webhooks",
@@ -250,3 +274,37 @@ describe("provider signature compatibility", () => {
250274
});
251275
}
252276
});
277+
278+
describe("hookdeck headerPrefix", () => {
279+
const body = '{"id":"evt_hookdeck_fixture","type":"payment_intent.succeeded","note":"Zoë"}';
280+
const run = (headerPrefix: string | undefined, headers: Record<string, string>) =>
281+
verify(
282+
{ kind: "config", config: webhookProviderConfigs.hookdeck.config({ headerPrefix }) },
283+
{
284+
rawBytes: new TextEncoder().encode(body),
285+
secret: "hookdeck_fixture_secret",
286+
nowMs: NOW_MS,
287+
headers,
288+
url: "https://example.com/webhooks/v1/ingest/hookdeck_fixture",
289+
}
290+
);
291+
const prefixed = {
292+
"x-hookdeck-signature": "MRtBJRv3K6j8+CUcDsJuwYudgKi52qz9yfISiM3FKCw=",
293+
"x-hookdeck-signature-2": "3gqjNEFqbs5p4dNxA9fIzpEkE+YKzHRHZpA1gc9Z6QM=",
294+
"x-acme-eventid": "evt_acme_delivery",
295+
"x-acme-requestid": "req_acme",
296+
};
297+
298+
it("keeps verifying the x-hookdeck signature headers and dedupes on <prefix>-eventid", () => {
299+
expect(run("X-Acme-", prefixed)).toMatchObject({
300+
ok: true,
301+
idempotencyKey: "evt_acme_delivery",
302+
});
303+
});
304+
305+
it("without the option, still verifies but cannot read the renamed event id", () => {
306+
const verdict = run(undefined, prefixed);
307+
expect(verdict.ok).toBe(true);
308+
expect(verdict.idempotencyKey).not.toBe("evt_acme_delivery");
309+
});
310+
});

0 commit comments

Comments
 (0)