Every server-side route handler in src/app/api/ and the auth
callback. Routes are organized by directory.
- JSON in, JSON out. All handlers parse
request.json()and returnNextResponse.json(...). - Auth via the server Supabase client. Each handler calls
supabase.auth.getUser()(which validates the JWT). A401is returned if no user. - Zod validation. Request bodies are validated with schemas
from
src/lib/validations.ts. A400is returned on parse failure with{ error: "Invalid request", details: ... }. - Rate limiting (where applied) is in-memory via
src/lib/rate-limiter.ts. Limits are per-user (keyed byuser.id). The default is 100 requests / 60s forleads:get. See Rate limiting. - Plan tier limits are enforced in
POST /api/leads. See Plan limits. - Error envelope.
{ error: string, ... }on failure, with appropriate HTTP status code.
File: src/app/auth/callback/route.ts
PKCE exchange endpoint. Receives the redirect from Supabase GoTrue after magic link, Google OAuth, password reset, or passkey.
Query params:
| Name | Type | Notes |
|---|---|---|
code |
string | PKCE code from Supabase. Required. |
next |
string | Optional. Path to redirect to after success. Defaults to /dashboard. |
Responses:
307 redirectto<origin>/<next>on success.307 redirectto<origin>/login?error=auth_callback_failedon error.
Notes: Uses the server Supabase client
(src/lib/supabase/server.ts). The browser must reach this route
unauthenticated, so src/middleware.ts:20 excludes auth/callback
from the middleware matcher.
File: src/app/api/leads/route.ts
List the current user's active leads. Rate-limited to 100 requests / 60 seconds per user.
Auth: required (401 if no user).
Query params: none.
Response:
200 OK
{
data: Database["public"]["Tables"]["leads"]["Row"][];
}Implementation:
supabase.auth.getUser()→ user (or 401).apiRateLimit("leads:get", user.id, 100, 60_000)— returns 429 if exceeded.supabase.from("leads").select("*").eq("user_id", user.id).eq("is_active", true).order("created_at", { ascending: false })- Return rows.
File: src/app/api/leads/route.ts
Insert a new lead. Enforces the plan tier limit server-side.
Auth: required.
Request body (validated by leadSchema):
{
full_name: string; // required
email?: string; // optional
phone?: string; // optional
source: string; // required, enum
pipeline_stage?: string; // optional, default "new_lead"
notes?: string; // optional
next_action?: string; // optional
next_action_date?: string; // optional, ISO timestamp
}Response:
201 Created
{
data: Database["public"]["Tables"]["leads"]["Row"];
}
// Or 403:
{
error: "Plan limit reached",
plan: "free",
currentCount: 10,
maxAllowed: 10,
}Implementation:
supabase.auth.getUser()→ user (or 401).- Parse body, validate with
leadSchema. 400 on parse failure. checkPlanLimit()— if!allowed, return 403.supabase.from("leads").insert({ ...validated, user_id: user.id }).select().single()- The
check_free_tier_lead_limitPostgres trigger fires on insert. If it raises, the response is a 500 with theFree tier limited to 10 active leads...message.
File: src/app/api/leads/[id]/route.ts
Get a single lead by id. RLS scopes the read to the current user.
Auth: required.
Response:
200 OK
{
data: Database["public"]["Tables"]["leads"]["Row"];
}
// 404 if the row doesn't exist or isn't owned by the user.File: src/app/api/leads/[id]/route.ts
Partial update of a lead. All fields optional.
Auth: required.
Request body (validated by leadUpdateSchema — all fields
optional):
{
full_name?: string;
email?: string | null;
phone?: string | null;
source?: string;
pipeline_stage?: string;
notes?: string | null;
next_action?: string | null;
next_action_date?: string | null;
is_active?: boolean;
deleted_at?: string | null;
}Response:
200 OK
{
data: Database["public"]["Tables"]["leads"]["Row"];
}Notes: The plan-limit trigger does NOT fire on update. Drag
operations in /pipeline are not rate-limited at the database
level.
File: src/app/api/paymongo/checkout/route.ts
Create a PayMongo Checkout Session for the AgentFlow Pro subscription.
Auth: required.
Request body: none.
Response:
200 OK
{
url: string; // Checkout Session URL
}Implementation:
supabase.auth.getUser()→ user.- Lazy-init the PayMongo client (
getPayMongo()fromsrc/lib/paymongo.ts). getOrCreatePayMongoCustomer(user)— looks upprofiles.paymongo_customer_id; if missing, creates a PayMongo customer and updates the profile via service-role key.createCheckoutSession(customer, user.email, returnUrl)— mode:subscription, line_items:PAYMONGO_CONFIG.price(the $8/mo Pro tier), success_url:<origin>/settings/billing?upgraded=true, cancel_url:<origin>/settings/billing.- Return
{ url: session.url }.
The client (/settings/billing/page.tsx) does
window.location.href = url to start the checkout flow.
File: src/app/api/paymongo/webhook/route.ts
PayMongo webhook receiver. Signature-verified; the raw body is read
via request.text() and passed to
verifyPayMongoSignature(rawBody, sig, PAYMONGO_WEBHOOK_SECRET).
Auth: PayMongo signature verification (no Supabase auth).
Handled events:
| Event | Handler |
|---|---|
checkout.session.completed |
handleCheckoutCompleted() — updates profiles.plan = 'pro', paymongo_customer_id, paymongo_subscription_id. |
customer.subscription.deleted |
handleSubscriptionDeleted() — sets profiles.plan = 'free'. |
invoice.payment_failed |
handlePaymentFailed() — logs a warning and (optionally) downgrades the user. |
All handlers use the service-role key to bypass RLS, since the profile being updated may not match the (absent) user session.
File: src/app/api/cron/daily-digest/route.ts
Sends the daily digest email to all users who have leads with
actions due today or overdue. Authenticated by
Authorization: Bearer ${CRON_SECRET}.
Auth: Bearer token from CRON_SECRET env var. Returns 401
otherwise.
Response:
200 OK
{
sent: number; // # of users emailed
failed: number; // # of users whose email failed
total: number; // # of users with due actions
}Implementation:
- Validate
Authorizationheader. - Lazy-init Resend (
getResend()fromsrc/lib/resend.ts). - Query all users who have actions due today or earlier (across all users, using service-role key).
- For each user, query their pending actions grouped by lead.
sendDailyDigest(user.email, items)— HTML email via Resend.- Return counts.
The cron schedule is configured externally (Vercel Cron, GitHub Actions schedule, etc.) — see DEPLOYMENT.md.
File: src/app/api/health/route.ts
Liveness probe. No auth.
Response:
200 OK
{
status: "ok",
timestamp: string; // ISO
}Used by the scheduled-health-check.yml workflow and any external
monitoring.
src/lib/constants.ts:
export const PLAN_LIMITS = {
free: { maxActiveLeads: 10, maxPipelines: 10, price: 0 },
pro: { maxActiveLeads: Infinity, maxPipelines: Infinity, price: 800 }, // $8/mo
team: { maxActiveLeads: Infinity, maxPipelines: Infinity, price: 0 }, // internal placeholder
} as const;
export type PlanType = keyof typeof PLAN_LIMITS;Used by:
checkPlanLimit()insrc/lib/plan-limit.ts— client-side read.POST /api/leads— server-side re-check.check_free_tier_lead_limit()Postgres trigger — final defense.- Landing page pricing section.
src/lib/rate-limiter.ts:
apiRateLimit(
bucket: "leads:get", // namespace
userId: string, // key
limit?: number, // default 100
windowMs?: number, // default 60_000 (1 min)
): { success: boolean; remaining: number; resetAt: number }In-memory Map<string, { count, resetAt }>. Process-local.
In serverless / Vercel deployments, the Map is recreated per cold
start, so rate limits are per-instance. This is fine for the
current load (solo-agent CRM) but should be swapped for Redis if
the app scales horizontally.
Currently applied:
GET /api/leads— 100 req / 60s per user.
All endpoints return errors in the shape:
{ error: string; details?: unknown }with appropriate HTTP status codes:
| Code | Meaning |
|---|---|
400 |
Invalid request body (Zod parse failure) |
401 |
No authenticated user (or bad PayMongo signature) |
403 |
Plan limit reached |
404 |
Resource not found or not owned by user |
429 |
Rate limit exceeded |
500 |
Database or third-party error |
There is no explicit CORS configuration. All endpoints are
same-origin by design (Vercel hosts the app; the Supabase URL
is accessed from server-side handlers, not the browser). If you
add a public API, configure CORS explicitly in
next.config.mjs and consider an origin allowlist.
- AUTHENTICATION.md — how the auth cookie gets to the server and the right Supabase client to use per runtime.
- DATABASE.md — the three-layer plan-limit enforcement (the third layer is the Postgres trigger in migration 002).
- SECURITY.md — why the rate limiter is in-memory and what to do if it becomes a bottleneck.