Webhook Gateway for External Service Integration
Context
joelclaw receives events from the outside world through two mechanisms today:
- Telegram — long-polling via the gateway extension (real-time chat, photos, voice)
- Inngest cron — heartbeat, content sync, periodic checks
But the system needs to receive push notifications from external services: Todoist comments (ADR-0047), GitHub events, Vercel deploy status, Google Calendar changes, Stripe webhooks, and future integrations. Each of these services has its own webhook format, authentication scheme, and retry behavior.
Currently there’s no general-purpose way for external services to push events into joelclaw. The Mac Mini sits behind Tailscale with no public endpoints. Every new integration requires a one-off poll-based cron, which adds latency and wastes API calls.
Prior Art: OpenClaw Hooks System
OpenClaw (~/Code/openclaw/openclaw) has a mature webhook gateway (src/gateway/hooks.ts + hooks-mapping.ts) with patterns worth adopting:
What OpenClaw does well:
- Hooks as a first-class gateway concept —
POST /hooks/<path>with bearer token auth - Mapping system — declarative config maps incoming webhook payloads to internal actions via templates (
{{payload.field}}) - Transform functions — JS modules that can transform/filter webhook payloads before dispatch
- Preset mappings — built-in transforms for known services (Gmail preset)
- Path-based routing —
/hooks/todoist,/hooks/github,/hooks/vercel - Rate limiting on auth failures — 20 failures per IP per minute window
- Session key derivation — unique session per webhook source (
hook:todoist:{{taskId}}) - Max body size enforcement — 256KB default, configurable
What’s different for joelclaw:
- OpenClaw dispatches webhooks to agent chat sessions. joelclaw dispatches to Inngest events — durable functions, not ephemeral chat.
- OpenClaw’s hooks are tightly coupled to its gateway process. joelclaw needs hooks that work both as a standalone HTTP endpoint AND as an Inngest cron fallback (for services behind firewalls).
- OpenClaw handles auth per-hook globally. joelclaw needs per-provider signature verification (Todoist HMAC, GitHub SHA-256, Stripe signatures).
Decision
Build a webhook gateway as a lightweight HTTP server that receives webhook POSTs from external services, validates signatures, normalizes payloads, and emits Inngest events. Dual-mode: direct webhooks when reachable, poll-based fallback when not.
Architecture
External Service joelclaw
┌─────────────┐ ┌──────────────────────────────┐
│ Todoist │ │ Webhook Gateway │
│ GitHub │────▶│ (Caddy → Bun HTTP server) │
│ Vercel │ │ │
│ Calendar │ │ 1. Route by path │
│ Stripe │ │ 2. Verify signature │
│ │ │ 3. Normalize payload │
└─────────────┘ │ 4. Emit Inngest event │
└──────────┬───────────────────┘
│
┌──────────▼───────────────────┐
│ Inngest │
│ todoist/comment.added │
│ github/push │
│ vercel/deploy.completed │
│ calendar/event.updated │
└──────────────────────────────┘Dual-Mode: Push + Poll
Every provider adapter supports two modes:
interface WebhookProvider {
id: string; // "todoist", "github", "vercel"
// Push mode: verify and normalize incoming webhook POST
verifySignature(req: Request): boolean;
normalizePayload(body: unknown): NormalizedEvent[];
// Poll mode: fetch changes since last check (fallback)
poll?(since: string): Promise<NormalizedEvent[]>;
// Inngest event name prefix
eventPrefix: string; // "todoist", "github", "vercel"
}
interface NormalizedEvent {
name: string; // "comment.added", "push", "deploy.completed"
data: Record<string, unknown>; // provider-agnostic payload
idempotencyKey?: string; // dedup across push + poll
}Push and poll can run simultaneously for reliability. The idempotencyKey on events ensures Inngest deduplicates if both fire for the same change.
Webhook Gateway Server
Lightweight Bun HTTP server exposed via Caddy + Tailscale Funnel:
// packages/system-bus/src/webhooks/server.ts
Bun.serve({
port: 3200,
async fetch(req) {
const url = new URL(req.url);
const path = url.pathname; // /webhooks/todoist
const provider = path.split("/")[2]; // "todoist"
const adapter = getProvider(provider);
if (!adapter) return new Response("Not Found", { status: 404 });
if (!adapter.verifySignature(req)) {
return new Response("Unauthorized", { status: 401 });
}
const body = await req.json();
const events = adapter.normalizePayload(body);
for (const event of events) {
await inngest.send({
name: `${adapter.eventPrefix}/${event.name}`,
data: event.data,
...(event.idempotencyKey ? { id: event.idempotencyKey } : {}),
});
}
return Response.json({ ok: true, events: events.length });
},
});Provider Adapters
Todoist (first)
const todoistProvider: WebhookProvider = {
id: "todoist",
eventPrefix: "todoist",
verifySignature(req) {
// Todoist uses HMAC-SHA256 with client_secret
const signature = req.headers.get("x-todoist-hmac-sha256");
const body = await req.text();
const expected = hmacSha256(TODOIST_CLIENT_SECRET, body);
return timingSafeEqual(signature, expected);
},
normalizePayload(body) {
// Todoist webhook payload: { event_name, event_data, ... }
const { event_name, event_data } = body;
return [{
name: mapEventName(event_name), // "note:added" → "comment.added"
data: {
taskId: event_data.item_id,
content: event_data.content,
postedAt: event_data.posted_at,
},
idempotencyKey: `todoist-${event_name}-${event_data.id}`,
}];
},
// Poll fallback via Activity Log API
async poll(since) {
const activities = await todoist.getActivities({
objectEventTypes: ["note:added", "item:completed", "item:added"],
dateFrom: since,
annotateNotes: true,
annotateParents: true,
});
return activities.results.map(a => ({
name: mapActivityType(a.event_type, a.object_type),
data: normalizeActivity(a),
idempotencyKey: `todoist-activity-${a.id}`,
}));
},
};GitHub (second)
const githubProvider: WebhookProvider = {
id: "github",
eventPrefix: "github",
verifySignature(req) {
// GitHub uses HMAC-SHA256 with webhook secret
const signature = req.headers.get("x-hub-signature-256");
const body = await req.text();
return verifyGitHubSignature(signature, body, GITHUB_WEBHOOK_SECRET);
},
normalizePayload(body) {
const event = req.headers.get("x-github-event"); // "push", "pull_request", etc.
return [{
name: event,
data: { ...body },
idempotencyKey: `github-${body.delivery || body.hook_id}-${Date.now()}`,
}];
},
};Vercel (third)
const vercelProvider: WebhookProvider = {
id: "vercel",
eventPrefix: "vercel",
verifySignature(req) {
const signature = req.headers.get("x-vercel-signature");
return verifyVercelSignature(signature, body, VERCEL_WEBHOOK_SECRET);
},
normalizePayload(body) {
return [{
name: `deploy.${body.type}`, // "deploy.ready", "deploy.error"
data: {
deploymentId: body.payload?.deployment?.id,
url: body.payload?.deployment?.url,
state: body.payload?.deployment?.readyState,
project: body.payload?.name,
},
idempotencyKey: `vercel-${body.id}`,
}];
},
};Exposure via Caddy + Tailscale Funnel
# Caddyfile addition
webhooks.joelclaw.ts.net {
reverse_proxy localhost:3200
}Tailscale Funnel makes this reachable from the internet without port forwarding. Caddy handles TLS.
For services that can’t reach the endpoint (or during development), the poll-based fallback runs via Inngest cron:
inngest.createFunction(
{ id: "webhook-poll-todoist" },
{ cron: "*/2 * * * *" },
async ({ step }) => {
const since = await step.run("get-checkpoint", () =>
redis.get("webhook:todoist:last-poll")
);
const events = await step.run("poll", () =>
todoistProvider.poll(since || twoMinutesAgo())
);
for (const event of events) {
await step.sendEvent(`emit-${event.name}`, {
name: `todoist/${event.name}`,
data: event.data,
...(event.idempotencyKey ? { id: event.idempotencyKey } : {}),
});
}
await step.run("update-checkpoint", () =>
redis.set("webhook:todoist:last-poll", new Date().toISOString())
);
}
);Where It Lives
packages/system-bus/src/webhooks/
├── server.ts # Bun HTTP server (port 3200)
├── types.ts # WebhookProvider interface, NormalizedEvent
├── providers/
│ ├── todoist.ts # Todoist webhook + poll adapter
│ ├── github.ts # GitHub webhook adapter
│ └── vercel.ts # Vercel webhook adapter
├── verify.ts # Signature verification utilities
└── poll.ts # Inngest cron poll functions (fallback)Security
Adapted from OpenClaw’s approach:
- Per-provider signature verification — HMAC-SHA256 for Todoist/GitHub, provider-specific for others
- Rate limiting — track auth failures per source IP, throttle after 20 failures/minute (OpenClaw’s pattern)
- Max body size — 256KB default, reject larger payloads
- Secrets in agent-secrets — webhook signing secrets stored with TTL leasing
- No query string tokens — bearer header or provider signature only (OpenClaw explicitly blocks
?token=) - Idempotency keys on all events — safe to receive duplicates from retry-happy providers
Alternatives Considered
A: Poll-Only (no webhook server)
Just poll every provider via Inngest cron. Simpler — no public endpoint needed.
Rejected because polling adds 1-5 min latency, wastes API calls, and doesn’t scale to providers without poll APIs (Vercel, Stripe). Keep poll as fallback, not primary.
B: Inngest Webhook Proxy
Use Inngest’s built-in webhook → event feature. Inngest cloud can receive webhooks and convert to events.
Rejected because joelclaw runs self-hosted Inngest. Would need to add a public endpoint to the Inngest server itself, which is more complex than a dedicated lightweight webhook server.
C: Cloudflare Worker Proxy
Deploy a Cloudflare Worker that receives webhooks publicly and forwards to the Mac Mini via Tailscale.
Good fallback if Tailscale Funnel proves unreliable. More moving parts but better DDoS protection. Park for now.
D: OpenClaw’s hooks system directly
Fork OpenClaw’s hooks.ts + hooks-mapping.ts and adapt.
Partially adopted — the mapping template system ({{payload.field}}), auth patterns, and rate limiting are excellent. But OpenClaw dispatches to agent sessions; joelclaw dispatches to Inngest events. The provider adapter pattern is joelclaw-specific.
Consequences
Positive
- Real-time push from any service with webhook support
- Poll fallback for development and unreachable services
- Idempotent — push + poll can run simultaneously without duplicates
- Extensible — new provider = implement
WebhookProviderinterface - Todoist conversations (ADR-0047) become real-time instead of 2-min delayed
- GitHub CI events trigger agent reactions (deploy failures, PR reviews)
- Vercel deploy status feeds back into the system automatically
Negative
- Public endpoint via Tailscale Funnel — attack surface (mitigated by signature verification + rate limiting)
- New process to manage — webhook server alongside Inngest worker and gateway
- Provider-specific signature verification — each provider is different (not a standard)
Credits
- OpenClaw (~/Code/openclaw/openclaw) for the hooks architecture patterns: path-based routing, template mapping, rate limiting, body size limits, auth failure tracking
- Ali Abdaal for the “comments as conversation” pattern that motivated this
Implementation Status
✅ Phase 2: Webhook Server + Tailscale Funnel (2026-02-18)
Implemented first — skipped Phase 1 polling since webhooks proved simpler.
- Webhook server as Hono sub-app mounted on worker at
/webhooks/:provider(not separate process) WebhookProviderinterface:verifySignature(),normalizePayload(), per-provider routing- Todoist adapter: HMAC-SHA256 with
client_secret, 3 event types (note:added,item:completed,item:added) - Tailscale Funnel :443 → worker :3111 directly (ADR-0051). Caddy path-routing dropped — Caddy swallows POST bodies on Funnel requests.
- 3 Inngest notify functions with API enrichment step (fetches task title + project name)
- Gateway middleware returns
GatewayPushResult(not void) for observability - Rate limiting: 10 req/min per IP via Hono middleware
- HMAC gotcha: Todoist “Verification token” ≠ signing key.
client_secretis the HMAC key per docs. - Key files:
src/webhooks/server.ts,src/webhooks/providers/todoist.ts,src/inngest/functions/todoist-notify.ts
✅ Phase 2b: Front Webhook Adapter (2026-02-18)
- Rules-based webhook (not app-level) — scoped to private inboxes at Front’s Rules layer
- HMAC-SHA1 over
JSON.stringify(body)→ base64 (different from Todoist’s SHA256) - No challenge mechanism (rules webhooks don’t use challenges)
- 3 Inngest notify functions: inbound email, outbound sent, assignee changed
- API enrichment step fetches conversation details (tags, assignee, status) from Front API
- Structured agent prompts with triage instructions (matches Todoist enrich → build-prompt → notify pattern)
- Webhook URL:
https://panda.tail7af24.ts.net/webhooks/front - Secrets:
front_rules_webhook_secret(HMAC),front_api_token(enrichment) in agent-secrets - Key files:
src/webhooks/providers/front.ts,src/inngest/functions/front-notify.ts - Verified E2E: real Front webhook → HMAC ✅ → Inngest → gateway notification. Confirmed with The Information newsletter + Vercel notifications flowing through.
Gotchas learned (Front webhooks):
- Two webhook mechanisms: App-level (
inbound_receivedtypes, HMAC-SHA256 withtimestamp:body, challenge handshake) vs Rules-based (inboundtypes, HMAC-SHA1 withJSON.stringify(body), no challenge). Completely different formats. - App-level webhooks auto-disable after repeated 401/500 failures. No retry, no warning. If your secret isn’t ready when Front validates, the webhook silently dies.
- Rules webhooks send Event objects, not app-level payloads. Type names are short (
inbound,outbound,assign) not compound (inbound_received,assignee_changed). Data lives at top level (conversation,source,target) not nested underpayload. - Front Rules can scope to private inboxes — better than server-side teammate filtering. The rule’s “When” condition + inbox filter handles what
FRONT_MONITORED_TEAMMATEwas supposed to do. - Front ID conversion: URL numeric IDs → API IDs via
base36(numericId)+ resource prefix. E.g., teammate URL 818967 →base36(818967)=hjx3→tea_hjx3. agent-secretsv0.5.0 dropped--rawflag (raw is now the default output). Workerstart.shmust not pass--rawor leases silently fail.- Rules webhook User-Agent is
Needle/3.0.0(Node.js HTTP client), notFront/1.0like app-level webhooks.
⬜ Phase 1: Poll Fallback
- Todoist Activity Log API poll as backup (not needed while Funnel is reliable)
- Idempotency keys already on all webhook events — safe to run push + poll simultaneously
⬜ Phase 3: GitHub + Vercel Adapters
- GitHub webhook adapter (push, PR, deploy status events)
- Vercel webhook adapter (deploy.ready, deploy.error)
- Provider interface is ready — add adapter + register route + notify function
⬜ Phase 4: Mapping System (optional)
- Port OpenClaw’s template mapping for config-driven webhooks
- Config-driven: add new webhooks without code changes