Skip to content

chore: webhooks docs #7604

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
8 changes: 7 additions & 1 deletion apps/portal/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MessageCircleIcon } from "lucide-react";
import { MessageCircleIcon, WebhookIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { Grid, Heading, SDKCard } from "@/components/Document";
Expand Down Expand Up @@ -147,6 +147,12 @@ function ReferenceSection() {
iconClassName="text-muted-foreground"
title="Bundler"
/>
<SDKCard
href="/webhooks"
icon={WebhookIcon}
iconClassName="text-muted-foreground"
title="Webhooks"
/>
</Grid>
</section>
);
Expand Down
27 changes: 27 additions & 0 deletions apps/portal/src/app/webhooks/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { createMetadata } from "@/components/Document";
import { DocLayout } from "@/components/Layouts/DocLayout";
import { sidebar } from "./sidebar";
export default async function Layout(props: { children: React.ReactNode }) {
return (
<DocLayout
editPageButton={true}
sideBar={sidebar}
sidebarHeader={
<div className="flex-col items-center gap-1">
<p className="py-5 font-semibold text-foreground text-lg">Webhooks</p>
</div>
}
>
{props.children}
</DocLayout>
);
}

export const metadata = createMetadata({
description: "Receive real-time updates for onchain and offchain events.",
image: {
icon: "webhooks",
title: "Thirdweb Webhooks",
},
title: "thirdweb Webhooks",
});
160 changes: 160 additions & 0 deletions apps/portal/src/app/webhooks/page.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { createMetadata, Details } from "@/components/Document";
import { DocImage, FeatureCard, OpenSourceCard, Callout } from "@doc";
import { ArrowLeftRightIcon, UserLockIcon, UsersIcon, WalletIcon } from "lucide-react";


export const metadata = createMetadata({
title: "thirdweb Webhooks",
description: "Receive real-time updates for onchain and offchain events.",
});

# Webhooks

Webhooks are a way to receive real-time updates for onchain and offchain events.

An **event** is a real-time update or action that occured for your team or project.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a typo in this line: "occured" should be spelled "occurred" (with double 'r').

Suggested change
An **event** is a real-time update or action that occured for your team or project.
An **event** is a real-time update or action that occurred for your team or project.

Spotted by Diamond

Is this helpful? React 👍 or 👎 to let us know.


A **webhook** is a configured endpoint on your backend that receives events.

A **topic** is an identifier that groups events (example: `engine.transaction.sent`).
- The thirdweb product ("engine")
- The object ("engine.transaction")
- The event that occurred ("sent")

Each webhook can subscribe to multiple topics.

## Quickstart

1. Configure an HTTP endpoint on your backend to receive events.
1. Create a webhook in the thirdweb dashboard to subscribe to topics.
1. (Recommended) Verify the webhook signature to secure your endpoint.

## Manage your webhooks

Manage your webhooks from the thirdweb dashboard.

1. Select your team and project.
1. Select **Webhooks** in the left sidebar.

### Create a webhook

Select **Create Webhook** to create a new webhook.

Provide the following details:
- Description: A name for your webhook.
- Destination URL: The URL to send the webhook to. Only HTTPS URLs are supported.
- Topics: The thirdweb topics to subscribe this webhook to.
- Start Paused: Whether the webhook should immediately start receiving events.

### Update or delete a webhook

Find your webhook in the list.
- Select **... > Edit** to update your webhook details or subscribed topics.
- Select **... > Delete** to delete it.

### View analytics

Monitor your webhooks' requests over time to identify errors or latency issues.

## Retries

Webhook delivery attempts only consider a **2xx status code** returned within the **10-second timeout** as successful.

Otherwise the delivery will retry multiple times over the next 24 hours with exponential backoff.

### Automatic suspension of failing webhooks

Webhooks experiencing high error rates (non-2xx status codes) sustained over several hours will be paused automatically.
A paused webhook cannot receive any webhook events until it is manually resumed from the dashboard.

You will be notified via email and a dashboard notification when your webhook is paused.

## Handle webhook events

Your HTTP endpoint should handle webhook events and return a 200 status code quickly. Avoid slow or long-running synchronous operations, or move them to a queue in your backend.

### HTTP format

Webhooks are sent as a `POST` request to your configured endpoint.

**Headers**

- `content-type`: `application/json`
- `x-webhook-id`: A unique identifier for this webhook
- `x-webhook-signature`: HMAC-SHA256 signature
- See *Verify webhook signature* below
- `x-webhook-timestamp`: Timestamp of delivery attempt in Unix seconds
- See *Reject expired webhooks* below

**Request body**

```json
{
"id": "evt_cllcqqie908ii4q0ugld6noeu",
"type": "engine.transaction.sent",
"triggered_at": 1752471197,
"object": "engine.transaction",
"data": {
// ...engine.transaction fields
},
}
```

- `id`: A unique identifier for the event for this topic. Multiple delivery attempts for the same event will use the same ID.
- `type`: The topic that an event was triggered for.
- `triggered_at`: The timestamp the event was triggered in Unix seconds.
- Note: This value does not change for each delivery attempt, but the `x-webhook-timestamp` header does.
- `object`: The object that defines the shape of `data`.
- `data`: The object payload for the event.

### Secure your webhook endpoint

The `x-webhook-signature` header is a signature that hashes the raw request body and the delivery timestamp.
This signature ensures that neither the request body nor the timestamp were modified and can be trusted as sent from thirdweb.

Follow these steps to verify the webhook signature:
1. Concatenate `{TIMESTAMP_IN_UNIX_SECONDS}.{REQUEST_JSON_BODY}`.
1. Hash the result with the webhook secret using SHA256.
1. Compare the result with the `x-webhook-signature` header.

**Code examples**

<Details summary="Verify webhook signature in TypeScript">
```typescript
import { createHmac, timingSafeEqual } from "crypto";

const webhookSecret = "whsecret_..."; // Your webhook secret from the dashboard
const actualSignature = req.headers["x-webhook-signature"];
const timestamp = req.headers["x-webhook-timestamp"];
const body = "..." // raw HTTP body as string

// Generate the expected signature.
const expectedSignature = createHmac("sha256", webhookSecret)
.update(`${timestamp}.${body}`)
.digest("hex");

// Use `timingSafeEqual` to compare the signatures safely.
const expected = Buffer.from(expectedSignature, "hex");
const actual = Buffer.from(actualSignature, "hex");
const isValidSignature =
expected.length === actual.length &&
timingSafeEqual(expected, actual);

if (!isValidSignature) {
throw new Error("Invalid webhook signature");
}
```
</Details>

### Reject expired webhooks (optional)

You can reject webhook attempts that are received after a certain duration. This prevents requests from being replayed.

```typescript
const MAX_AGE_SECONDS = 10 * 60 * 1000; // 10 minutes
const timestamp = req.headers["x-webhook-timestamp"];
if (Date.now() / 1000 - timestamp > MAX_AGE_SECONDS) {
throw new Error("Webhook expired");
}
```
Comment on lines +154 to +159
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Unit mismatch – constant is milliseconds but compared against seconds

MAX_AGE_SECONDS multiplies by 1000 but is compared to values in seconds.

-const MAX_AGE_SECONDS = 10 * 60 * 1000; // 10 minutes
+const MAX_AGE_SECONDS = 10 * 60; // 10 minutes in seconds
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const MAX_AGE_SECONDS = 10 * 60 * 1000; // 10 minutes
const timestamp = req.headers["x-webhook-timestamp"];
if (Date.now() / 1000 - timestamp > MAX_AGE_SECONDS) {
throw new Error("Webhook expired");
}
```
const MAX_AGE_SECONDS = 10 * 60; // 10 minutes in seconds
const timestamp = req.headers["x-webhook-timestamp"];
if (Date.now() / 1000 - timestamp > MAX_AGE_SECONDS) {
throw new Error("Webhook expired");
}
🤖 Prompt for AI Agents
In apps/portal/src/app/webhooks/page.mdx around lines 154 to 159, the constant
MAX_AGE_SECONDS is incorrectly calculated in milliseconds (multiplied by 1000)
but compared against a timestamp difference in seconds. To fix this, either
rename the constant to reflect milliseconds and convert the timestamp difference
to milliseconds before comparison, or keep the constant in seconds by removing
the multiplication by 1000 so that both values are in the same unit for accurate
comparison.


13 changes: 13 additions & 0 deletions apps/portal/src/app/webhooks/sidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { WebhookIcon } from "lucide-react";
import type { SideBar } from "@/components/Layouts/DocLayout";

export const sidebar: SideBar = {
links: [
{
href: "/webhooks",
icon: <WebhookIcon />,
name: "Overview",
},
Comment on lines +8 to +10
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Pass the icon component, not the rendered element

DocLayout sidebars in this repo expect a React component (e.g. WebhookIcon) so they can uniformly size & theme it. Passing <WebhookIcon /> produces a pre-rendered node and breaks those controls.

-      icon: <WebhookIcon />,
+      icon: WebhookIcon,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
icon: <WebhookIcon />,
name: "Overview",
},
icon: WebhookIcon,
name: "Overview",
},
🤖 Prompt for AI Agents
In apps/portal/src/app/webhooks/sidebar.tsx around lines 8 to 10, the icon
property is assigned a rendered React element <WebhookIcon /> instead of the
component itself. Replace the icon value to pass the WebhookIcon component
directly without JSX syntax, i.e., use WebhookIcon instead of <WebhookIcon /> so
that DocLayout sidebars can properly size and theme the icon.

],
name: "Webhooks",
};
7 changes: 4 additions & 3 deletions apps/portal/src/components/Document/metadata.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import { getBaseUrl } from "../../lib/getBaseUrl";

const BAST_URL = getBaseUrl();
const BASE_URL = getBaseUrl();

type DynamicImageOptions = {
title: string;
Expand All @@ -22,7 +22,8 @@ type DynamicImageOptions = {
| "dotnet"
| "nebula"
| "unreal-engine"
| "insight";
| "insight"
| "webhooks";
};

export type MetadataImageIcon = DynamicImageOptions["icon"];
Expand All @@ -41,7 +42,7 @@ export function createMetadata(obj: {
? [
{
height: 630,
url: `${BAST_URL}/api/og?icon=${obj.image.icon}&title=${obj.image.title}`,
url: `${BASE_URL}/api/og?icon=${obj.image.icon}&title=${obj.image.title}`,
width: 1200,
Comment on lines 44 to 46
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Use URLSearchParams to escape user-supplied values

title comes straight from obj.image.title; if it ever contains &, ?, or #, the generated URL will break. Safer to build the query string with URLSearchParams.

- url: `${BASE_URL}/api/og?icon=${obj.image.icon}&title=${obj.image.title}`,
+ url: `${BASE_URL}/api/og?` +
+   new URLSearchParams({
+     icon: obj.image.icon,
+     title: obj.image.title,
+   }).toString(),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
height: 630,
url: `${BAST_URL}/api/og?icon=${obj.image.icon}&title=${obj.image.title}`,
url: `${BASE_URL}/api/og?icon=${obj.image.icon}&title=${obj.image.title}`,
width: 1200,
height: 630,
url: `${BASE_URL}/api/og?` +
new URLSearchParams({
icon: obj.image.icon,
title: obj.image.title,
}).toString(),
width: 1200,
🤖 Prompt for AI Agents
In apps/portal/src/components/Document/metadata.ts around lines 44 to 46, the
URL query string is constructed by directly embedding user-supplied values,
which can break the URL if special characters like &, ?, or # are present. To
fix this, replace the string interpolation with URLSearchParams to properly
encode the query parameters. Create a new URLSearchParams instance, append the
icon and title values from obj.image, and use its toString() method to build the
query string safely.

},
]
Expand Down
Loading