Data Flow

Outbound email / SMS

Customer → POST /api/v1/emails
         ↓
withApiAuth:
  1. Parse Bearer token
  2. validateApiKey() — sha256 hash lookup in api_keys
  3. Build ApiContext { orgId, apiKeyId, environment }
  4. checkRateLimit(orgId) — Upstash sliding window (100/min)
         ↓
Route handler:
  5. Check Idempotency-Key header → replay cached response if hit
  6. checkUsageLimit(orgId, "email") — enforce free-tier quota
  7. zod parse sendEmailSchema
  8. If environment === "live": sendEmail() via SES v2
     else: simulate with providerMessageId = "test_msg_..."
  9. INSERT into messages (status: "sent")
 10. incrementUsage() — fire-and-forget UPSERT into usage_records
 11. storeIdempotency() — cache response for 24h
 12. Return { id, channel, status, created_at }

Error path: on provider exception, INSERT message with status: "failed" and return 500.

Scheduled sends

POST /api/v1/emails { scheduled_at: "2026-05-01T12:00:00Z" }
         ↓
  1. Validate + (if live) check usage quota
  2. INSERT messages (status: "scheduled", scheduled_at: ...)
  3. Return { id, status: "scheduled", scheduled_at }
         ↓
Cron: GET /api/cron/send-scheduled  (every minute)
  1. SELECT ≤100 messages WHERE status='scheduled' AND scheduled_at <= now()
  2. For each: call provider, UPDATE status, sentAt
  3. incrementUsage + fanoutWebhookEvent('message.sent')

Webhook fan-out with retries

event fires → fanoutWebhookEvent(orgId, event, data)
  1. Select matching enabled webhook_endpoints
  2. For each: INSERT webhook_deliveries (status: pending, attempts: 0)
  3. attemptDelivery() — fetch POST + signature headers (10s timeout)
  4. On success: UPDATE status=delivered, deliveredAt=now
  5. On failure: attempts++, next_attempt_at = now + backoff
     - If attempts >= 5 → status=failed, next_attempt_at=null
         ↓
Cron: GET /api/cron/retry-webhooks  (every 5 minutes)
  1. SELECT pending rows WHERE next_attempt_at <= now()
  2. attemptDelivery() again for each

Overage reporting (monthly)

Cron: GET /api/cron/report-overage  (06:00 UTC on day 1)
  1. For each organizations WHERE plan_status='pro' with stripeCustomerId:
     a. Read usage_records for current period, live env
     b. email_overage = max(0, emails - 10_000)
     c. sms_overage = max(0, sms - 1_000)
     d. stripe.v1.billing.meterEvents.create(...) for each > 0

Inbound delivery status (SES → SNS → app)

SES sends email → publishes event to SNS topic
SNS → POST /api/webhooks/ses
         ↓
  1. If SubscriptionConfirmation → fetch SubscribeURL, ack
  2. If Notification:
     • Parse Message JSON
     • Map notificationType → status:
       - Delivery  → "delivered" + message.delivered event
       - Bounce    → "bounced"   + message.bounced event
       - Complaint → "bounced"   + message.bounced event
       - Reject    → "failed"    + message.failed event
     • UPDATE messages WHERE provider_message_id = ...
     • fanoutWebhookEvent() — POST to every matching webhook_endpoint
       with HMAC-SHA256 signature

Billing: free → pro upgrade

User clicks Upgrade → POST /api/internal/billing
         ↓
  1. Find or create Stripe customer
  2. Persist stripeCustomerId on organizations
  3. stripe.checkout.sessions.create(subscription, STRIPE_PRO_PRICE_ID)
  4. Return { url } — client redirects
         ↓
Customer completes checkout
         ↓
Stripe → POST /api/webhooks/stripe
  • constructEvent verifies signature
  • checkout.session.completed → UPDATE organizations SET plan_status = 'pro'
  • customer.subscription.deleted → downgrade to 'free'

