diff --git a/apps/portal/src/app/insight/sidebar.tsx b/apps/portal/src/app/insight/sidebar.tsx
index 2450a52c82c..8d35542d2b8 100644
--- a/apps/portal/src/app/insight/sidebar.tsx
+++ b/apps/portal/src/app/insight/sidebar.tsx
@@ -9,6 +9,7 @@ import {
Network,
Rocket,
StickyNote,
+ Webhook,
Wrench,
} from "lucide-react";
@@ -58,6 +59,33 @@ export const sidebar: SideBar = {
},
],
},
+ {
+ name: "Webhooks",
+ href: `${insightSlug}/webhooks`,
+ icon: ,
+ links: [
+ {
+ name: "Getting Started",
+ href: `${insightSlug}/webhooks`,
+ },
+ {
+ name: "Managing Webhooks",
+ href: `${insightSlug}/webhooks/managing-webhooks`,
+ },
+ {
+ name: "Filtering",
+ href: `${insightSlug}/webhooks/filtering`,
+ },
+ {
+ name: "Payload",
+ href: `${insightSlug}/webhooks/payload`,
+ },
+ {
+ name: "API Reference",
+ href: "https://insight-api.thirdweb.com/reference#tag/webhooks",
+ },
+ ],
+ },
{
name: "API Reference",
href: "https://insight-api.thirdweb.com/reference",
diff --git a/apps/portal/src/app/insight/webhooks/filtering/page.mdx b/apps/portal/src/app/insight/webhooks/filtering/page.mdx
new file mode 100644
index 00000000000..ec9d6ec5dbb
--- /dev/null
+++ b/apps/portal/src/app/insight/webhooks/filtering/page.mdx
@@ -0,0 +1,112 @@
+import { createMetadata } from "@doc";
+
+export const metadata = createMetadata({
+ title: "Insight Webhooks | thirdweb Infrastructure",
+ description: "Filtering",
+ image: {
+ title: "Insight",
+ icon: "insight",
+ },
+});
+
+# Filtering
+
+## Webhook Topics
+
+### `v1.events`
+
+Subscribes to blockchain log events.
+
+### `v1.transactions`
+
+Subscribes to blockchain transactions.
+
+## Webhook Filters
+
+You can filter webhook notifications based on specific criteria.
+Each webhook must have either an events filter or a transactions filter (or both).
+
+### Event Filters
+```typescript
+{
+ "v1.events": {
+ chain_ids: string[], // Filter by specific chains
+ addresses: string[], // Filter by contract addresses
+ signatures: { // Filter by event signatures
+ sig_hash: string, // Event signature hash
+ abi?: string, // Optional ABI for data decoding
+ params?: Record // Filter on decoded parameters
+ }[]
+ }
+}
+```
+
+### Transaction Filters
+```typescript
+{
+ "v1.transactions": {
+ chain_ids: string[], // Filter by specific chains
+ from_addresses: string[], // Filter by sender addresses
+ to_addresses: string[], // Filter by recipient addresses
+ signatures: { // Filter by function signatures
+ sig_hash: string, // Function signature hash
+ abi?: string, // Optional ABI for data decoding
+ params?: string[] // Filter on decoded parameters
+ }[]
+ }
+}
+```
+
+### ABIs
+
+You can specify partial ABIs to have decoded data sent in the webhook payload. For this you also need to give the `sig_hash` of the event or function call.
+
+The following example will filter for `Transfer` events on Ethereum for the contract `0x1f9840a85d5af5bf1d1762f925bdaddc4201f984`
+```typescript
+{
+ ...
+ filters: {
+ "v1.events": {
+ chain_ids: ["1"],
+ addresses: ["0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"],
+ signatures: [
+ {
+ sig_hash:
+ "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
+ abi: '{"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount","type":"uint256"}],"name":"Transfer","type":"event"}',
+ },
+ ],
+ },
+ },
+ ...
+}
+```
+
+And this example will filter for `Approve` function calls on Ethereum for the contract `0x1f9840a85d5af5bf1d1762f925bdaddc4201f984`
+```typescript
+{
+ ...
+ filters: {
+ "v1.transactions": {
+ chain_ids: ["1"],
+ addresses: ["0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"],
+ signatures: [
+ {
+ sig_hash: "0x095ea7b3",
+ abi: '{"inputs":[{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"rawAmount","type":"uint256"}],"name":"approve","type":"function"}',
+ },
+ ],
+ },
+ },
+ ...
+}
+```
+
+### Params
+
+`params` on the `signatures` object will allow you to filter based on the decoded data. Only strict equality is supported at the moment.
+
+### Notes
+- You can specify ABIs to receive decoded event/transaction data
+- Parameter filtering supports equality matching only
+- At least one filter criteria must be specified
diff --git a/apps/portal/src/app/insight/webhooks/managing-webhooks/page.mdx b/apps/portal/src/app/insight/webhooks/managing-webhooks/page.mdx
new file mode 100644
index 00000000000..c905d0ea513
--- /dev/null
+++ b/apps/portal/src/app/insight/webhooks/managing-webhooks/page.mdx
@@ -0,0 +1,68 @@
+import { createMetadata } from "@doc";
+
+export const metadata = createMetadata({
+ title: "Insight Webhooks | thirdweb Infrastructure",
+ description: "Managing Insight webhooks",
+ image: {
+ title: "Insight",
+ icon: "insight",
+ },
+});
+
+# Managing Webhooks
+
+For most up to date technical spec refer to https://insight-api.thirdweb.com/reference#tag/webhooks.
+
+## List Webhooks
+`GET /v1/webhooks`
+
+Retrieve all webhooks for your project or get details for a specific webhook by ID.
+
+## Create Webhook
+`POST /v1/webhooks`
+
+Creates a new webhook subscription.
+It may take up to a minute to start working.
+
+### Verifying the created webhook
+- The webhook starts in a suspended state
+- An OTP (One-Time Password) is sent to your webhook URL for verification
+- You must verify the webhook within 15 minutes using the OTP
+- Once verified, it may take up to a minute for the webhook to become fully active
+- You can request a new OTP if you can't retrieve the initial OTP
+
+## Update Webhook
+`PATCH /v1/webhooks/:webhook_id`
+
+You can modify the URL or filters of a webhook. Additionally you can enable and disable the webhook.
+Changes may take up to a minute to take effect.
+
+### Updating webhook URL
+- If you update the webhook URL, you'll need to verify it again with a new OTP
+- Other fields can be updated without requiring re-verification
+
+## Delete Webhook
+`DELETE /v1/webhooks/:webhook_id`
+
+Permanently removes a webhook subscription. This action cannot be undone.
+
+## Verify Webhook
+`POST /v1/webhooks/:webhook_id/verify`
+
+Activates a webhook using the OTP code that was sent to your webhook URL. Required for:
+- New webhook creation
+- After updating a webhook URL
+
+## Resend OTP
+`POST /v1/webhooks/:webhook_id/resend-otp`
+
+Request a new OTP code if the original expires or is lost:
+- Invalidates the previous OTP
+- New OTP expires after 15 minutes
+- Only works for webhooks pending verification
+
+## Test Webhook
+`POST /v1/webhooks/test`
+
+Sends a sample payload signed with a test secret ('test123').
+Useful for testing your receiver endpoint before creating actual webhooks.
diff --git a/apps/portal/src/app/insight/webhooks/page.mdx b/apps/portal/src/app/insight/webhooks/page.mdx
new file mode 100644
index 00000000000..555a757489b
--- /dev/null
+++ b/apps/portal/src/app/insight/webhooks/page.mdx
@@ -0,0 +1,33 @@
+import { createMetadata } from "@doc";
+
+export const metadata = createMetadata({
+ title: "Insight Webhooks | thirdweb Infrastructure",
+ description: "Getting started with Insight webhooks",
+ image: {
+ title: "Insight",
+ icon: "insight",
+ },
+});
+
+# Webhooks
+
+Webhooks allow you to receive notifications when specific blockchain events or transactions occur. This enables you to automate workflows and keep your applications in sync with on-chain activity.
+
+## About Webhooks
+
+### Data Delivery
+Webhook events are collected and delivered in batches for optimal performance and reliability. This makes webhooks ideal for:
+- Tracking when specific blockchain events occur
+- Aggregating on-chain data
+- Building analytics and monitoring systems
+- Triggering downstream processes
+
+### Delivery Guarantees
+Events are guaranteed to be delivered *at least once*.
+Your application should implement idempotency and deduplication logic using the unique event ID in the payload.
+Events may occasionally be delivered out of order.
+
+### Performance Requirements
+- Your receiving endpoint must respond within 3 seconds
+- If your endpoint consistently fails to respond in time, the webhook will be automatically suspended
+- We recommend implementing a queue system if you need to perform time-consuming operations
diff --git a/apps/portal/src/app/insight/webhooks/payload/page.mdx b/apps/portal/src/app/insight/webhooks/payload/page.mdx
new file mode 100644
index 00000000000..6dcc187d2bc
--- /dev/null
+++ b/apps/portal/src/app/insight/webhooks/payload/page.mdx
@@ -0,0 +1,202 @@
+import { createMetadata } from "@doc";
+import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
+
+export const metadata = createMetadata({
+ title: "Insight Webhooks | thirdweb Infrastructure",
+ description: "Payload",
+ image: {
+ title: "Insight",
+ icon: "insight",
+ },
+});
+
+# Webhook Payload
+
+## Payload Format
+```typescript
+{
+ topic: string, // topic of the data
+ timestamp: string, // timestamp of when the payload was sent in seconds
+ data: [
+ {
+ data: object, // data of the event or transaction
+ status: "new" | "reverted",
+ type: "event" | "transaction",
+ id: string // unique id of the data
+ }
+ ]
+}
+```
+
+Example Response:
+
+
+
+
+ Events
+ Transactions
+
+
+
+```json
+{
+ "timestamp": 1743164112,
+ "topic": "v1.events",
+ "data": [
+ {
+ "data": {
+ "chain_id": "1",
+ "block_number": 22140383,
+ "block_hash": "0x85bd4045d947c34b6b568fc84c7550c0efa741f71c834bbb3d3950e9da27842e",
+ "block_timestamp": 1743104207,
+ "transaction_hash": "0x460ced3718d0e09145eae8b63fd985dc366adfc4523c791116f24dc051e8363a",
+ "transaction_index": 93,
+ "log_index": 230,
+ "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984",
+ "data": "0x000000000000000000000000000000000000000000000000009440c61e928cca",
+ "topics": [
+ "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
+ "0x000000000000000000000000d360131b77ead72f1f23fb185b4896fe01dc8cb5",
+ "0x00000000000000000000000085cd07ea01423b1e937929b44e4ad8c40bbb5e71"
+ ],
+ "decoded": {
+ "name": "Transfer",
+ "indexed_params": {
+ "from": "0xd360131b77ead72f1f23fb185b4896fe01dc8cb5",
+ "to": "0x85cd07ea01423b1e937929b44e4ad8c40bbb5e71"
+ },
+ "non_indexed_params": {
+ "amount": "41729516213800138"
+ }
+ }
+ },
+ "status": "new",
+ "type": "event",
+ "id": "76b81da4ec46486f0a6e325596f506cad13ebd629520aded104efcaa37a419d5"
+ }
+ ]
+}
+```
+
+
+
+```json
+{
+ "timestamp": 1743165030,
+ "topic": "v1.transactions",
+ "data": [
+ {
+ "data": {
+ "chain_id": "1",
+ "hash": "0xceba210be88785cac0884127cb42d370bcde8248e11e490af0deeb6ded2bb421",
+ "nonce": 5,
+ "block_hash": "0x86cfe1e32fb3e5091bc2533b78d1503a4539936f50ff5a33b09df639f1572fdd",
+ "block_number": 22145183,
+ "block_timestamp": 1743162179,
+ "transaction_index": 62,
+ "from_address": "0xcd1785a6a49748948059d3c27d8f55ad0ef94c8c",
+ "to_address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984",
+ "value": "0",
+ "gas": 50000,
+ "gas_price": "2000000000",
+ "data": "0xa9059cbb00000000000000000000000028c6c06298d514db089934071355e5743bf21d60000000000000000000000000000000000000000000000024ac7a2466fe6d4000",
+ "function_selector": "0xa9059cbb",
+ "max_fee_per_gas": "2000000000",
+ "max_priority_fee_per_gas": "2000000000",
+ "transaction_type": 2,
+ "r": "93121701149456501424579980283392650303695688563543540003926043202730906996706",
+ "s": "5712073776861917154895012394334633911928305143620822626852008432451821415527",
+ "v": "1",
+ "access_list_json": "[]",
+ "contract_address": null,
+ "gas_used": 35318,
+ "cumulative_gas_used": 9124496,
+ "effective_gas_price": "2000000000",
+ "blob_gas_used": 0,
+ "blob_gas_price": "0",
+ "logs_bloom": "0x40000000000000000000000000000000000000000020000000000000000000000000000000000000000001000000001000008000000000000000000000000000000000000000000000000008100000000000000000000000000000000000000008000000000000000000000000000000000000000000000002000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
+ "status": 1,
+ "decoded": {
+ "name": "transfer",
+ "inputs": {
+ "dst": "0x28c6c06298d514db089934071355e5743bf21d60",
+ "rawAmount": "676511072800000000000"
+ }
+ }
+ },
+ "status": "new",
+ "type": "transaction",
+ "id": "e922999aff6625e1e26e6eca478b2c6ece33a5b8cf3a3c8bde96c00da8d2acc0"
+ }
+ ]
+}
+```
+
+
+
+## Headers
+Each webhook request includes:
+- `x-webhook-id` - ID of the webhook configuration to know which one triggered the send
+- `x-webhook-signature` - hash of the payload signed with the webhook's secret. Used to validate the payload
+
+## Signature Verification (highly recommended)
+
+To verify that the request is from thirdweb webhooks it is highly recommended to verify the payload.
+Each webhook has a `webhook_secret` which is used to sign the raw payload and is then attached to the headers.
+
+To verify the webhook:
+```typescript
+const generateSignature = (
+ rawBody: string,
+ secret: string,
+): string => {
+ return crypto
+ .createHmac("sha256", secret)
+ .update(rawBody)
+ .digest("hex");
+};
+
+const isValidSignature = (
+ rawBody: string,
+ signature: string,
+ secret: string,
+): boolean => {
+ const expectedSignature = generateSignature(
+ body,
+ secret,
+ );
+ return crypto.timingSafeEqual(
+ Buffer.from(expectedSignature),
+ Buffer.from(signature),
+ );
+};
+
+// extract the signature from request headers
+const signature = req.headers['x-signature'];
+// get the raw body of the request
+const rawBody = req.rawBody;
+
+const isValid = isValidSignature(rawBody, signature, secret);
+```
+
+**You need to use the raw request body when verifying webhooks, as the cryptographic signature is sensitive to even the slightest changes. You should watch out for frameworks that parse the request as JSON and then stringify it because this too will break the signature verification.**
+
+## Payload Age
+
+You can optionally also verify the age of the payload.
+After you have verified the signature, you can be sure the timestamp in the payload is correct and apply any time limit you wish.
+
+```typescript
+export const isExpired = (
+ timestamp: string,
+ expirationInSeconds: number,
+): boolean => {
+ const currentTime = Math.floor(Date.now() / 1000);
+ return currentTime - parseInt(timestamp) > expirationInSeconds;
+};
+```
+
+## Reorged Data
+If a blockchain reorganization occurs:
+- You'll receive an event with `status: "reverted"` instead of `status: "new"`
+- You should handle this by reverting any actions taken for the original event
\ No newline at end of file