API Reference
Complete backend API reference for the Skateboard Hono server — every endpoint with method, path, auth, request, and response.
API Reference
Complete reference for the Skateboard backend, a Hono server running on Node.js (>=24.0.0, ES modules, TypeScript). The server is defined entirely in backend/server.ts and started with:
node server.ts
Node runs the .ts source directly (no build step). The default SQLite adapter imports node:sqlite (DatabaseSync). From the repo root, npm run server delegates to the backend workspace’s start script, and npm run start runs the Vite frontend and backend concurrently.
Base URL
The server listens on the PORT env var (default 8000) on both IPv4 and IPv6 (hostname: '::'). All API routes are prefixed with /api.
Development: http://localhost:8000
In the frontend, the API base is configured in src/constants.json:
devBackendURL:http://localhost:8000/apibackendURL:/api(production, same-origin)
Authentication
Authentication uses a JWT stored in an HttpOnly cookie named token — there is no Authorization: Bearer header path. The cookie is set by the server on sign up / sign in and read by authMiddleware via the request cookies. Requests from the frontend must send credentials (credentials: 'include').
- JWT: hand-rolled HS256 using
node:cryptoHMAC-SHA256 (nojsonwebtokendependency). Payload is{ userID, exp }. Expiry is 30 days. - Cookie:
token, HttpOnly,sameSite: 'Strict',securein production,maxAge30 days. - If
JWT_SECRETis unset, protected endpoints return503. A missing, expired, or invalid token returns401.
CSRF protection
State-changing requests on protected routes require a CSRF token. A non-HttpOnly cookie csrf_token (sameSite: 'Lax') is set alongside token, and the matching value must be sent in the x-csrf-token header.
- CSRF tokens are generated with
crypto.randomBytes(32), stored in an in-memory map (24-hour expiry, hourly cleanup, LRU eviction at 50,000 entries), and validated withcrypto.timingSafeEqual. - The CSRF middleware skips
GETrequests and the/api/signup//api/signinpaths. - CSRF is only attached to:
PUT /api/me,POST /api/signout,POST /api/checkout,POST /api/portal. (POST /api/usageis not CSRF-protected — it hasauthMiddlewareonly.)
Account lockout
Sign-in failures are tracked in memory per email. After 5 failed attempts the account is locked for 15 minutes; locked requests return 429 with a Retry-After header.
Endpoints
| Method | Path | Auth | CSRF |
|---|---|---|---|
GET |
/api/health |
No | No |
POST |
/api/signup |
No | No |
POST |
/api/signin |
No | No |
POST |
/api/signout |
Yes | Yes |
GET |
/api/me |
Yes | No (GET) |
PUT |
/api/me |
Yes | Yes |
POST |
/api/usage |
Yes | No |
POST |
/api/checkout |
Yes | Yes |
POST |
/api/portal |
Yes | Yes |
POST |
/api/payment |
No (Stripe signature) | No |
Health
GET /api/health
Health check. No authentication.
Response (200):
{
"status": "ok",
"timestamp": 1748390400000
}
Authentication
POST /api/signup
Register a new user. Sets the token and csrf_token cookies on success.
Request Body:
{
"email": "user@example.com",
"password": "password123",
"name": "John Doe"
}
Response (201):
{
"id": "user_123",
"email": "user@example.com",
"name": "John Doe",
"tokenExpires": 1750982400000
}
Signup inserts the user, then the auth record. If the auth insert fails, the user row is rolled back (
DELETE FROM Users WHERE _id = ?on SQL adapters).
POST /api/signin
Authenticate an existing user. Sets the token and csrf_token cookies on success. Subject to account lockout after 5 failed attempts.
Request Body:
{
"email": "user@example.com",
"password": "password123"
}
Response (200):
{
"id": "user_123",
"email": "user@example.com",
"name": "John Doe",
"subscription": {
"stripeID": "cus_1234567890",
"expires": 1750982400,
"status": "active"
},
"tokenExpires": 1750982400000
}
The subscription object is present only when the user has one.
Response (429): Returned when the account is locked. Includes a Retry-After header.
POST /api/signout
Sign out the current user. Clears the token and csrf_token cookies.
Auth: token cookie + x-csrf-token header.
Response (200):
{
"message": "Signed out successfully"
}
User
GET /api/me
Get the authenticated user.
Auth: token cookie.
Response (200):
{
"_id": "user_123",
"email": "user@example.com",
"name": "John Doe",
"created_at": 1748390400000,
"subscription": {
"stripeID": "cus_1234567890",
"expires": 1750982400,
"status": "active"
},
"usage": {
"count": 3,
"reset_at": 1750982400000
}
}
Response (404): User not found.
PUT /api/me
Update the authenticated user. Only name is whitelisted (UPDATEABLE_USER_FIELDS); any other fields are ignored.
Auth: token cookie + x-csrf-token header.
Request Body:
{
"name": "John Smith"
}
Response (200): The updated user object.
Response (400): No valid fields supplied, or no changes made.
Response (404): User not found.
Usage / Freemium
POST /api/usage
Check or track usage for the freemium model. Subscribers (subscription.status === 'active' and not expired) get unlimited usage. Free users are limited by FREE_USAGE_LIMIT (default 20) over a rolling 30-day window.
Auth: token cookie. (No CSRF.)
Request Body:
{
"operation": "check"
}
operation is "check" or "track". track atomically increments usage.
Response (200) — subscriber:
{
"remaining": -1,
"total": -1,
"isSubscriber": true,
"subscription": {
"stripeID": "cus_1234567890",
"expires": 1750982400,
"status": "active"
}
}
Response (200) — free user:
{
"remaining": 17,
"total": 20,
"isSubscriber": false,
"used": 3,
"subscription": null
}
Response (429): Limit reached on a track operation.
{
"error": "Usage limit exceeded",
"remaining": 0,
"total": 20,
"isSubscriber": false
}
Stripe
Stripe is optional: the SDK only initializes when STRIPE_KEY is set, otherwise these flows are disabled with a startup warning. See the Stripe guide for setup. Redirect URLs use FRONTEND_URL (or the request origin, or http://localhost:<port>) as the base.
POST /api/checkout
Create a Stripe Checkout session for a subscription. The product is resolved by Stripe lookup key, not a hardcoded price ID.
Auth: token cookie + x-csrf-token header.
Request Body:
{
"email": "user@example.com",
"lookup_key": "my_lookup_key"
}
Response (200):
{
"url": "https://checkout.stripe.com/c/pay/cs_test_...",
"id": "cs_test_1234567890",
"customerID": "cus_1234567890"
}
Errors: 400 missing email/lookup_key or no price found for the lookup key; 403 if email does not match the authenticated user; 500 { "error": "Stripe session failed" }.
POST /api/portal
Create a Stripe Billing Portal session.
Auth: token cookie + x-csrf-token header.
Request Body:
{
"customerID": "cus_1234567890"
}
Response (200):
{
"url": "https://billing.stripe.com/p/session/...",
"id": "bps_1234567890"
}
Errors: 400 missing customerID; 403 if the user’s stored subscription.stripeID exists and does not match customerID; 500 { "error": "Stripe portal failed" }.
POST /api/payment
Stripe webhook endpoint. No auth middleware — verified instead by the stripe-signature header against STRIPE_ENDPOINT_SECRET using stripe.webhooks.constructEventAsync. Idempotent: each event.id is recorded before processing and skipped if already seen.
Headers:
stripe-signature: t=...,v1=...
Configure the webhook URL as https://your-backend-url/api/payment.
Handled events:
customer.subscription.created/.updated/.deleted— patchessubscription(stripeID,expires,status).checkout.session.completed— retrieves the subscription and patchessubscription.invoice.paid— patchessubscription.invoice.payment_failed— setssubscription.paymentFailedandsubscription.paymentFailedAt.
Response: 200 on success or skip (empty body); 400 on signature/data failure; 500 on processing error.
Static / SPA fallback
Non-/api routes serve static files from config.staticDir (../dist). Any non-asset, non-/api path returns index.html (SPA fallback). /api/* paths and paths ending in a file extension that aren’t found return 404. If index.html is missing, the server returns the text Welcome to Skateboard API.
Database
The backend uses a unified adapter pattern (backend/adapters/manager.ts, exported as databaseManager). The active database is selected by backend/config.json → database.dbType (default "sqlite") — it is not chosen by an env var. Env vars (MONGODB_URL, POSTGRES_URL, DATABASE_URL) only substitute into the connectionString via ${VAR} placeholders. Switching databases requires editing config.json.
| dbType | Adapter | Driver |
|---|---|---|
sqlite (default) |
adapters/sqlite.ts |
node:sqlite (DatabaseSync), WAL mode |
postgresql / postgres |
adapters/postgres.ts (dynamic import) |
pg Pool |
mongodb / mongo |
adapters/mongodb.ts (dynamic import) |
mongodb driver |
pgandmongodbare not declared inbackend/package.jsondependencies; they are loaded lazily and resolved from the hoisted rootnode_modules. SQLite-only deployments work without them.
Schema (logical):
- Users —
_id,email(unique),name,created_at,subscription_stripeID,subscription_expires,subscription_status,usage_count,usage_reset_at. - Auths —
email(PK),password(hash),userID→Users(_id). - WebhookEvents —
event_id(PK),event_type,processed_at.
SQL adapters flatten subscription/usage into columns and reconstruct nested objects on read; MongoDB stores them as native nested objects.
Password hashing
New password hashes use node:crypto scrypt in the format scrypt$<base64url salt>$<base64url key> (salt 16 bytes, key 64 bytes). Verification dispatches on the stored prefix: scrypt$ uses a timing-safe scrypt compare; $2 (legacy bcrypt) is verified by the vendored verify-only module at backend/vendor/legacy-bcrypt.js and lazily re-hashed to scrypt on successful sign in.
Environment Variables
Loaded manually (no dotenv) from backend/.env then backend/.env.local when not in production; a missing .env is created from .env.example.
| Variable | Required | Default | Purpose |
|---|---|---|---|
PORT |
No | 8000 |
HTTP listen port |
JWT_SECRET |
Yes | — | HS256 JWT signing secret (503 if missing) |
STRIPE_KEY |
No | — | Stripe secret key; if unset, Stripe is disabled |
STRIPE_ENDPOINT_SECRET |
No | — | Stripe webhook signature secret |
FREE_USAGE_LIMIT |
No | 20 |
Free-tier usage limit |
NODE_ENV |
No | — | production enables secure cookies, disables env file loading |
CORS_ORIGINS |
No | localhost fallback | Comma-separated allowed origins |
FRONTEND_URL |
No | — | Base for Stripe success/cancel/return URLs |
MONGODB_URL |
No | — | MongoDB connection string (${VAR} substitution) |
POSTGRES_URL |
No | — | PostgreSQL connection string (${VAR} substitution) |
DATABASE_URL |
No | — | Generic DB connection (validation hint only) |
validateEnvironmentVariables() warns (does not exit) if JWT_SECRET, STRIPE_KEY, or STRIPE_ENDPOINT_SECRET are missing, or if the DB connection string contains an unresolved ${VAR}.
HTTP Status Codes
200— Success201— Created (sign up)400— Bad request (missing/invalid fields, no changes)401— Unauthorized (missing/expired/invalid token)403— Forbidden (email or customer ID mismatch)404— Not found429— Too many requests (account lockout, usage limit)500— Internal server error503— Auth disabled (JWT_SECRETnot set)
Dependencies
backend/package.json declares only three runtime dependencies:
@hono/node-server^1.14.1hono^4.7.11stripe^18.5.0
There are no backend devDependencies; tests run with Node’s built-in test runner (node --test server.test.ts).