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 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 toenterprisefor 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_SECRET—cronAuthorizedreturnsNODE_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 leavescheduled, 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 localcurlworks with no setup. - Vercel: use
vercel env pull .env.localto sync from project env.