Webhook Relay
The local dashboard cannot receive inbound webhooks from external services because it is not publicly accessible. The webhook relay solves this by running a lightweight API on Vercel that receives webhooks, verifies signatures, stores events temporarily, and lets the local dashboard poll for them.Why this exists
Without the relay, you need a tunnel (Tailscale Funnel, Cloudflare Tunnel) running at all times to receive webhooks locally. The relay removes that requirement:- External services send webhooks to a permanent public URL (Vercel, Railway, Fly.io, Render, or any host)
- Events are buffered in Upstash Redis for 24 hours
- The local dashboard polls for new events every 15 seconds
- No tunnel, no port forwarding, no always-on process
Architecture
High-level flow
Security middleware pipeline
Each incoming webhook passes through these layers in order, from cheapest to most expensive. Any layer can reject the request before the next one runs.Polling sequence
Deployment options
How polling works
The local dashboard uses a client component (<WebhookRelayPoller/>) that calls a local API route (POST /api/relay/poll) every 15 seconds. The API route holds the relay secret server-side and makes the actual fetch to the cloud relay. Events are fed directly into the existing notification pipeline (emitNotificationEvents).
This design means:
- The relay poll secret is never exposed to the browser
- The local dashboard only makes outbound HTTPS requests (no inbound ports needed)
- If the relay is unreachable, the dashboard continues working — polling silently retries
- If
WEBHOOK_RELAY_URLis not set, the poller detects{ configured: false }on the first call and stops
Setup
1. Create an Upstash Redis database
- Go to Upstash Console
- Create a new Redis database (the free tier is sufficient)
- Note the REST URL and REST Token — you will need them for any platform
2. Deploy the relay
The webhook relay can be deployed to any platform that runs Node.js, Docker containers, or edge runtimes. Choose the platform that works best for you.- Vercel
- Railway
- Fly.io
- Render
- Cloudflare Workers
- Docker (self-hosted)
Option A — Vercel CLI:Option B — Connect the monorepo:
- Import the repository in the Vercel Dashboard
- Set Root Directory to
apps/webhook-relay - Vercel will detect the
vercel.jsonand build automatically
KV_REST_API_URL and KV_REST_API_TOKEN.3. Configure environment variables
Set these on whichever platform you chose above.
You only need to set secrets for the integrations you actually use.
4. Configure the local dashboard
Step A — Set the poll secret in env: Add to yourapps/app/.env.local:
- Vercel:
https://your-relay.vercel.app - Railway:
https://your-relay.up.railway.app - Fly.io:
https://your-relay.fly.dev - Render:
https://your-relay.onrender.com - Cloudflare:
https://radarboard-webhook-relay.your-subdomain.workers.dev - Self-hosted:
https://relay.yourdomain.com
<WebhookRelayPoller/> component will start polling automatically once both the URL and secret are configured.
5. Point external services to the relay
Configure each service’s webhook settings to point at your relay URL instead of your local machine.Per-integration webhook configuration
Each integration has its own signing mechanism. The relay reuses the existing webhook handlers frompackages/integrations/src/*/events/webhook.ts — the same code that powers the local /api/webhooks/[integration] route.
GitHub
Where to configure: Repository → Settings → Webhooks → Add webhook
Docs: Validating webhook deliveries
Events supported:
pull_request, issues, release, deployment_status, star
Vercel
Where to configure: Project → Settings → Webhooks
Important: Vercel displays the signing secret only once when you create the webhook. Copy it immediately and set it as
WEBHOOK_SECRET_VERCEL.
Docs: Vercel Webhooks
Events supported: deployment.created, deployment.succeeded, deployment.error
Sentry
Where to configure: Settings → Developer Settings → Internal Integrations → Create/Edit
Important: Sentry webhook signing is only available through Internal Integrations, not through the legacy “Webhooks” plugin. You must create an Internal Integration and use its Client Secret as
WEBHOOK_SECRET_SENTRY.
Docs: Sentry Webhooks (Integration Platform)
Events supported: issue.created, issue.resolved, error.created
Linear
Where to configure: Settings → API → Webhooks → New webhook
Docs: Linear Webhooks
Events supported:
Issue (created, updated, removed), Comment, Project
BetterStack
Where to configure: Monitoring → Integrations → Webhook → Edit
Important: BetterStack does not support HMAC signature verification. Instead, our handler checks for a shared token in the
x-betterstack-token header (or Authorization: Bearer header). You must configure your BetterStack webhook to include this token. Set the same value as WEBHOOK_SECRET_BETTERSTACK on Vercel.
Docs: BetterStack Outgoing Webhooks
Events supported: Monitor status changes (up/down), incidents
Relay API endpoints
POST /api/webhooks/:integration
Receives webhooks from external services.
Returns
{ received: true, eventCount: N } on success.
GET /api/events?since=<ms>&limit=<n>
Poll endpoint for the local dashboard.
Requires
Authorization: Bearer <RELAY_POLL_SECRET> header.
Returns a JSON array of relay events. Includes X-Relay-Timestamp response header for clock-skew-safe pagination — the local poller uses this value as the since parameter for the next poll.
GET /api/health
Returns { status: "ok", timestamp: <ms> }. No authentication required.
Security model
The relay applies multiple defense layers in order, from cheapest to most expensive. Each layer can independently reject a request before the next layer runs.Kill switch
Instantly disable all webhook ingestion or specific integrations without redeploying:- Global: Set
RELAY_ENABLED=falseto reject all webhooks with503 Service Unavailable - Per-integration: Set
RELAY_DISABLE_GITHUB=true(or any integration name) to disable that integration only
Body size limit
Rejects payloads larger than 256 KB with413 Payload Too Large. This prevents memory abuse and Redis bloat from oversized bodies. All supported webhook providers send payloads well under this limit.
Content-Type validation
Rejects requests withoutapplication/json Content-Type with 415 Unsupported Media Type. All supported webhook providers send JSON payloads.
Rate limiting
Uses Upstash Ratelimit with a sliding window algorithm:- Webhook routes: 100 requests/minute per IP per integration
- Poll route: 20 requests/minute per IP
429 Too Many Requests with a Retry-After header when exceeded.
Replay protection
Two complementary strategies:- Delivery ID dedup: Stores processed delivery IDs in Redis with a 5-minute TTL. If the same delivery ID arrives again, returns
409 Conflict. - Timestamp freshness: For integrations that include a timestamp header (Sentry), rejects payloads older than 5 minutes.
Signature verification
Four of five integrations use HMAC signature verification. The relay delegates to the same handler code that the local dashboard uses (packages/integrations/src/*/events/webhook.ts). All HMAC comparisons use constant-time XOR to prevent timing attacks.
BetterStack uses shared token comparison (also constant-time) since it does not support HMAC signing.
Secret rotation
Webhook secret env vars support comma-separated values for zero-downtime rotation. When multiple secrets are configured (e.g.WEBHOOK_SECRET_GITHUB=new-secret,old-secret), the relay tries each key in order until one matches. This allows you to:
- Add the new secret alongside the old one
- Update the external service to use the new secret
- Remove the old secret after all in-flight deliveries have completed
Event expiration
Events older than 24 hours are pruned from Redis on each poll request.Sentry error monitoring
IfSENTRY_DSN is configured, unhandled errors in the relay are captured with full request context (integration name, route, headers). This is for monitoring the relay itself, not the external services.
CI pipeline
The relay has a GitHub Actions workflow at.github/workflows/webhook-relay-ci.yml that runs on pushes to main and pull requests affecting the relay or its dependencies:
- Typecheck
- Lint (Biome)
- Unit tests (Vitest, 22 tests)
- Build
Project structure
Dashboard integration files
Manual testing
Use the included shell script to send a signed test webhook and verify it appears in the poll response:pull_request payload, waits 1 second, then polls for the event and verifies it appears.
Managed relay (multi-tenant)
The managed relay atrelay.radarboard.app hosts webhook ingestion for multiple users. Each user gets an isolated tenant with their own webhook URLs, secrets, and event store.
How it works
Each tenant is fully isolated:- Events:
relay:{tenantId}:events— no cross-tenant data leakage - Rate limits:
rl:webhook:{tenantId}:{ip}:{integration}— per-tenant limits - Replay protection:
relay:{tenantId}:dedup:*— per-tenant dedup - Secrets: stored in Redis per tenant, not in env vars
Tenant provisioning
All tenant management requires theRELAY_ADMIN_SECRET for authorization.
Create a tenant:
Connecting a tenant’s dashboard
The user sets their relay URL in Settings > Integrations > Webhook Relay:RELAY_POLL_SECRET in their apps/app/.env.local to the pollSecret returned at provisioning.
The dashboard’s poller appends /events?since=... automatically — it doesn’t know or care that it’s hitting a multi-tenant relay.
Environment variables (managed deployment)
Webhook secrets are not set as env vars in managed mode — they’re stored per-tenant in Redis via the provisioning API.
Troubleshooting
Relay returns 401 on webhook
- Verify the webhook secret env var is set on Vercel for that integration
- Check the secret matches exactly what the external service is using to sign
- For Sentry: make sure you are using an Internal Integration, not the legacy webhooks plugin
Relay returns 404 on webhook
- Check the integration name in the URL path matches one of:
github,vercel,sentry,linear,betterstack
Relay returns 429
- Rate limit exceeded. The response includes a
Retry-Afterheader. External services will typically retry automatically.
Dashboard not receiving events
- Check
WEBHOOK_RELAY_URLandRELAY_POLL_SECRETare set inapps/app/.env.local - Check the relay is deployed and
/api/healthreturns200 - Check the browser console for network errors on
/api/relay/poll - Try the manual test script to verify events reach the relay
Events appear in relay but not in notifications
- The notification system has its own preferences, quiet hours, and rules. Check the Notification settings page.
- Events are deduplicated by
sourceEventId— the same event will not produce duplicate notifications.
Relay health but Redis errors
- If using the Upstash Vercel integration, check the integration is still linked in your Vercel project settings
- Check the Upstash console for database status and quota usage