Errors
BookFlow uses two error envelope shapes depending on which layer produced the error. Both are documented here. Successful responses are always wrapped in { data: ... }.
Shape 1: simple string (rate limiter, middleware)
{ "error": "rate_limited" }Used by the rate limiter, CSRF middleware, and a few low-level handlers.
Shape 2: object (handler errors, validation)
{
"error": {
"code": "invalid_credentials",
"message": "Wrong email or password",
"details": { "fieldErrors": { "email": ["Invalid email"] } }
}
}Used by handlers and Zod validation. The code is a stable machine-readable string; message is human-readable; details is only present on zod validation failures.
It's the result of zodError.flatten() and matches: formErrors: string[], fieldErrors: Record<string, string[]>.
HTTP status codes
| Code | Meaning | When |
|---|---|---|
| 200 | OK | Successful GET, PATCH, or POST that doesn't create a new resource |
| 201 | Created | Successful POST that creates a new resource |
| 400 | Bad Request | Body validation failed (Zod). Shape 2 with code: invalid_request |
| 401 | Unauthorized | Missing or invalid token, or wrong audience (Shape 1: { error: 'unauthorized' }) |
| 403 | Forbidden | Token valid but lacks the required role or scope (Shape 1: { error: 'tenant required' | 'tenant not found' }) |
| 404 | Not Found | Resource doesn't exist or isn't visible to the caller (Shape 2: { code: 'not_found' }) |
| 409 | Conflict | Resource state conflict (slot taken, duplicate, slug_taken, invalid_transition, etc.) |
| 429 | Too Many Requests | Rate limit exceeded (Shape 1: { error: 'rate_limited' }, with Retry-After header) |
| 500 | Server Error | Unhandled error. NetWit gets an alert |
| 503 | Service Unavailable | Admin /api/admin/system/health returns 503 when D1 or KV probes fail |
Common error codes
| Code | HTTP | Meaning |
|---|---|---|
| invalid_request | 400 | Zod validation failed. details.fieldErrors has per-field messages. |
| unauthorized (string) | 401 | Token missing, invalid, or wrong audience. Body is { error: 'unauthorized' }. |
| invalid_credentials | 401 | Login: wrong email or password. |
| tenant required / tenant not found | 403 | Authed but no valid tenant context (check JWT tenantId). |
| not_found | 404 | Resource not found. |
| slot_taken | 409 | Slot was just booked by another customer. Re-fetch availability. |
| email_taken | 409 | Register: email already exists. |
| slug_taken | 409 | Register or POST /api/businesses: business slug already exists. |
| invalid_transition | 409 | Tried to change a booking's status to one not allowed from its current status (e.g. cancelled → completed). |
| not_reschedulable | 409 | Booking is in a terminal state (cancelled, completed, no_show) and cannot be rescheduled. |
| not_cancellable | 409 | Booking is completed or no_show and cannot be cancelled. |
| guest_disabled | 403 | Public booking attempted but tenant does not allow guest checkout. |
| stripe_not_connected | 400 | Tried to create a payment intent before the owner connected Stripe. |
| rate_limited (string) | 429 | Rate limit hit. Wait the number of seconds in Retry-After. |
Idempotency
BookFlow does not currently implement an Idempotency-Key header on POST endpoints. To safely retry on network errors, use a unique idempotency_key column on the client side (e.g. a UUID per booking attempt) and check for 409 slot_taken to detect a duplicate that beat you to it.
Request IDs
BookFlow does not currently emit an X-Request-Id response header. When you contact support, include the request URL, method, full response body, and approximate timestamp so the team can correlate against worker logs.
Email hello@netwit.ca or call +1-604-206-8169. NetWit responds in 1 business day.