Skip to main content

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
For local development with tunnels, see Local Webhook Development. The relay is the production alternative.

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_URL is not set, the poller detects { configured: false } on the first call and stops

Setup

1. Create an Upstash Redis database

  1. Go to Upstash Console
  2. Create a new Redis database (the free tier is sufficient)
  3. Note the REST URL and REST Token — you will need them for any platform
If you deploy to Vercel, the Upstash Vercel Integration can provision these automatically.

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.
Option A — Vercel CLI:
cd apps/webhook-relay
vercel
Option B — Connect the monorepo:
  1. Import the repository in the Vercel Dashboard
  2. Set Root Directory to apps/webhook-relay
  3. Vercel will detect the vercel.json and build automatically
Option C — Install the Upstash integration:Add the Upstash Vercel Integration to auto-provision KV_REST_API_URL and KV_REST_API_TOKEN.

3. Configure environment variables

Set these on whichever platform you chose above.
VariableRequiredDescription
KV_REST_API_URLYesUpstash Redis REST endpoint.
KV_REST_API_TOKENYesUpstash Redis auth token.
RELAY_POLL_SECRETYesShared secret the local dashboard uses to authenticate poll requests. Generate with openssl rand -hex 32.
WEBHOOK_SECRET_GITHUBPer integrationSecret configured in your GitHub webhook settings.
WEBHOOK_SECRET_VERCELPer integrationSigning secret shown when creating a Vercel webhook.
WEBHOOK_SECRET_SENTRYPer integrationClient secret from your Sentry internal integration.
WEBHOOK_SECRET_LINEARPer integrationSigning secret from Linear webhook settings.
WEBHOOK_SECRET_BETTERSTACKPer integrationShared token you set in your BetterStack webhook.
SENTRY_DSNNoSentry DSN for error monitoring of the relay itself.
ALLOWED_ORIGINNoRestricts CORS on the poll endpoint. Not needed if your dashboard polls server-to-server.
RELAY_ENABLEDNoSet to false to globally disable all webhook ingestion. Defaults to enabled.
RELAY_DISABLE_GITHUBNoSet to true to disable GitHub webhooks only. Works for any integration: RELAY_DISABLE_VERCEL, etc.
You only need to set secrets for the integrations you actually use.
Secret rotation: Webhook secret env vars support comma-separated values for zero-downtime rotation. Set WEBHOOK_SECRET_GITHUB=new-secret,old-secret and the relay will try both keys until one matches. Remove the old key after all in-flight deliveries have completed.

4. Configure the local dashboard

Step A — Set the poll secret in env: Add to your apps/app/.env.local:
RELAY_POLL_SECRET=<same-secret-as-relay>
Step B — Set the relay URL in dashboard settings: Open the dashboard, go to Settings > Integrations, and enter your relay URL in the Webhook Relay section. Examples:
  • 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
