Skip to content
Draft
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ public/sitemap*.xml
# TypeScript
*.tsbuildinfo

# Python examples and tests
__pycache__/
*.pyc

*.bak

# Vale synced packages (re-sync with `vale sync`)
Expand Down
3 changes: 3 additions & 0 deletions app/en/build/_meta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ export const meta: MetaRecord = {
title: "Quickstart: build an MCP server",
href: "/get-started/quickstarts/mcp-server-quickstart",
},
eventing: {
title: "Events and webhooks",
},
"tool-calling": {
title: "Call tools",
},
Expand Down
589 changes: 589 additions & 0 deletions app/en/build/eventing/page.mdx

Large diffs are not rendered by default.

166 changes: 166 additions & 0 deletions examples/eventing/receiver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import base64
import hashlib
import hmac
import json
import logging
import sqlite3
import time
from collections.abc import Callable, Mapping

TOLERANCE_SECONDS = 300
SECRET_FORMAT_ERROR = (
"webhook secrets must use whsec_ followed by padded standard base64"
)
WebhookSecrets = list[str] | tuple[str, ...]
logger = logging.getLogger(__name__)


class VerificationError(Exception):
pass


class ConfigurationError(Exception):
pass


def verify_request(
body: bytes,
headers: Mapping[str, str],
secrets: WebhookSecrets,
now: int | None = None,
) -> tuple[dict, str]:
if not isinstance(secrets, (list, tuple)) or any(
not isinstance(secret, str) for secret in secrets
):
raise ConfigurationError("webhook secrets must be a list or tuple of strings")

normalized = {key.lower(): value for key, value in headers.items()}
try:
delivery_id = normalized["webhook-id"]
timestamp_text = normalized["webhook-timestamp"]
supplied = normalized["webhook-signature"].split()
except KeyError as error:
raise VerificationError(f"missing {error.args[0]}") from error

try:
timestamp = int(timestamp_text)
except ValueError as error:
raise VerificationError("invalid webhook-timestamp") from error

verification_time = int(time.time()) if now is None else now
if abs(verification_time - timestamp) > TOLERANCE_SECONDS:
raise VerificationError("webhook-timestamp outside tolerance")

signed = (
delivery_id.encode()
+ b"."
+ timestamp_text.encode()
+ b"."
+ body
)
keys: list[bytes] = []
for secret in secrets:
if not secret.startswith("whsec_"):
raise ConfigurationError(SECRET_FORMAT_ERROR)
try:
key = base64.b64decode(secret.removeprefix("whsec_"), validate=True)
except ValueError as error:
raise ConfigurationError(SECRET_FORMAT_ERROR) from error
if not key:
raise ConfigurationError(SECRET_FORMAT_ERROR)
keys.append(key)
if not keys:
raise ConfigurationError("no webhook secrets configured")

matched = False
for key in keys:
digest = hmac.new(key, signed, hashlib.sha256).digest()
expected = b"v1," + base64.b64encode(digest)
for candidate in supplied:
try:
encoded = candidate.encode("ascii")
except UnicodeEncodeError:
continue
matched |= hmac.compare_digest(expected, encoded)
if not matched:
raise VerificationError("invalid webhook-signature")

try:
event = json.loads(body)
except json.JSONDecodeError as error:
raise VerificationError("invalid JSON") from error
if not isinstance(event, dict):
raise VerificationError("event must be a JSON object")
return event, delivery_id


class SQLiteInbox:
"""A durable idempotency inbox for one receiver process."""

def __init__(self, path: str):
self.path = path
connection = sqlite3.connect(path, timeout=30, isolation_level=None)
try:
connection.execute(
"""CREATE TABLE IF NOT EXISTS webhook_inbox (
webhook_id TEXT PRIMARY KEY,
received_at INTEGER NOT NULL
)"""
)
connection.commit()
finally:
connection.close()

def handle(
self,
delivery_id: str,
event: dict,
handler: Callable[[sqlite3.Connection, dict], None],
) -> bool:
connection = sqlite3.connect(self.path, timeout=30, isolation_level=None)
try:
connection.execute("BEGIN IMMEDIATE")
inserted = connection.execute(
"""INSERT OR IGNORE INTO webhook_inbox(webhook_id, received_at)
VALUES (?, ?)""",
(delivery_id, int(time.time())),
).rowcount
if inserted == 0:
connection.commit()
return False
handler(connection, event)
connection.commit()
return True
except Exception:
connection.rollback()
raise
finally:
connection.close()


def receive(
body: bytes,
headers: Mapping[str, str],
subscription_secrets: WebhookSecrets,
inbox: SQLiteInbox,
authorize: Callable[[dict], bool],
handler: Callable[[sqlite3.Connection, dict], None],
now: int | None = None,
) -> int:
try:
event, delivery_id = verify_request(body, headers, subscription_secrets, now)
except VerificationError:
return 400
except ConfigurationError:
return 500

