Secrets and webhook verification

Machine-to-machine secrets

Secret Guards Behaviour when unset

INTERNAL_API_SECRET

Broadcast endpoints and internal routes, via verifyInternalSecret()

Fails closed — every request rejected

MCP_API_SECRET

POST /api/mcp

-32603 Server misconfiguration (HTTP 500)

INNGEST_SIGNING_KEY

/api/inngest

Inngest refuses to invoke functions

SUPABASE_SERVICE_ROLE_KEY

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 RESEND_WEBHOOK_SECRET

Verification skipped (development mode)

HubSpot

HMAC / legacy signature, HUBSPOT_WEBHOOK_CLIENT_SECRET

Request rejected

Nooks

Authorization: Bearer or X-Nooks-Signature, NOOKS_WEBHOOK_SECRET

Verification skipped

Telnyx

Ed25519 over the raw body, TELNYX_PUBLIC_KEY

Request rejected (503)

ElevenLabs

HMAC-SHA256 over {t}.{rawBody}, ELEVENLABS_WEBHOOK_SECRET

Request rejected (503)

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 import lib/supabase/admin-client.ts from 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_ENVIRONMENT and NEXT_PUBLIC_APP_URL. The anon key is safe to expose; the service-role key is not.

Not committed

.env and .env.sentry-build-plugin are 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 default INCOMING_EMAIL_SQS_QUEUE_URL. They are identifiers, not credentials, but changing an environment means reviewing them.

Rotation

  1. Update the value in Vercel for the target environment.

  2. Redeploy — lib/constants.ts reads process.env at module load, so a running instance keeps the old value.

  3. For INTERNAL_API_SECRET, update the pg_cron job definitions in Supabase in the same window; broadcasts will 401 until both sides match.

  4. 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.sql with 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.

Rendering user content

React escapes JSX by default. The exception is email HTML, which is rendered as markup — anything user-authored that reaches dangerouslySetInnerHTML must be sanitised first.