Environment Variables

Source: env.example + extra vars referenced in code. The two are diffed key-for-key — anything declared there has a row here, and the REQUIRED IN PRODUCTION markers in that file map onto the second table below.

Required

Name Used by Purpose
DATABASE_URL src/lib/db/index.ts Neon Postgres connection string
NEXTAUTH_SECRET proxy.ts, src/lib/auth/options.ts JWT signing secret
NEXTAUTH_URL src/lib/auth/options.ts, billing checkout Public site URL (e.g. http://localhost:3000)
AWS_ACCESS_KEY_ID src/lib/providers/* AWS credentials for SES + SNS
AWS_SECRET_ACCESS_KEY src/lib/providers/* AWS credentials
AWS_REGION src/lib/providers/* AWS region. Falls back to us-east-1 in code only — it is not the region of any particular deployment, and this one runs in ap-southeast-2. Read exactly once, into DEFAULT_SES_REGION (src/lib/providers/domain.ts), which is what createDomainIdentity builds identities in and what POST /api/internal/ip-pools stamps on a new pool. A SES configuration set is a regional resource and a pool-bound send pairs the pool's set with the domain row's region, so a second reader of this var — or a hardcoded literal beside it — is what produces ConfigurationSetDoesNotExist on every send.
STRIPE_SECRET_KEY src/lib/billing/stripe.ts Stripe API key
STRIPE_WEBHOOK_SECRET src/app/api/webhooks/stripe/route.ts Stripe webhook signing secret

Required in production (fails closed)

These are not conditionally required — they are required, and every one of them fails silently. Unset, the deploy boots, serves the dashboard and accepts sends; what stops is a background subsystem with no customer-facing error to raise. That is why they are their own table rather than entries in the optional list, which is where they used to sit.

Name Used by Unset in production means
CRON_SECRET src/lib/api/cron-auth.ts — all 13 /api/cron/* routes Bearer secret Vercel Cron sends as Authorization: Bearer <secret>, compared with timingSafeEqual. The check fails closed. Unset on a prod deploy rejects every caller, Vercel Cron included; it does not leave the endpoints open. All thirteen jobs then 401 on every tick with no alert, because a 401 is an ordinary response and the platform records the invocation as having happened. Outside production an unset secret is permissive so curl localhost:3000/api/cron/* works with no setup — except the destructive sweeps and the scheduled-send claim path (send-scheduled), which gate on cronSecretPresented in every environment because dev shares the production database: without a presented secret, send-scheduled answers a logged no-op instead of claiming live customers' due rows. Generate with openssl rand -hex 32. See Cron jobs → Authentication.
SNS_TOPIC_ARN_ALLOWLIST src/lib/api/sns-guard.ts/api/webhooks/{ses,sns-sms,inbound-email} Comma-separated SNS TopicArns allowed to deliver. A valid AWS signature only proves "some AWS account signed this", so isAllowedTopic rejects every notification with 403 when this is unset — message statuses stay stuck at sent, bounces and complaints stop auto-suppressing, STOP replies are dropped, all with nothing on the sending side to show for it. One sns.topic_allowlist_unset error line is the whole signal. List every SES + SMS + inbound topic on the account (scripts/setup-sns.sh prints them). Unset outside production means "no allowlist enforced".
STRIPE_PRO_PRICE_ID src/app/api/internal/billing/route.ts, src/app/api/webhooks/stripe/route.ts, src/lib/billing/plan-price.ts Flat monthly price for the Pro tier, and the only thing on a live subscription that names its tier. Unset does more than 503 that tier's checkout: planForSubscriptions throws UnresolvedSubscriptionPlanError rather than guess, so no plan is written at all and the org holds whatever tier it is already recorded as having.
STRIPE_PAYG_PRICE_ID same Same, for Pay as you go. Every fallback was a guess in one direction or the other — resolving to Pro handed a $5 PAYG customer Pro's 10,000-email allowance and billed nothing for it; resolving to PAYG would strip a real Pro customer's allowance the moment STRIPE_PRO_PRICE_ID went missing — and writing nothing is the only outcome that cannot invent an entitlement nobody bought. Both callers catch it around the plan write alone and keep serving: the billing page still repairs overage prices and returns the portal, the Stripe webhook still answers 200 (repeated 5xx is how Stripe disables an endpoint). Logged as billing.subscription_plan_unresolved with the price ids and the unset env var, plus billing.self_heal_plan_unresolved / stripe.webhook.plan_unresolved from the caller. Recovery is setting the var: the next subscription event re-derives the tier.
OVERAGE_LEDGER_FIRST_PERIOD src/app/api/cron/report-overage/route.ts, src/lib/billing/settlement.ts First billing period (YYYY-MM) the overage_reports ledger is authoritative for. Older periods are never metered — the cron cannot tell "unbilled" from "already billed by the pre-ledger code", and it now runs on days 1-5, outside Stripe's ~24h identifier dedup. Unset = no metering at all, ever. Every org with overage lands in skipped_pre_ledger, and the unset epoch is a 500 condition in its own right independent of that list — a month where nothing exceeded an allotment still returns 500, because the deployment is metering nothing rather than because this month owed nothing (ledger_epoch_unset in the body says which). So an unset epoch is a red cron every day of the day 1-5 window rather than a quiet one. Logged as cron.overage.pre_ledger_period. Set it to the first period whose day-1 run happens after the ledger deploy. Final settlement reads it the same way for the previous period; the current period is always billable there, since nothing can have metered it yet.
UNSUBSCRIBE_SECRET src/lib/api/unsubscribe.ts HMAC key for the signed one-click unsubscribe token in List-Unsubscribe and the footer link. Falls back to NEXTAUTH_SECRET — the signer throws in production only when neither is set, so this one does not take a deploy down on its own. Set it anyway: sharing the auth secret couples the two rotations, and rotating NEXTAUTH_SECRET then invalidates every unsubscribe link already sitting in an inbox. CAN-SPAM requires that mechanism to keep working at least 30 days after the send.
TRACKING_SECRET src/lib/api/tracking.ts HMAC key for the ?s= signature on tracking-pixel and click-redirect URLs, without which /api/track/click/:token is an open redirect on a customer's own sending domain. Falls back to UNSUBSCRIBE_SECRET, then NEXTAUTH_SECRET, and throws in production only if all three are unset. Give it its own value for the same rotation reason as above.

Optional (conditionally required)

Name Used by Purpose
STRIPE_EMAIL_OVERAGE_METER_EVENT src/app/api/cron/report-overage/route.ts Stripe meter event name for email overage. Required for monthly overage billing.
STRIPE_SMS_OVERAGE_METER_EVENT same Stripe meter event name for SMS overage
STRIPE_EMAIL_OVERAGE_PRICE_ID / STRIPE_SMS_OVERAGE_PRICE_ID src/lib/billing/overage.ts, src/app/api/internal/billing/route.ts, src/app/api/cron/report-overage/route.ts, src/lib/billing/settlement.ts Metered price ids added to the Checkout session alongside the tier's flat price. Shared by Pro and Pay as you go. A Stripe meter only bills through a price attached to the subscription, so a meter event with no such price is accepted by Stripe and invoices nothing. Still optional, and unset is still a supported flat-rate deployment — but it is no longer silent: report-overage resolves billability before it claims a ledger row, reports that channel's volume under skipped_no_overage_price, names the unset vars in overage_prices_missing, and stays green. Note what unset costs on Pay as you go, whose allowance is 0 by design: all of that tier's volume is overage, so none of it bills. Set-but-wrong is the loud case — a price with no recurring.meter is refused as price_not_metered and fails the run rather than metering into the void. Subscriptions minted before Checkout started attaching these are repaired in place, by the billing page when an owner opens it and by the cron before it meters. Final settlement reads the price for a second purpose — its unit_amount_decimal is the rate on the one-off invoice a leaving customer gets — so unset means that invoice is not raised either, and a tiered price (or one with transform_quantity) is refused rather than approximated.
COMPED_OWNER_EMAILS src/lib/billing/comped.ts Comma-separated org-owner addresses granted the uncapped tier with no Stripe subscription — added to the list baked into comped.ts, not a replacement for it. Takes effect on the next deploy, then within the 60s plan cache. Matched exactly against the normalized users.email (no plus-address or dotted-Gmail folding) and only for orgs the address owns. See Billing → Comped accounts.
UPSTASH_REDIS_REST_URL src/lib/api/rate-limit.ts Redis REST URL. If missing, rate limiting is skipped.
UPSTASH_REDIS_REST_TOKEN src/lib/api/rate-limit.ts Redis REST token
SYSTEM_FROM_EMAIL email-verification, password-reset, team-invite SES-verified From: for system emails. Fallback: no-reply@sendoka.com. No call site passes a region, so these go through clientFor(undefined)AWS_REGION — which makes arriving system mail a free, credential-free signal that the parent sendoka.com identity every platform-managed sandbox domain rides is verified there. See aws-ses.md.
SIGNUP_NOTIFY_EMAIL src/lib/auth/signup-notification.ts Where the internal "New Sendoka signup" mail goes. Defaults to the founder inbox. Sent only once a signup is fully verified (email, plus phone when one was collected) and only for self-provisioned orgs — see Authentication → Internal signup notification.
SYSTEM_SMS_FROM src/lib/auth/phone-verification.ts — register, verify-phone, resend-phone-code Origination identity for signup verification codes — an E.164 number this account owns, or an alphanumeric sender ID (AU/UK/EU support them; US/CA require a real number). Master switch for phone verification, and it works in both directions: unset, no phone is collected, signup is unchanged, and the sign-in gate stops applying — so clearing it after an incident also releases accounts already stranded mid-signup, rather than locking them out permanently with the recovery endpoints disabled by the same switch. A US/CA number must belong to Sendoka's own 10DLC brand/campaign under an OTP/2FA use case — without an explicit originator SNS picks any eligible number on the account, misattributing 10DLC traffic and routing STOP replies to the wrong org. Two production prerequisites beyond the variable itself: the AWS account must be out of the SMS sandbox (in sandbox only verified destination numbers receive, so every real signup returns undeliverable) and the default $1/month spend limit must be raised. +91 signups are refused while DLT_ENFORCE is on, since no system DLT template exists for this message.
PHONE_CODE_GLOBAL_HOURLY_LIMIT src/lib/auth/phone-verification.ts Circuit breaker on signup SMS across all destinations, default 500/hour (an empty value means the default, not 0). The per-phone, per-IP and per-country ceilings are each widened by adding numbers, proxies or countries; this is the only one that is not. Raise it for a launch, don't remove it — and set it to 0 to stop every outbound code during an incident.
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET src/lib/auth/options.ts Enables Google OAuth login when both are set
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET src/lib/auth/options.ts Enables GitHub OAuth login when both are set
UNSUBSCRIBE_TOKEN_MAX_AGE_DAYS src/lib/api/unsubscribe.ts Max age of a verified unsubscribe token, in days. Default 365. Tokens without an iat claim (legacy) are accepted indefinitely.
TRACK_OPEN_ALLOW_UNSIGNED src/app/api/track/open/[token]/route.ts Deprecated escape hatch — leave unset. true lets the tracking pixel record opens for hits with no ?s= signature, so mail sent before open-signing shipped keeps reporting. It also reopens the hole signing closes: anyone holding a message id can forge an open by GETting the URL. Self-expiring — the route refuses to honour it past UNSIGNED_OPEN_SUNSET_MS (src/lib/api/tracking.ts, currently 2026-11-01T00:00:00Z), set or not. See Tracking telemetry.
ALLOW_IDEMPOTENCY_WITHOUT_REDIS src/lib/api/idempotency.ts When Upstash is unset, set to 1 to allow idempotency to degrade to DB-only. Otherwise the idempotency middleware fails closed.
IDEMPOTENCY_TTL_HOURS src/lib/api/idempotency.ts Stored idempotency response cache TTL in hours. Default 24. Range 1–168; values outside fall back to default.
TOTP_ENC_KEY src/lib/auth/seed-crypto.ts At-rest AES-256-GCM key for TOTP seeds (two_factor_secrets.secret). Exactly 64 hex chars — openssl rand -hex 32. Unset fails open: 2FA keeps working with plaintext seeds and one two_factor.seed_unencrypted logError per process, deliberately, because locking every 2FA user out is worse than the plaintext status quo. Set, new enrollments write versioned enc1:<iv>:<tag>:<ct> rows and plaintext rows are re-encrypted on their owner's next successful login. Changing or clearing it after rows are encrypted makes those seeds undecryptable and verification fails closed (two_factor.seed_decrypt_failed); bcrypt backup codes stay the recovery path. No rotation support yet — do not rotate without a re-encryption plan. A malformed value falls back to plaintext writes with a once-per-process two_factor.seed_key_invalid.
AUDIT_SIGNING_SECRET src/lib/api/audit.ts, src/app/api/internal/audit/verify/route.ts HMAC key for per-row audit-log signatures. Opt-in with no fallback — unset means rows are written with signature NULL and GET /api/internal/audit/verify answers 503. Rotating it makes every previously-signed row read as invalid. See Audit log → Tamper evidence.
AUDIT_SIGNING_CUTOVER src/lib/api/audit.ts ISO 8601 instant from which a missing or non-re-derivable signature is a finding (stripped / invalid) rather than pre-signing history (unsigned / legacy). Defaults to 2026-08-10T00:00:00Z. Set it if you enable AUDIT_SIGNING_SECRET after that date, or the first verify run reports every unsigned row written since as stripped — a false breach signal. Use the later of "signing deployed" and "secret set"; move it to the rotation instant on rotation. See Audit log → Tamper evidence.
ARCHIVE_AUDIT_LOGS src/app/api/cron/retention/route.ts When true, the nightly retention cron uploads audit rows older than the 365d window to Vercel Blob (audit-archive/YYYY-MM-DD.json, private, random suffix), 5000 per run oldest-first, and then deletes exactly the ids it archived — never the whole < cutoff set, which would drop the un-archived remainder beyond the batch. Re-runs drain the backlog. Requires BLOB_READ_WRITE_TOKEN: without it the upload is skipped, nothing is deleted, and cron.retention.audit_archive_skipped_no_blob is logged. Default is keep, not delete — see AUDIT_LOG_HARD_DELETE.
AUDIT_LOG_HARD_DELETE src/app/api/cron/retention/route.ts true hard-deletes aged-out audit rows with no archive at all, in DELETE_BATCH chunks. This is the only setting that destroys audit history outright. Unset — and with no successful archive — aged audit rows are simply kept, which is the deliberate default: the audit log is the tamper-evidence trail, and a nightly sweep that silently dropped it is the bug this replaced. Setting it alongside ARCHIVE_AUDIT_LOGS is redundant.
BLOB_READ_WRITE_TOKEN src/app/api/cron/retention/route.ts, src/app/api/cron/run-exports/route.ts, src/app/api/internal/org/export/async/[id]/route.ts Vercel Blob store token, provisioned with the store (dashboard → Storage), not generated. Three paths use it and none fails loudly without it: the audit archive above refuses to delete what it could not upload; run-exports falls back to keeping the export manifest as JSON inside the org_exports row, which is fine in dev but can exceed Neon's row-size cap for a large org (that export then fails outright); and the authenticated download streams private blobs with this token, so an export written with a token and served without one answers 502/404 — the row exists and the customer can never fetch it.
MESSAGE_CONTENT_TTL_DAYS src/app/api/cron/retention/route.ts Message-content retention (GDPR data-minimization). A number of days; the retention sweep then redacts recipient + body PII in place on older messages — to_email, to_number, subject, bodies, cc, bcc, headers, error_message, the raw provider_response (it embeds the recipient's address and the read APIs return it verbatim), attachments_meta, media_urls and metadata — 5000 rows per run, re-runs draining the rest. The row, its status and its timestamps survive, so analytics and billing are unaffected, and scheduled / queued / sending rows are skipped so a far-future send is never stripped of the destination it still needs. Redacted bodies come back as null from GET /v1/emails/{id}?include=body. No longer purely opt-in: the effective window per org is min(plan logRetentionDays, this value when set) — Free redacts at 7 days and Pro at 90 with this unset, which is the retention /pricing sells. PAYG, Enterprise, comped owners and unrecognised plan_status values carry no plan promise, so for them this stays the only window and unset still means never redact — an unknown tier must not silently inherit Free's 7 days.
MESSAGE_EVENTS_TTL_DAYS src/app/api/cron/retention/route.ts How long message_events rows — the open/click trail, holding the recipient's raw IP, user agent and clicked URL — survive the nightly sweep. Default 90; empty, non-positive or garbled values fall back to 90, so a typo cannot disable it, only the value can lengthen or shorten it. 20 000 rows per run, re-runs draining the rest. Analytics read these rows, so a shorter window shortens the open/click history the dashboard can show.
INBOUND_TTL_DAYS src/app/api/cron/retention/route.ts How long received messages survive the nightly sweep — one window covering both inbound_messages (full parsed bodies of third-party email, up to 10 MB each) and inbound_sms. Default 90, same fallback behaviour. suppressions is never touched by this sweep, so deleting the inbound row that carried a STOP does not forget the opt-out.
STRANDED_SIGNUP_REAP src/app/api/cron/retention/route.ts, src/lib/auth/signup-reap.ts Opt-in for the stranded-signup sweep — the only sweep in the tree that deletes an account rather than pruning a log. Unset (or anything but 1) still runs the candidate query and logs auth.signup_reap.dry_run with the ids it would delete, deleting nothing; run a cycle that way and read the log first. 1 lets it hard-delete self-serve signups that never verified their address, are >30d old, never held a session, and whose orgs are solo, key-less and message-less. The destructive path also requires a real CRON_SECRET bearer in every environment, not just production, because dev and prod share one Neon database and cronAuthorized waves an unauthenticated local curl through when the secret is unset. See Cron jobs → Stranded-signup sweep.
SES_IP_POOL_DRY_RUN src/lib/providers/ses-ip-pool.ts true skips real AWS calls when creating / inspecting / deleting dedicated IP pools: create returns a row parked in provisioning, the warmup cron's status poll returns null, delete is a no-op. Absent AWS credentials do the same thing implicitly — the guard is !AWS_ACCESS_KEY_ID || !AWS_SECRET_ACCESS_KEY || dry run — so this is only for the case where the keys are real and you don't want the five billable AWS mutations a pool creation makes. Leave it unset in production, or a customer who paid for a dedicated IP never gets one and nothing says so.
IP_POOL_MAX_IPS_PER_ORG src/lib/api/ip-pool-cap.ts Org-wide ceiling on total dedicated SES IPs — the sum of ip_count across all of an org's pools, the pool being requested included. Nothing bills per IP yet (per-IP billing is a pending product decision), so this cap is the only thing between one org and 100 IPs of raw AWS spend ($2,500/mo). Default 2; empty or garbled values fall back to 2 rather than failing open, and 0 refuses every new pool — the switch for a spend incident. Read per request, never at module load, so no warm instance holds a stale value. Over-cap creates answer 402 DEDICATED_IP_LIMIT_EXCEEDED before any AWS call is made.
SES_CONFIGURATION_SET src/lib/providers/domain.ts, src/app/api/cron/domain-verify-poll/route.ts Name of the SES configuration set carrying the SNS event destination. A newly created domain identity is pointed at it. Unset means SES has nowhere to publish delivery, bounce and complaint events — so SNS_TOPIC_ARN_ALLOWLIST above guards topics nothing ever writes to, and statuses stay at sent for a second, independent reason. Existing identities are not touched when it changes; the domain-verify-poll cron is the only thing that back-attaches it to identities predating the attach, capped at 12 consecutive failures before retiring into domains.config_set_attach_state = 'attempt_cap'.
SES_EVENT_TOPIC_NAME / AWS_ACCOUNT_ID src/lib/providers/ses-ip-pool.ts Topic name (not ARN — it is per-region) plus account id, used to derive the SNS destination for a dedicated-IP pool's own configuration set. A pool-bound send pins that set, overriding the identity default, so without these a dedicated-IP customer gets no events at all. Both must be set for it to apply; opt-in and never fatal.
SMS_CONFIGURATION_SET src/lib/providers/sms.ts AWS End User Messaging configuration set named on both text and MMS sends (SendTextMessageCommand / SendMediaMessageCommand). Unset, sends still go out but produce no delivery-status events — statuses freeze at sent with nothing logged, so treat an unset value in production as a misconfiguration once the SNS topics are wired.
DLT_PROVIDER src/lib/providers/dlt.ts gupshup (default) or none. Selects the India DLT provider HTTP client. none records local rows without provider calls.
GUPSHUP_API_KEY / GUPSHUP_USER_ID src/lib/providers/dlt.ts Required when DLT_PROVIDER=gupshup.
DLT_ENFORCE src/lib/api/dlt-gate.ts false to bypass the send-time DLT template check for +91 recipients (dev / migration). Default true.
DLT_DRY_RUN src/lib/providers/dlt.ts true skips provider HTTP calls; rows land in submitted and the reconcile cron is a no-op.
LOG_LEVEL src/lib/log.ts One of debug, info, warn, error. Default info.
SENTRY_DSN src/lib/observability.ts Enables Sentry capture for warn / error log lines and logError(...) calls. Requires npm install @sentry/nextjs. Unset = no-op.

Tracking telemetry

/api/track/* is not in the proxy.ts matcher, so the route handlers are the only layer that can see abuse of the pixel / redirect endpoints. Three log events to alert on:

Event Level Means
track.open.rejected warn A pixel hit was not recorded. reason is missing_signature (no ?s=, hatch off), invalid_signature (a ?s= that doesn't verify — a forgery attempt, never a legacy pixel), or legacy_window_closed (TRACK_OPEN_ALLOW_UNSIGNED is set but the sunset date has passed).
track.open.unsigned_accepted warn An unsigned open was recorded because the escape hatch is on. Every one of these is an open anybody could have forged.
track.click.rejected warn A click redirect was refused for a bad/absent signature — i.e. someone probing the endpoint as an open-redirect for phishing off a customer's sender domain.

Each line carries message_id, signature_present, and the resolved client IP (same value the route already persists to message_events.ip_address).

All three are sampled: an in-process token bucket caps each route at 20 lines per minute per warm instance. The events are attacker-triggerable, so an unsampled logger would make a forged-pixel flood into a logging incident. A real spike is still obvious — the line count pins to the cap. A Redis-backed sampler was deliberately not used: it would add an I/O round trip to the cheapest path in the route, which is the wrong shape of defence against a flood.

Notes

  • Rate limiting is gated on Upstash envs — absent Upstash envs, all API calls pass without throttling. Per-plan limits (free/pro/enterprise) auto-select from resolveOrgPlan()organizations.plan_status, overridden to enterprise for a comped owner.
  • Test-mode API keys (sok_test_*) skip provider calls and usage limits — no AWS creds needed to test key plumbing.
  • OAuth providers are opt-in — Google/GitHub sign-in buttons render only when the respective client_id + secret pair is set.
  • Cron endpoints are closed without CRON_SECRETcronAuthorized returns NODE_ENV !== "production" when the secret is absent, so an unset secret on the production deploy rejects every caller, Vercel Cron included. All thirteen jobs then 401 on every tick with no alert, because a 401 is an ordinary response and the platform records the invocation as having happened: scheduled messages never leave scheduled, webhook retries never fire, overage is never metered, retention never prunes. Set it before the first tick. Outside production the absent secret is permissive, so local curl works with no setup.
  • Vercel: use vercel env pull .env.local to sync from project env.