--- title: API description: The app that exists to be called by other machines. type: reference related: - /en/docs/packages/rate-limit - /en/docs/packages/payments - /en/docs/deployment/docker --- # API 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: ```sh 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: ```ts 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: ```ts 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](/en/docs/packages/observability/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: ```ts const claimEvent = async (event: Stripe.Event): Promise => { 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_SECRET ``` The 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 ```ts title="apps/api/app/users/route.ts" 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](/en/docs/packages/rate-limit). ## Calling it from another app Each app has `NEXT_PUBLIC_API_URL` in its `.env.example`, pointing at `http://localhost:3002` locally: ```ts title="apps/app/app/actions/users.ts" '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](/en/docs/deployment/docker). --- For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md) For an index of all available documentation, see [/llms.txt](/llms.txt)