The relay URL is stored in the database so it can be changed without restarting the dashboard. The <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 from packages/integrations/src/*/events/webhook.ts — the same code that powers the local /api/webhooks/[integration] route.

GitHub

SettingValue
Webhook URLhttps://your-relay.vercel.app/api/webhooks/github
Content typeapplication/json
SecretA random string you generate. Set as WEBHOOK_SECRET_GITHUB on Vercel.
Signing methodHMAC-SHA256
Signature headerX-Hub-Signature-256 (format: sha256=<hex>)
Where to configure: Repository → Settings → Webhooks → Add webhook Docs: Validating webhook deliveries Events supported: pull_request, issues, release, deployment_status, star

Vercel

SettingValue
Webhook URLhttps://your-relay.vercel.app/api/webhooks/vercel
Signing methodHMAC-SHA1
Signature headerx-vercel-signature (format: raw hex)
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

SettingValue
Webhook URLhttps://your-relay.vercel.app/api/webhooks/sentry
Signing methodHMAC-SHA256
Signature headersentry-hook-signature (format: raw hex)
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

SettingValue
Webhook URLhttps://your-relay.vercel.app/api/webhooks/linear
Signing methodHMAC-SHA256
Signature headerLinear-Signature (format: raw hex)
Where to configure: Settings → API → Webhooks → New webhook Docs: Linear Webhooks Events supported: Issue (created, updated, removed), Comment, Project

BetterStack

SettingValue
Webhook URLhttps://your-relay.vercel.app/api/webhooks/betterstack
Signing methodShared token (not HMAC)
Token headerx-betterstack-token or Authorization: Bearer <token>
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.
Because BetterStack does not sign payloads, the relay relies on token comparison only. This is less secure than HMAC. The rate limiter and replay protection provide additional defense, but treat this integration’s secret with extra care.
Docs: BetterStack Outgoing Webhooks Events supported: Monitor status changes (up/down), incidents

Relay API endpoints

POST /api/webhooks/:integration

Receives webhooks from external services.
StepDescription
Kill switchRejects immediately if RELAY_ENABLED=false or RELAY_DISABLE_<INTEGRATION>=true (503)
Body limitRejects payloads larger than 256 KB (413)
Content-TypeRejects requests without application/json Content-Type (415)
Rate limit100 requests/min per IP per integration (429)
Replay checkRejects duplicate delivery IDs and payloads older than 5 minutes (409)
Signature verifyTries each configured secret (supports rotation). Delegates to the integration’s verifySignature() handler (401)
Parse payloadDelegates to the integration’s parsePayload() handler (400)
StoreWrites events to an Upstash Redis sorted set
Returns { received: true, eventCount: N } on success.

GET /api/events?since=<ms>&limit=<n>

Poll endpoint for the local dashboard.
ParameterDefaultDescription
sincenow - 60sTimestamp in milliseconds. Returns events received at or after this time.
limit100Maximum events to return. Capped at 500.
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=false to reject all webhooks with 503 Service Unavailable
  • Per-integration: Set RELAY_DISABLE_GITHUB=true (or any integration name) to disable that integration only
The poll endpoint is not affected — the dashboard can still drain buffered events after the kill switch is flipped.

Body size limit

Rejects payloads larger than 256 KB with 413 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 without application/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
Returns 429 Too Many Requests with a Retry-After header when exceeded.

Replay protection

Two complementary strategies:
  1. Delivery ID dedup: Stores processed delivery IDs in Redis with a 5-minute TTL. If the same delivery ID arrives again, returns 409 Conflict.
  2. Timestamp freshness: For integrations that include a timestamp header (Sentry), rejects payloads older than 5 minutes.
Delivery ID headers per integration:
IntegrationHeader
GitHubX-GitHub-Delivery
VercelX-Vercel-Delivery
Sentrysentry-hook-resource
Linearlinear-delivery
BetterStackx-betterstack-delivery

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:
  1. Add the new secret alongside the old one
  2. Update the external service to use the new secret
  3. Remove the old secret after all in-flight deliveries have completed
No requests are rejected during the rotation window.

Event expiration

Events older than 24 hours are pruned from Redis on each poll request.

Sentry error monitoring

If SENTRY_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

apps/webhook-relay/
├── src/
│   ├── index.ts              # Hono app setup, middleware, route mounting
│   ├── vercel-entry.ts       # Vercel/serverless entry
│   ├── cloudflare-entry.ts   # Cloudflare Workers entry
│   ├── lib/
│   │   ├── env.ts            # Centralized env var access
│   │   ├── store.ts          # Upstash Redis event storage (sorted set)
│   │   ├── auth.ts           # Bearer token verification (timing-safe)
│   │   └── sentry.ts         # Sentry init + error middleware
│   ├── middleware/
│   │   ├── rate-limit.ts     # Upstash Ratelimit (sliding window)
│   │   └── replay-protection.ts  # Delivery ID dedup + timestamp check
│   ├── routes/
│   │   ├── webhooks.ts       # POST /api/webhooks/:integration
│   │   ├── events.ts         # GET /api/events
│   │   └── health.ts         # GET /api/health
│   └── __tests__/
│       ├── store.test.ts
│       ├── auth.test.ts
│       ├── webhooks.test.ts
│       └── events.test.ts
├── scripts/
│   └── test-webhook.sh       # Manual integration test
├── package.json
├── tsconfig.json
├── vercel.json
└── vitest.config.ts

Dashboard integration files

FileDescription
apps/app/lib/webhook-relay-poller.tsServer-side polling logic — fetches from relay, emits notification events
apps/app/app/api/relay/poll/route.tsAPI route — secure bridge between browser and cloud relay
apps/app/components/webhook-relay-poller.tsxClient component — drives the 15s polling interval

Manual testing

Use the included shell script to send a signed test webhook and verify it appears in the poll response:
RELAY_URL=https://your-relay.vercel.app \
RELAY_POLL_SECRET=your-secret \
WEBHOOK_SECRET_GITHUB=your-github-secret \
bash apps/webhook-relay/scripts/test-webhook.sh
The script computes an HMAC-SHA256 signature locally, sends a test GitHub pull_request payload, waits 1 second, then polls for the event and verifies it appears.

Managed relay (multi-tenant)

The managed relay at relay.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 the RELAY_ADMIN_SECRET for authorization. Create a tenant:
curl -X POST https://relay.radarboard.app/api/tenants \
  -H "Authorization: Bearer $RELAY_ADMIN_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"label": "Acme Corp"}'
Response:
{
  "tenant": { "id": "a1b2c3d4", "createdAt": 1711324800000, "label": "Acme Corp" },
  "pollSecret": "generated-uuid",
  "webhookBaseUrl": "/api/t/a1b2c3d4/webhooks",
  "pollUrl": "/api/t/a1b2c3d4/events"
}
Set webhook secrets for the tenant:
curl -X PUT https://relay.radarboard.app/api/tenants/a1b2c3d4/secrets \
  -H "Authorization: Bearer $RELAY_ADMIN_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"webhookSecrets": {"github": "their-github-secret", "vercel": "their-vercel-secret"}}'
List all tenants:
curl https://relay.radarboard.app/api/tenants \
  -H "Authorization: Bearer $RELAY_ADMIN_SECRET"
Delete a tenant (removes all data):
curl -X DELETE https://relay.radarboard.app/api/tenants/a1b2c3d4 \
  -H "Authorization: Bearer $RELAY_ADMIN_SECRET"

Connecting a tenant’s dashboard

The user sets their relay URL in Settings > Integrations > Webhook Relay:
https://relay.radarboard.app/api/t/a1b2c3d4
And sets 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)

VariableRequiredDescription
KV_REST_API_URLYesUpstash Redis REST endpoint
KV_REST_API_TOKENYesUpstash Redis auth token
RELAY_ADMIN_SECRETYesAdmin secret for tenant provisioning API
RELAY_POLL_SECRETYesFallback poll secret for single-tenant mode
ALLOWED_ORIGINRecommendedRestrict CORS on events endpoint
SENTRY_DSNNoError monitoring
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-After header. External services will typically retry automatically.

Dashboard not receiving events

  1. Check WEBHOOK_RELAY_URL and RELAY_POLL_SECRET are set in apps/app/.env.local
  2. Check the relay is deployed and /api/health returns 200
  3. Check the browser console for network errors on /api/relay/poll
  4. 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