Skip to content

Commit d5dbac1

Browse files
authored
feat: webhook verification (#794)
1 parent e876d02 commit d5dbac1

6 files changed

Lines changed: 380 additions & 243 deletions

File tree

docs/developer-docs/6.x/navigation.tsx

Lines changed: 250 additions & 237 deletions
Large diffs are not rendered by default.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
AI Context: Webhook Verification (webhook-verification.mdx)
2+
3+
Source of Information:
4+
1. Webiny SDK source: packages/sdk/src/methods/webhooks/verifyWebhookPayload.ts
5+
2. Webiny API core abstractions: packages/api-core/src/features/webhooks/WebhookVerifyPayload/abstractions.ts
6+
3. Standard Webhooks specification: https://www.standardwebhooks.com
7+
4. standardwebhooks npm package (used internally by the SDK)
8+
9+
Key Documentation Decisions:
10+
1. Focused exclusively on the SDK's createVerifyWebhookPayload function — no DI container or feature registration
11+
2. Did not cover setting up a receiver endpoint (Express, Next.js, etc.) — the article is about the verification API itself
12+
3. Did not explain the Result pattern inline — linked to existing core-concepts/basic/result docs
13+
4. Did not cover the signing side (how Webiny signs outgoing payloads) — that is internal
14+
5. Mentioned the Node.js-only constraint prominently since the import path looks like it could be used anywhere
15+
6. Did not cover webhook configuration in the Admin UI — the article assumes webhooks are already set up
16+
7. Used process.env.WEBHOOK_SECRET as the idiomatic way to pass the secret — the SDK accepts any string
17+
18+
Understanding of Webhook Verification:
19+
- Webiny follows the Standard Webhooks spec for signing and verifying payloads
20+
- Three headers are sent with every delivery: webhook-id, webhook-timestamp, webhook-signature
21+
- The SDK function createVerifyWebhookPayload is a factory — call it once with the secret, then reuse the returned function per request
22+
- If the secret is undefined, the factory returns a function that throws immediately (fail-fast for misconfigured environments)
23+
- Verification uses HMAC-SHA256 under the hood (via standardwebhooks library)
24+
- The standardwebhooks library enforces a timestamp tolerance window to prevent replay attacks
25+
- The raw body must be passed — parsing to JSON before verification breaks the signature
26+
27+
Related Documents:
28+
- core-concepts/basic/result.mdx — the Result pattern used by the verification return type
29+
- headless-cms/event-handlers.mdx — shows webhook calls from the Webiny side (outgoing, not incoming verification)
30+
31+
Key Code Locations:
32+
- /webiny-js-v6/packages/sdk/src/methods/webhooks/verifyWebhookPayload.ts — the SDK implementation
33+
- /webiny-js-v6/packages/api-core/src/features/webhooks/WebhookVerifyPayload/abstractions.ts — the interface and header types
34+
- /webiny-js-v6/packages/webhooks/src/api/features/WebhookVerifyPayload/WebhookVerifyPayload.ts — the internal API implementation (not SDK)
35+
36+
Tone Guidelines:
37+
- Direct and practical — the reader wants to verify webhooks, not learn cryptography
38+
- Explain "why verify" briefly, then focus on "how to verify"
39+
- Keep code examples minimal and copy-paste ready
40+
- Trust the reader to know Result pattern basics — just link to the docs
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
---
2+
id: sbwmyo92
3+
title: Webhooks
4+
description: Verify incoming Webiny webhook payloads using the Webiny SDK to ensure authenticity and integrity.
5+
---
6+
7+
import { Alert } from "@/components/Alert";
8+
9+
<Alert type="success" title="WHAT YOU'LL LEARN">
10+
11+
- Why you should verify webhook payloads before processing them
12+
- Which headers Webiny sends with every webhook delivery
13+
- How to use `createVerifyWebhookPayload` from `@webiny/sdk` to verify a payload
14+
- How to handle verification failures
15+
16+
</Alert>
17+
18+
## Overview
19+
20+
When Webiny delivers a webhook, it signs the payload so that your receiving service can confirm two things: the request genuinely came from Webiny, and the payload was not tampered with in transit.
21+
22+
Webiny follows the [Standard Webhooks](https://www.standardwebhooks.com) specification. Every delivery includes a set of headers that carry a message ID, a timestamp, and a cryptographic signature. The `@webiny/sdk` package provides a `createVerifyWebhookPayload` function that checks these headers against the raw request body and your signing secret.
23+
24+
<Alert type="warning" title="Node.js only">
25+
26+
`@webiny/sdk/webhooks` relies on the `standardwebhooks` library, which is not compatible with browser environments. Only use it in server-side code.
27+
28+
</Alert>
29+
30+
## Webhook Headers
31+
32+
Webiny sends three headers with every webhook delivery:
33+
34+
| Header | Description |
35+
| ------------------- | ------------------------------------------------------------------------------------------------------- |
36+
| `webhook-id` | A unique identifier for the message. You can use this to detect duplicate deliveries. |
37+
| `webhook-timestamp` | A Unix timestamp (in seconds) of when the message was sent. |
38+
| `webhook-signature` | A Base64-encoded signature computed from the message ID, timestamp, and body using your signing secret. |
39+
40+
Your receiving endpoint must forward all three headers — along with the **raw request body** — to the verification function. Parsing the body as JSON before verification will invalidate the signature.
41+
42+
## Verifying a Payload
43+
44+
Start by creating a verification function with your signing secret:
45+
46+
```typescript
47+
import { createVerifyWebhookPayload } from "@webiny/sdk/webhooks";
48+
49+
const verifyWebhookPayload = createVerifyWebhookPayload(process.env.WEBHOOK_SECRET);
50+
```
51+
52+
`createVerifyWebhookPayload` accepts the signing secret and returns an async function you call for each incoming request. If the secret is `undefined`, the returned function throws immediately — this lets you catch misconfigured environments early.
53+
54+
Call the returned function with the raw body and the three webhook headers:
55+
56+
```typescript
57+
const result = await verifyWebhookPayload(rawBody, {
58+
"webhook-id": headers["webhook-id"],
59+
"webhook-timestamp": headers["webhook-timestamp"],
60+
"webhook-signature": headers["webhook-signature"]
61+
});
62+
```
63+
64+
The function returns a [`Result`](/docs/developer-docs/6.x/core-concepts/basic/result) — check it before processing the payload:
65+
66+
```typescript
67+
if (result.isFail()) {
68+
// Signature is invalid — reject the request.
69+
console.error("Webhook verification failed:", result.error);
70+
return;
71+
}
72+
73+
const payload = result.value;
74+
```
75+
76+
## Error Handling
77+
78+
Verification fails when:
79+
80+
- **The signature does not match** — the payload was tampered with or the wrong secret was used.
81+
- **The timestamp is too old** — the `standardwebhooks` library rejects messages outside a tolerance window to prevent replay attacks.
82+
- **A required header is missing** — one or more of the three webhook headers was not included in the request.
83+
84+
When verification fails, `result.isFail()` returns `true` and `result.error` contains the underlying error with a message describing the reason. Do not process the payload — return an appropriate HTTP error status to the caller.

generateDocs.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ process.env.NODE_PATH = process.cwd();
33
const tsNode = require("ts-node");
44
const { resolve } = require("path");
55
const yargs = require("yargs");
6+
const { hideBin } = require("yargs/helpers");
67
const rimraf = require("rimraf");
78

89
tsNode.register({
@@ -13,7 +14,7 @@ const { App, AppConfig } = require("@webiny/docs-generator");
1314
const { default: docsConfig } = require("./docs.config");
1415

1516
(async () => {
16-
const { watch, watchOnly, version } = yargs()
17+
const { watch, watchOnly, version } = yargs(hideBin(process.argv))
1718
.version(false)
1819
.option("v", {
1920
alias: "version",

generator/src/app/CompositeDocumentRootWatcher.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ export class CompositeDocumentRootWatcher implements IDocumentRootWatcher {
88
}
99

1010
async watch(onFile: OnFile, onEvent?: OnWatchEvent): Promise<void> {
11-
this.documentRootWatchers.forEach(documentRootWatcher =>
12-
documentRootWatcher.watch(onFile, onEvent)
13-
);
11+
await Promise.all(this.documentRootWatchers.map(w => w.watch(onFile, onEvent)));
1412
}
1513
}

generator/src/app/DocumentRootWatcher.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ export class DocumentRootWatcher implements IDocumentRootWatcher {
2222
.map(file => file.getSourcePath())
2323
.filter((value?: string): value is string => !!value)
2424
.map(path => this.enforceExtensionOrGlob(path));
25-
2625
const watcher = watch([...paths, ...this.additionalFiles]);
2726

2827
watcher
@@ -38,6 +37,8 @@ export class DocumentRootWatcher implements IDocumentRootWatcher {
3837
}
3938

4039
private enforceExtensionOrGlob(value: string) {
41-
return [".js", ".ts", ".tsx"].some(ext => value.endsWith(ext)) ? value : `${value}*`;
40+
return [".js", ".ts", ".tsx", ".mdx"].some(ext => value.endsWith(ext))
41+
? value
42+
: `${value}*`;
4243
}
4344
}

0 commit comments

Comments
 (0)