try:
# Build this callback from server-side subscription configuration. Do not
# accept event types or tenant IDs merely because they appear in the payload.
if not authorize(event):
return 403
inbox.handle(delivery_id, event, handler)
except Exception:
logger.exception("webhook handler failed")
return 500
return 204
3 changes: 2 additions & 1 deletion public/llms.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!-- git-sha: 23fe2a48add7a58708e5c8e00c386002fa8df2a7 generation-date: 2026-08-28T19:21:13.355Z -->
<!-- git-sha: 558d09b41d9836396266e6e47df425f027e08827 generation-date: 2026-08-29T15:03:17.160Z -->

# Arcade

Expand Down Expand Up @@ -93,6 +93,7 @@ Arcade docs serve two audiences. Start with the path that matches your goal:
- [Build an AI agent with Arcade and Spring AI](https://docs.arcade.dev/en/get-started/agent-frameworks/springai): Documentation page
- [Build an AI Chatbot with Arcade and TanStack AI](https://docs.arcade.dev/en/get-started/agent-frameworks/tanstack-ai): This documentation page guides users through the process of building a browser-based AI chatbot using Arcade tools and TanStack AI, enabling integration with Gmail and Slack for seamless communication. Users will learn how to set up a TanStack Start project, manage chat state,
- [Build an AI Chatbot with Arcade and Vercel AI SDK](https://docs.arcade.dev/en/get-started/agent-frameworks/vercelai): This documentation page guides users through the process of building a browser-based AI chatbot using the Vercel AI SDK and Arcade tools for Gmail and Slack integration. Users will learn how to set up a Next.js project, manage chat state, and implement authorization
- [Build event-driven integrations](https://docs.arcade.dev/en/build/eventing): Documentation page
- [Build MCP Server QuickStart](https://docs.arcade.dev/en/get-started/quickstarts/mcp-server-quickstart): The "Build MCP Server QuickStart" documentation provides a step-by-step guide for users to create and run a custom MCP Server using the Arcade MCP framework. It covers prerequisites, installation of necessary tools, server setup, and how to implement and call various
- [Build with Arcade](https://docs.arcade.dev/en/build): Documentation page
- [Build Your Own Contextual Access Server](https://docs.arcade.dev/en/operate/governance/contextual-access/build-your-own): Documentation page
Expand Down
159 changes: 159 additions & 0 deletions tests/eventing-guide.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { meta } from "../app/en/build/_meta";

const PAGE = "app/en/build/eventing/page.mdx";
const TITLE_RE = /title:\s*"Build event-driven integrations"/;
const MODEL_SECTION_RE =
/## The eventing model([\s\S]*?)## Choose your deployment origin/;
const TABLE_DATA_ROW_RE = /^\| (?!Term \|)(?!-)[^|]+\|/gm;
const TIMESTAMP_TOLERANCE_RE = /through (\d+) seconds/;
const TIMESTAMP_REJECTION_RE = /timestamps (\d+) seconds away/;
const TOLERANCE_CONSTANT_RE = /TOLERANCE_SECONDS = (\d+)/;
const RETRY_DELAYS_RE =
/Arcade makes 8 attempts: immediately, then after ([^.]+)\. The configured delay/;
const RETRY_TOTAL_RE = /totals (\d+) hours, (\d+) minutes, and (\d+) seconds/;
const RETRY_DELAY_SEPARATOR_RE = /,\s*(?:and\s+)?/;
const RETRY_DELAY_RE = /(\d+) (second|minute|hour)s?/;
const RETENTION_WINDOW_RE =
/Keep each recorded `webhook-id` for at least Arcade's configured event-retention period \(90 days by default\)/;
const STABLE_WEBHOOK_ID_RE =
/same `webhook-id` across automatic retries, manual retry, and recovery/;

const page = readFileSync(join(process.cwd(), PAGE), "utf8");

describe("unified eventing guide", () => {
test("registers the eventing content directory in Build navigation", () => {
const contentDirectories = readdirSync(
join(process.cwd(), "app/en/build"),
{ withFileTypes: true }
)
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
expect(contentDirectories).toContain("eventing");
expect(meta.eventing).toEqual({
title: "Events and webhooks",
});
expect(page).toMatch(TITLE_RE);
});

test("defines the exact seven-term source-to-destination model", () => {
const model = page.match(MODEL_SECTION_RE)?.[1] ?? "";
const rows = [
"| Arcade event | Store | Trigger instance, schedule, or provider ingress | Project history |",
"| Trigger type | Configure | Toolkit declaration | Trigger instance |",
"| Trigger instance | Produce | Connected-account observation | Arcade event |",
"| Schedule | Produce | Time rule | Arcade event |",
"| Provider ingress | Produce | Verified provider callback | Arcade event |",
"| Webhook subscription | Route | Matching Arcade event | Webhook delivery |",
"| Webhook delivery | Deliver | Webhook subscription | Configured receiver |",
];
for (const row of rows) {
expect(model).toContain(row);
}
expect(model.match(TABLE_DATA_ROW_RE)).toHaveLength(7);
});

test("keeps examples on Dashboard and scoped REST surfaces", () => {
expect(page).toContain("## Try a scheduled event");
expect(page).toContain("## Try a filtered Gmail trigger");
expect(page).toContain("## Connect a customer-owned realtime provider");
expect(page).toContain('Tabs items={["Dashboard", "REST API"]}');
expect(page).toContain("Authorization: Bearer $ARCADE_API_KEY");
expect(page).toContain(
"/v1/orgs/$ARCADE_ORG_ID/projects/$ARCADE_PROJECT_ID"
);
for (const variable of [
"WEBHOOK_ID",
"SCHEDULE_ID",
"TRIGGER_ID",
"EVENT_ID",
"AUTH_PROVIDER_ID",
]) {
expect(page).toContain(`export ${variable}=`);
}
});

test("documents provider ingress setup, proof, recovery, and boundaries", () => {
for (const value of [
"slack.message.received",
"github.push.received",
'"preserve_signing_secret":true',
"current_secret_verified",
"last_verified_at",
"public_host_required",
"request-rate and byte-rate limits",
"seven days",
"5 MiB",
]) {
expect(page).toContain(value);
}
expect(page).toContain("$SCOPE/auth_providers/$AUTH_PROVIDER_ID/ingress");
expect(page).toContain("It is not the OAuth redirect URI");
expect(page).toContain("acknowledges and drops deliveries");
});

test("pins origins, tenant isolation, and the reference boundary", () => {
for (const value of [
"https://api.arcade.dev",
"https://app.arcade.dev",
"$ARCADE_ENGINE_URL/dashboard",
"http://localhost:9099",
"http://localhost:9099/dashboard",
]) {
expect(page).toContain(value);
}
expect(page).toContain("[Arcade API reference](/references/api)");
});

test("documents the supported lifecycle without hiding retained events", () => {
for (const route of [
"GET /triggers/{trigger_id}",
"PATCH /triggers/{trigger_id}",
"DELETE /triggers/{trigger_id}",
"GET /schedules/{schedule_id}",
"PATCH /schedules/{schedule_id}",
"DELETE /schedules/{schedule_id}",
"GET /events/{event_id}",
"POST /webhooks/{webhook_id}/rotate_secret",
"POST /webhooks/{webhook_id}/recover_deliveries",
"POST /webhooks/{webhook_id}/replay_missing",
"POST /webhooks/{webhook_id}/deliveries/{delivery_id}/retry",
]) {
expect(page).toContain(route);
}
});

test("states only reviewed delivery guarantees and resource boundaries", () => {
const tolerance = Number(page.match(TIMESTAMP_TOLERANCE_RE)?.[1]);
const rejection = Number(page.match(TIMESTAMP_REJECTION_RE)?.[1]);
const receiverTolerance = Number(page.match(TOLERANCE_CONSTANT_RE)?.[1]);
expect(Number.isInteger(tolerance)).toBe(true);
expect(Number.isInteger(rejection)).toBe(true);
expect(Number.isInteger(receiverTolerance)).toBe(true);
expect(receiverTolerance).toBe(tolerance);
expect(rejection).toBe(tolerance + 1);

const unitSeconds = { second: 1, minute: 60, hour: 3600 };
const delays = page
.match(RETRY_DELAYS_RE)?.[1]
.split(RETRY_DELAY_SEPARATOR_RE)
.map((delay) => {
const [, amount, unit] = delay.match(RETRY_DELAY_RE) ?? [];
return Number(amount) * unitSeconds[unit as keyof typeof unitSeconds];
});
expect(delays).toHaveLength(7);
const [, hours, minutes, seconds] = page.match(RETRY_TOTAL_RE) ?? [];
const statedTotal =
Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds);
expect(delays?.reduce((total, delay) => total + delay, 0)).toBe(
statedTotal
);
});

test("keeps deduplication through the manual recovery window", () => {
expect(page).toMatch(RETENTION_WINDOW_RE);
expect(page).toMatch(STABLE_WEBHOOK_ID_RE);
});
});
31 changes: 31 additions & 0 deletions tests/eventing-receiver-executable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "vitest";

const PAGE = "app/en/build/eventing/page.mdx";
const RECEIVER = "examples/eventing/receiver.py";
const RECEIVER_BLOCK_RE = /```python filename="receiver\.py"\n([\s\S]*?)\n```/;

describe("eventing receiver example", () => {
test("the published snippet is the executable example", () => {
const page = readFileSync(join(process.cwd(), PAGE), "utf8");
const published = page.match(RECEIVER_BLOCK_RE)?.[1];
const executable = readFileSync(
join(process.cwd(), RECEIVER),
"utf8"
).trim();

expect(published).toBe(executable);
});

test("executes signature, rotation, boundary, duplicate, and rollback proofs", () => {
const result = spawnSync("python3", ["tests/eventing_receiver_test.py"], {
cwd: process.cwd(),
encoding: "utf8",
env: { ...process.env, PYTHONPATH: process.cwd() },
});
expect(result.error?.message ?? "", result.stderr).toBe("");
expect(result.status, result.stderr || result.stdout).toBe(0);
});
});
Loading
Loading