API
The app that exists to be called by other machines.
The api app runs on port 3002. Deploy it to api.{yourdomain}.com.
apps/api is a Next.js app like the other two. It builds to a standalone
server and ships as a Docker image:
docker build --build-arg APP=api -t my-project/api .It is not serverless, and nothing about it assumes a particular host. It is a long-running Node process behind whatever reverse proxy the project uses.
Why it is separate
It renders nothing. Everything it serves is for another machine — a payment provider posting a webhook, a scheduler triggering a job, a load balancer probing readiness, eventually a mobile client.
Keeping that separate from the user-facing apps buys three things: a deliberately stricter security policy, since nothing it returns is a document; independent scaling, since webhook volume and page traffic have nothing to do with each other; and a stable public origin that does not move when the app is redesigned.
It does not buy you a way to reach the database from app or web. Those
are Next.js apps too — server components, server actions and route handlers all
talk to @kreogen/database directly. Reaching for the API from your own
front-end adds a network hop and a serialisation boundary for nothing. Use it
when the caller is genuinely external.
The middleware
apps/api/proxy.ts applies security headers and CORS to everything except
Next's own assets. It was the one app in the repository exposed to the internet
by design, and the last to get any of this.
Headers. The same Nosecone middleware as the browser-facing apps, but with its own Content Security Policy written out rather than spread from the shared one:
contentSecurityPolicy: {
directives: {
defaultSrc: ["'none'"],
frameAncestors: ["'none'"],
formAction: ["'none'"],
baseUri: ["'none'"],
sandbox: [],
},
}Nothing this app returns is a document, so denying every source outright is both accurate and stricter than anything derived from a policy that has to allow scripts and styles.
CORS. An allowlist, built from NEXT_PUBLIC_APP_URL and
NEXT_PUBLIC_WEB_URL. An origin not on it gets no CORS headers at all, which
is what makes a wildcard unnecessary:
const allowedOrigins = [
process.env.NEXT_PUBLIC_APP_URL,
process.env.NEXT_PUBLIC_WEB_URL,
].filter(Boolean);Webhook senders are servers. They send no Origin header and CORS does not
apply to them, so this list is only about browsers.
OPTIONS is answered in the middleware with a 204. Next would otherwise 405 a
preflight, and the browser reports that as an opaque CORS failure rather than
as the missing handler it is. The response also carries Vary: Origin, so a
shared cache cannot serve one origin's response to another.
Health and readiness
| Route | Answers | Touches |
|---|---|---|
/health | Is the process alive? | Nothing |
/ready | Can this instance serve traffic? | Postgres, Redis |
Point a container liveness probe at /health and a load balancer at /ready.
Swapping them means a database outage restarts every container instead of
draining traffic from them — see debugging.
Webhooks
Handlers live in apps/api/app/webhooks. The Stripe one at
/webhooks/payments is the worked example, and its shape is worth copying.
Verify the signature first, and reject a bad one with 400. A forged or mis-signed payload is permanently bad — the same bytes will never verify — and Stripe retries 5xx responses for days.
Answer 503, not 200, when the integration is unconfigured. 200 tells the provider the event was handled and stops the retries. A deploy that is merely missing its key would then discard live events permanently instead of having them redelivered once it is fixed.
Claim the event id before doing the work. Providers deliver at least once, not exactly once, so the same event arrives more than once in normal operation:
const claimEvent = async (event: Stripe.Event): Promise<boolean> => {
try {
await database.webhookEvent.create({
data: { id: event.id, provider: 'stripe', type: event.type },
});
return true;
} catch {
return false;
}
};WebhookEvent's primary key is the provider's event id, and the unique
constraint is what makes the claim atomic under concurrent deliveries. A
redelivery fails the insert, returns early, and answers { received: true, duplicate: true }.
If the handler then throws, the claim is released so the redelivery is processed rather than skipped as a duplicate. Without that, a transient failure mid-handler would be recorded as handled forever.
Processed ids are swept after thirty days by the cleanup job below — comfortably beyond any provider's retry window.
The event is deliberately not echoed back in the response. It carries customer identifiers, amounts and metadata, and no webhook response has a consumer that needs any of it.
Locally, the Stripe CLI forwards to the handler. bun dev --filter api starts
the listener alongside the dev server when the CLI is installed.
Scheduled work
Cron routes live in apps/api/app/cron and are ordinary HTTP endpoints.
Whatever invokes them — a GitLab scheduled pipeline, a systemd timer, a
Kubernetes CronJob — reaches them the same way the internet does, so they
authenticate with a shared secret rather than an assumption about the caller:
Authorization: Bearer $CRON_SECRETThe comparison is timing-safe, and the routes are closed by default: with
CRON_SECRET unset they answer 503 and log, rather than becoming public
endpoints anyone can trigger repeatedly. They accept GET and POST, because
invokers disagree about which to use and it is not worth a page of caveats.
/cron/cleanup sweeps expired sessions, verification tokens and pending
invitations, plus webhook ids past their retention window. Better Auth writes a
session row per sign-in and a verification row per email, and deletes neither
once they lapse — so those tables grow with traffic rather than with users, and
the indexes get slower for everyone.
Deletions use a 24-hour grace period rather than cutting at the instant of expiry, so clock skew between the app and the database cannot remove a session the app still considers valid.
Prefer a scheduled pipeline over in-app scheduling. Runs are logged, retryable
and alertable, and node-cron inside the app fires once per replica.
Adding an endpoint
import { requireSession } from '@kreogen/auth/session';
import { database } from '@kreogen/database';
export const GET = async () => {
await requireSession();
const users = await database.user.findMany();
return Response.json(users);
};The middleware sets headers and CORS; it does not authenticate. Every route
decides for itself, and a route that reads tenant data should take the scoped
client from requireOrganization() rather than filtering by hand.
Rate limit anything expensive or unauthenticated — see rate limiting.
Calling it from another app
Each app has NEXT_PUBLIC_API_URL in its .env.example, pointing at
http://localhost:3002 locally:
'use server';
import { env } from '@/env';
export const getUsers = async () => {
const response = await fetch(`${env.NEXT_PUBLIC_API_URL}/users`);
return response.json();
};Because it is a NEXT_PUBLIC_* value, Next inlines it into the client bundle
at build time — which would make every image environment-specific. The Docker
entrypoint substitutes it at container start instead, so one image serves
staging and production. Adding a new public variable means adding it to that
list too; see Docker.