Secrets and webhook verification
Machine-to-machine secrets
| Secret | Guards | Behaviour when unset |
|---|---|---|
|
Broadcast endpoints and internal routes, via |
Fails closed — every request rejected |
|
|
|
|
|
Inngest refuses to invoke functions |
|
Admin Supabase client |
Admin operations fail |
verifyInternalSecret
export function verifyInternalSecret(request: Request): boolean {
if (!INTERNAL_API_SECRET) {
console.error("[auth] INTERNAL_API_SECRET is not configured")
return false
}
const authHeader = request.headers.get("authorization") ?? ""
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : ""
return token.length > 0 && token === INTERNAL_API_SECRET
}
Deliberately generic: any internal endpoint — cron-triggered broadcasts, edge functions, other services — can use it. Failing closed on an unset secret means a misconfigured environment rejects requests rather than silently exposing them.
Callers: POST /api/campaigns/{id}/broadcast, POST /api/email-campaigns/{id}/broadcast.
MCP
POST /api/mcp requires two headers:
Authorization: Bearer <MCP_API_SECRET>
x-subscription-id: <subscriptionId>
A single shared secret, deliberately not a Supabase session — MCP callers are machines and take the
tenant explicitly. Every tool scopes its database access to context.subscriptionId, so the header is the
only tenancy input and cannot be bypassed. See MCP tool server.
Provider signature verification
| Provider | Mechanism | When the secret is unset |
|---|---|---|
Resend |
Svix, over the raw body, using |
Verification skipped (development mode) |
HubSpot |
HMAC / legacy signature, |
Request rejected |
Nooks |
|
Verification skipped |
Telnyx |
Ed25519 over the raw body, |
Request rejected ( |
ElevenLabs |
HMAC-SHA256 over |
Request rejected ( |
Retell |
none |
— |
Five9 |
none |
— |
Every verifying route reads the body as raw text first, because signatures are computed over the exact bytes — parsing and re-serialising would change them.
HubSpot, Telnyx and ElevenLabs reject on a missing secret. Resend and Nooks skip. HubSpot publishes onto
the CRM write path; Telnyx and ElevenLabs are public machine routes, so an unset TELNYX_PUBLIC_KEY or
ELEVENLABS_WEBHOOK_SECRET must not accept traffic.
Idempotency as a second line of defence
Verification is not the only protection. WebhookEvent is unique on (source, eventId),
Call.providerCallId is unique, Activity and Emails are unique on (subscriptionId, externalId), and
the emailSend workflow refuses a target that has left pending. A replayed event — forged or genuine —
is absorbed rather than duplicated.
The exception is POST /api/webhooks/five9, which generates a random eventId and is therefore not
idempotent on replay.
Secret handling
- Server-only
-
Everything except
NEXT_PUBLIC_*is server-only. Never importlib/supabase/admin-client.tsfrom a client component, and never inline a secret into a component prop. - Client-exposed
-
Only
NEXT_PUBLIC_SUPABASE_URL,NEXT_PUBLIC_SUPABASE_ANON_KEY,NEXT_PUBLIC_SENTRY_DSN,NEXT_PUBLIC_SENTRY_ENVIRONMENTandNEXT_PUBLIC_APP_URL. The anon key is safe to expose; the service-role key is not. - Not committed
-
.envand.env.sentry-build-pluginare git-ignored. Deployed values live in Vercel’s environment configuration. - Hardcoded values that are not secrets
-
RETELL_SMS_AGENT_ID,HUBSPOT_PORTAL_ID,HUBSPOT_TASK_OWNER_ID,HUBSPOT_NEWSLETTER_SUBSCRIPTION_TYPE_ID, the BigQuery service-account email and the defaultINCOMING_EMAIL_SQS_QUEUE_URL. They are identifiers, not credentials, but changing an environment means reviewing them.
Rotation
-
Update the value in Vercel for the target environment.
-
Redeploy —
lib/constants.tsreadsprocess.envat module load, so a running instance keeps the old value. -
For
INTERNAL_API_SECRET, update thepg_cronjob definitions in Supabase in the same window; broadcasts will401until both sides match. -
For provider webhook secrets, update the provider’s dashboard first, then the environment — a brief window of skipped or failed verification is preferable to a window of dropped events.
Input validation
Every route parses its body and query string with Zod before use. Schemas live in
lib/schema/<feature>.ts and are the source of the TypeScript types, so validation and typing cannot
drift.
Raw SQL appears in exactly two places, both parameterised:
-
fetchAndLockPendingTargets—Prisma.sqlwith bound values. -
lib/bigquery/client.ts— the identifier value is bound as a parameter; view and field names cannot be parameterised in SQL, so they are validated against a strict allowlist before interpolation.