Registration

POST /api/auth/register { name, email, password, phone? }
  1. Validate; canDeliverTo(phone)          ── refuse a number we can never text
  2. Rate-limit register:ip + register:email
  3. reserveSendSlot(phone, ip)             ── SPEND the SMS tokens FIRST:
                                               nothing below can be un-created
  4. Bcrypt hash password (cost 12)
  5. sql.transaction([                      ── one commit, Neon HTTP has no
       INSERT user,                            db.transaction
       INSERT organization (slug = email prefix + org ID tail),
       INSERT org_member (role: "owner"),
       INSERT project (is_default),
       INSERT domain (verified sandbox),
     ])
  6. sendSms(code) → then sql.transaction([supersede prior, INSERT pvc row])
  7. Set-Cookie sendoka_pv = <phone_verifications.id>   ── NOT the user id
     → 201 { phoneVerificationRequired, emailVerificationRequired }
                                                       ── no session yet

POST /api/auth/verify-phone { code }        ── identified by the pending cookie
  1. Atomic claim: UPDATE ... SET attempts = CASE WHEN hash matches THEN attempts
                                              ELSE attempts + 1 END
                   WHERE id = ? AND consumed_at IS NULL AND attempts < 5
  2. timingSafeEqual on the returned hash
  3. sql.transaction([consume code, stamp users.phone_verified])
  4. sendEmailCode → INSERT ev row, mail 6 digits, supersede OLDER rows
     (created_at-bounded: "every row but mine" made two overlapping sends
     retire each other and killed both codes). No row minted at all →
     createPendingEmailAnchor, so the next screen still has a resend.
  5. Clear sendoka_pv, Set-Cookie sendoka_ev = <email_verifications.token>.
     NO trusted-device cookie while the address is still owed — that cookie is
     the emailed-login-code exemption, and one proof of two does not earn it.
     → 200 { emailVerificationRequired: true, emailCodeSent,
             verificationSessionReady }

POST /api/auth/verify-email-code { code }   ── identified by the pending cookie
  1. Same atomic claim + timingSafeEqual, capped at 5 attempts. A claim that
     cannot be RECORDED retries once, then answers 503 `unavailable` — never
     "locked", which would tell the user to discard a live code.
  2. sql.transaction([consume row, stamp users.email_verified])
  3. Clear sendoka_ev, and mint the trusted-device cookie — unless the PHONE
     step is still owed, mirroring verify-phone's guard in the other direction
  4. maybeNotifyNewSignupAsync → the internal "New Sendoka signup" mail, iff
     signup_source is set, both columns are stamped, and the conditional
     UPDATE users SET signup_notified_at claims it first. Dispatched via
     after(), because the claim commits BEFORE the send: a floating promise
     frozen in between is a signup that is never announced, with no retry.

POST /api/auth/[...nextauth] (credentials login)
  1. login / login_email rate limits → SSO check → emailed code → bcrypt
  2. Phone gate: throw PHONE_UNVERIFIED if the number is still unverified
  3. Email gate: throw EMAIL_UNVERIFIED if signup_source is set and
     email_verified is NULL
  4. jwt callback: look up org_member → embed orgId in token
  5. session callback: expose user.id + user.orgId

With SYSTEM_SMS_FROM unset, steps 1/3/6/7 and the phone gate all drop out; registration mails the email code itself and ends at the email step. Nothing releases a session before verify-email-code.

Recovery for an abandoned signup: POST /api/auth/phone-challenge and POST /api/auth/email-challenge each re-present email + password + the emailed login code, and re-issue the matching pending cookie. Both apply the gates in authorize()'s order — email-challenge refuses while the phone step is owed, so the second proof cannot be completed ahead of the first — and both spend the login_challenge bucket rather than authorize()'s login bucket, because the login form POSTs to them automatically and charging one user action twice left the recovery endpoint 429-ing on the third try.