diff --git a/apps/portal/src/app/page.tsx b/apps/portal/src/app/page.tsx index 0d03b246ce2..fbc513a24e3 100644 --- a/apps/portal/src/app/page.tsx +++ b/apps/portal/src/app/page.tsx @@ -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"; @@ -147,6 +147,12 @@ function ReferenceSection() { iconClassName="text-muted-foreground" title="Bundler" /> + ); diff --git a/apps/portal/src/app/webhooks/layout.tsx b/apps/portal/src/app/webhooks/layout.tsx new file mode 100644 index 00000000000..420eadec62d --- /dev/null +++ b/apps/portal/src/app/webhooks/layout.tsx @@ -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 ( + +

Webhooks

+ + } + > + {props.children} +
+ ); +} + +export const metadata = createMetadata({ + description: "Receive real-time updates for onchain and offchain events.", + image: { + icon: "webhooks", + title: "Thirdweb Webhooks", + }, + title: "thirdweb Webhooks", +}); diff --git a/apps/portal/src/app/webhooks/page.mdx b/apps/portal/src/app/webhooks/page.mdx new file mode 100644 index 00000000000..1060567515d --- /dev/null +++ b/apps/portal/src/app/webhooks/page.mdx @@ -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. + +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** + +
+```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"); +} +``` +
+ +### 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"); +} +``` + diff --git a/apps/portal/src/app/webhooks/sidebar.tsx b/apps/portal/src/app/webhooks/sidebar.tsx new file mode 100644 index 00000000000..56bcdb2a51b --- /dev/null +++ b/apps/portal/src/app/webhooks/sidebar.tsx @@ -0,0 +1,13 @@ +import { WebhookIcon } from "lucide-react"; +import type { SideBar } from "@/components/Layouts/DocLayout"; + +export const sidebar: SideBar = { + links: [ + { + href: "/webhooks", + icon: , + name: "Overview", + }, + ], + name: "Webhooks", +}; diff --git a/apps/portal/src/components/Document/metadata.ts b/apps/portal/src/components/Document/metadata.ts index a9de8f463e5..1550d450eaf 100644 --- a/apps/portal/src/components/Document/metadata.ts +++ b/apps/portal/src/components/Document/metadata.ts @@ -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; @@ -22,7 +22,8 @@ type DynamicImageOptions = { | "dotnet" | "nebula" | "unreal-engine" - | "insight"; + | "insight" + | "webhooks"; }; export type MetadataImageIcon = DynamicImageOptions["icon"]; @@ -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, }, ]