Base URL (dev): http://localhost:4000
Two mechanisms:
POST /api/auth/login{ email, password }→{ user, accessToken }and sets an httpOnly refresh cookie.POST /api/auth/refresh(cookie) → a fresh{ user, accessToken }.POST /api/auth/logout— revokes the refresh token.- Send the access token as
Authorization: Bearer <accessToken>on authenticated routes.
- Create keys in the app (API keys page) or
POST /api/me/api-keys{ name }. - The full key (
efk_…) is shown once. Send it asX-API-Key: efk_…(orAuthorization: Bearer efk_…). - A key can only read what its owning user can (same per-form permissions). Read-only.
Roles: ROOT, STAFF, USER. Per-form permission keys (granted to STAFF; ROOT and the
form's creator have all implicitly):
form.view, form.edit, form.delete, response.view, response.export,
response.delete, analytics.view, webhook.manage.
Access rule: a staff member with no access to a form gets 404; with access but missing the specific key, 403.
| Endpoint | Requires | Returns |
|---|---|---|
GET /api/v1/forms |
— (owner's forms) | accessible forms |
GET /api/v1/forms/:id |
form.view |
form meta + latest published schema |
GET /api/v1/forms/:id/responses |
response.view |
paginated responses |
GET /api/v1/forms/:id/responses/:responseId |
response.view |
one response |
GET /api/v1/forms/:id/analytics?range=7|30|90 |
analytics.view |
analytics payload |
Example:
curl -H "X-API-Key: efk_…" http://localhost:4000/api/v1/formsGET /api/public/forms/:slug— a published form's title/description + schema.POST /api/public/forms/:slug/submit{ answers, completionTime? }— submit. Include a bearer token to attribute the submission to a logged-in user.POST /api/public/forms/:slug/view— record a view (used by the form page beacon).
Configure per form (needs webhook.manage). On submission.created / submission.updated
each active subscribed webhook receives a POST with headers:
X-ExpressForms-Event— the event name.X-ExpressForms-Delivery— the delivery id.X-ExpressForms-Signature—sha256=<hex>, an HMAC of the raw body keyed by the webhook secret.
Verify in Node:
import crypto from 'node:crypto';
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));Payload: { event, form: { id, title, slug }, response: { id, answers, submittedAt, submittedBy }, occurredAt }.
Failed deliveries retry with exponential backoff (up to 5 attempts).
GET /api/health— liveness (public).GET /api/health/ready— readiness incl. DB check (503if the DB is down).GET /api/health/metrics— request counts, status classes, avg latency, uptime (ROOT).
GET /api/audit-logs?action=&actor=&page=&limit=— ROOT only. Records auth, user, form, permission, response, webhook, and API-key actions.
| Var | Required | Notes |
|---|---|---|
DATABASE_URL |
yes | PostgreSQL connection string |
JWT_ACCESS_SECRET |
yes | ≥32 chars (openssl rand -hex 32) |
PORT |
no | default 4000 |
NODE_ENV |
no | development | test | production |
ACCESS_TOKEN_TTL |
no | default 15m |
REFRESH_TOKEN_TTL_DAYS |
no | default 7 |
ROOT_EMAIL / ROOT_PASSWORD |
no | seeded root account |
TRUST_PROXY |
no | false (dev) / hop count / IP list behind a proxy |
SMTP_URL |
no | when unset, emails are logged to EmailLog only |
MAIL_FROM |
no | From address for notification emails |
npm run backup (in backend/) dumps the database to
backups/expressforms-<timestamp>.sql via pg_dump (requires the PostgreSQL client
tools on PATH). Restore into an empty database with
psql "$DATABASE_URL" -f backups/<file>.sql.