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.