All routes live in web/app/api/ and run on Vercel. The Lightsail server
exposes three more, listed at the end.
There is no public API. Every route is called by one of four callers: the browser session, the external scheduler, the queue worker, or Razorpay.
Four different schemes, one per caller. There is no shared middleware; each route checks its own.
| Scheme | Header | Used by | Checked with |
|---|---|---|---|
| Session | cookie | Browser | auth() from Auth.js |
| Cron secret | Authorization: Bearer <CRON_SECRET> |
cron-job.org | secureEquals |
| Sync secret | x-secret: <SYNC_SECRET> |
Queue worker, intake server | secureEquals |
| Webhook signature | x-razorpay-signature |
Razorpay | HMAC SHA256 |
secureEquals (web/lib/secure-compare.ts) is a constant time comparison. A
plain === leaks secret length and content through timing.
Every secret compare treats a missing or unconfigured secret as unauthorized rather than throwing, so a misconfigured deploy returns 401 instead of 500.
Called by an external scheduler. Both accept GET and POST because scheduler
services vary in what they send.
Reads new mail for every eligible Gmail account and acts on it.
Auth: Authorization: Bearer <CRON_SECRET>
Timeout: maxDuration = 300, region sin1
An account is eligible only if its user has both a Gmail integration and a Signal integration. Accounts without both are skipped silently.
The route has two modes, chosen by whether INTAKE_SERVER_URL is set:
| Mode | When | Behaviour |
|---|---|---|
queued |
INTAKE_SERVER_URL set |
Posts all jobs to the intake server and returns immediately |
inline |
INTAKE_SERVER_URL blank |
Runs each account in the request itself |
Queued response:
{ "mode": "queued", "accountsQueued": 3 }Inline response:
{
"mode": "inline",
"accountsProcessed": 1,
"successfulRuns": 1,
"failedRuns": 0,
"runs": [
{
"userId": "...",
"integrationId": "...",
"emailsFound": 4,
"summariesSent": 2,
"status": "success",
"totalTokens": 8123
}
]
}| Status | Meaning |
|---|---|
| 200 | Ran. accountsQueued: 0 is normal and means nothing was eligible |
| 401 | Bad or missing CRON_SECRET |
| 500 | Handler threw. Most often the intake server returned non 2xx, which usually means a SYNC_SECRET mismatch or a stale INTAKE_SERVER_URL |
Before collecting jobs the route calls expireStalePendingActions(), so old
unanswered drafts are cleaned up on the same tick.
Drains Signal replies and applies them.
Auth: Authorization: Bearer <CRON_SECRET>
Timeout: maxDuration = 60, region sin1
{ "usersPolled": 1, "repliesHandled": 2 }This route only polls users who have a pending draft waiting. Polling every user would create lock contention inside signal-cli, and duplicate Signal numbers are removed before polling for the same reason.
Reply grammar is a 4 character ref code followed by a command:
| Reply | Parsed as |
|---|---|
A3X9 send, A3X9 yes |
draft_send |
A3X9 no, A3X9 discard |
draft_discard |
A3X9 make it shorter |
draft_revise with those instructions |
A3X9 |
ref_usage, replies with help text |
| No valid ref code | Ignored |
Ref codes are 4 characters from ABCDEFGHJKMNPQRSTUVWXYZ23456789. That
alphabet drops I, L, O, 0 and 1, which are the characters people
mistype when reading a code off a phone screen.
Called by the queue worker on the Lightsail box, not by a browser.
Runs the agent for one Gmail account. This is where the real work happens: the worker does not run the agent itself, it calls back here.
Auth: x-secret: <SYNC_SECRET>
Timeout: maxDuration = 300, region sin1
Request:
{ "userId": "uuid", "integrationId": "uuid" }Both fields are validated with Zod and must be non empty strings.
Response is an AgentRunSummary:
{ "emailsFound": 4, "summariesSent": 2, "status": "success", "totalTokens": 8123 }| Status | Meaning |
|---|---|
| 200 | Run finished |
| 400 | Payload failed schema validation |
| 401 | Bad or missing SYNC_SECRET |
| 500 | Run threw. The worker retries, so this is not final |
Records queue job lifecycle in Postgres. Redis trims completed jobs to the last 100 and failures to the last 500, so job history would disappear. This route makes it durable.
Auth: x-secret: <SYNC_SECRET>
{
"bullJobId": "42",
"userId": "uuid",
"integrationId": "uuid",
"status": "completed",
"attempts": 1,
"error": null
}status is one of the values in SYNC_JOB_STATUS_VALUES: enqueued,
active, completed, failed, dead.
failed and dead are different. BullMQ fires its failed event on every
attempt, so the worker reports failed for a retry and dead only once the
job has used its final attempt.
Returns { "ok": true }, or 400 on schema failure, 401 on bad secret, 500 on
write failure.
Require a signed in browser session. All return 401 without one.
Runs the standalone agent console on the dashboard. Not part of the email pipeline.
{ "workflow": "operations_copilot", "task": "...", "context": "..." }Limits: task must be at least 12 characters, task at most 4000, context
at most 12000, output capped at 500 tokens.
{ "result": { }, "usage": { }, "workflow": "operations_copilot", "provider": "groq" }Returns 400 if the task is too short or the input too large.
The Auth.js catch-all. web/app/api/auth/[...nextauth]/route.ts re-exports the
GET and POST handlers from web/auth.ts and adds nothing of its own.
It serves the sign-in, callback, session, and sign-out endpoints:
| Path | Purpose |
|---|---|
/api/auth/signin |
Starts Google sign-in |
/api/auth/callback/google |
Google redirects here after consent |
/api/auth/session |
Current session as JSON, used by useSession() |
/api/auth/csrf |
CSRF token for the sign-in form |
/api/auth/signout |
Clears the session |
/api/auth/callback/google is the URI to register in Google Cloud Console:
http://localhost:3000/api/auth/callback/google locally, and the same path on
your production domain.
The jwt callback in web/auth.ts swaps the provider's random subject for the
user id already stored against that email, so signing in twice does not create a
second user. See resolveUserIdByEmail in web/db/queries/users.ts.
Do not confuse these with /api/auth/composio, which is the Gmail connection
and unrelated to sign-in.
{ "signedIn": true }Starts the Gmail connection. Redirects straight to Google's OAuth screen, using
Composio's long_redirect_url option so the Composio-hosted page is skipped. If
that endpoint has been retired for the auth config, it falls back to a Composio
Connect Link. Returns 502 if no redirect URL comes back.
Composio returns here after the user grants Gmail access. Redirects back into the app.
Proxies the signal-cli QR image so the browser never talks to signal-cli
directly. Returns the raw image bytes with Cache-Control: no-store.
Times out after 15 seconds. Returns 502 if signal-cli responds with an error, 500 on anything else.
Creates a Razorpay subscription and records intent.
{ "subscriptionId": "sub_...", "shortUrl": "https://rzp.io/..." }The user's plan is not upgraded here. It flips to pro only when the
webhook confirms payment. Trusting the client to report its own payment would
be the obvious hole.
Auth: HMAC SHA256 signature in x-razorpay-signature, verified against the
raw body before parsing.
Duplicates are handled with the x-razorpay-event-id header and a
processed_webhook_events table. A repeat delivery returns:
{ "ok": true, "duplicate": true }Payment providers retry, so a webhook that is not idempotent will double apply.
| Status | Meaning |
|---|---|
| 200 | Processed, or already seen |
| 401 | Signature check failed |
| 500 | Processing threw |
Served by server/server.ts behind nginx. Only reachable through port 80;
Express itself binds to 127.0.0.1:3001.
{ "ok": true }This endpoint does not check Redis. It returns ok when the queue is
completely unreachable. Do not use it as a real health check. To actually
verify the stack, check the nginx access log for POST /sync returning 200 and
confirm the completed count in Redis is rising.
Auth: x-secret: <SYNC_SECRET>
{ "jobs": [{ "userId": "uuid", "integrationId": "uuid" }] }Malformed entries are filtered out rather than rejecting the batch. One BullMQ job is added per entry.
{ "ok": true, "enqueued": 3 }An empty jobs array returns enqueued: 0 with a 200. Combined with the
/health behaviour above, this means a completely broken queue can still pass
both checks.
Job options: 3 attempts, exponential backoff starting at 5s, keep the last 100 completed and last 500 failed.
Returns 401 on bad secret, 500 if the enqueue fails.
Bull Board dashboard, behind HTTP basic auth.
Only mounted when both QUEUE_DASHBOARD_USER and QUEUE_DASHBOARD_PASSWORD
are set. If either is missing the route does not exist at all, so it cannot be
exposed unauthenticated by accident. The server logs a warning at startup when
it skips mounting.
This page reads Redis to render, which makes it the most useful liveness check in the system.