API conventions
The canonical reference for all cross-cutting concepts of the WOHNO REST API: authentication, scopes, error format, pagination, idempotency, caching/ETag, rate limits and quotas.
This is the single reference for all cross-cutting concepts of the public REST
API (/api/v1/*). Every guide links here instead of repeating these concepts —
when a convention changes, it changes here.
Authentication
Every /api/v1/* request is authenticated with an API key in the
X-API-Key header. There are two key types:
| Prefix | Type | Use where | Allowed scopes |
|---|---|---|---|
sk_live_* | Secret | Server-to-server | Any scope (incl. write / delete / *) |
pk_live_* | Publishable | Browser / embed | Read-only whitelist (see Scopes) |
sk_test_* / pk_test_* exist for sandbox setups.
- Secret (
sk_) — anywhere the key can stay secret: backends, cron jobs, CRM sync. May read, write and delete. Stored only as a bcrypt hash and returned in cleartext exactly once. - Publishable (
pk_) — anywhere the key lands in a browser: embeds, public HTML, client-side JS. May only carry the read-only whitelist and requires an origin allowlist.
For the full explanation of key types, origin/IP allowlists and rotation, see the Authentication guide.
Scopes
Scopes follow the resource:action format (e.g. listings:read) and are
hierarchical:
*→ all resources and actions (full access).resource:*→ all actions on a resource (e.g.appointments:*).- Implication:
deleteimplieswrite, andwriteimpliesread.
listings:*
├── listings:read
├── listings:write (implies read)
└── listings:delete (implies write + read)
Registry
| Resource | Scopes |
|---|---|
organizations | organizations:read · :write · :delete |
members | members:read · :write · :delete |
webhooks | webhooks:read · :write · :delete |
api-keys | api-keys:read · :write · :delete |
listings | listings:read · :write · :delete |
embed | embed:read |
appointments | appointments:read · :write · :book · appointments:* |
| Full access | * |
Publishable whitelist
A pk_ key may carry only these read scopes:
organizations:readlistings:readembed:readappointments:readappointments:book
This is enforced twice (application layer and a database trigger). A missing
scope returns 403 INSUFFICIENT_SCOPE.
Response format
Successful responses always wrap the payload in data, plus a meta object:
{
"data": {
/* object (detail endpoint) or array (list endpoint) */
},
"meta": {
"timestamp": "2026-06-07T10:00:00.000Z"
/* list endpoints add e.g. "org_id", "filter_applied" */
}
}Cursor lists additionally return a pagination object; offset lists return the
counters under meta (see Pagination).
Field naming (camelCase vs. snake_case)
⚠️ Field naming is not fully consistent — account for this when generating client models.
- Listings & Discovery (read DTOs): camelCase —
zipCode,propertyType,livingArea,rentCold,organizationId. - Listings write body + all other resources (Webhooks, Members,
Appointments, Usage, API keys, WBS): snake_case —
zip_code,created_at,user_id,start_at.
So a listing is snake_case when you write it and camelCase in the
response. Validation errors (details.fields) always reference the correct
(snake_case) input keys.
Error format
Every error follows a single format. The body is always wrapped in error:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"status": 400,
"details": {
"fields": {
"name": "Name is required"
}
}
}
}code— stable, machine-readable string. Branch on this code, never on themessagetext.message— human-readable English summary.status— HTTP status (mirrored in the body).details— optional extra object (e.g.fieldson validation errors).
Error code table (complete)
| Code | HTTP | Meaning | Typical cause |
|---|---|---|---|
UNAUTHORIZED | 401 | Authentication required | X-API-Key header missing entirely. |
INVALID_API_KEY | 401 | API key invalid or expired | Key unknown, revoked or past expires_at. |
KEY_ROTATED_OUT | 401 | Key rotated, overlap window passed | Old key used after rotation_expires_at. |
FORBIDDEN | 403 | Access denied | Key valid, but resource belongs to another organization. |
INSUFFICIENT_SCOPE | 403 | Key lacks the required scope | e.g. listings:write requested, key only has listings:read. |
PLAN_REQUIRED | 403 | Endpoint requires a higher subscription plan | Premium-gated endpoint called with a Free/Plus org. |
CONSENT_REQUIRED | 403 | Org has not enabled API access to applicant data | Applications endpoint called without the org consent set. |
ORIGIN_REQUIRED | 403 | Origin header required for pk_ keys | Publishable key called without an Origin header. |
ORIGIN_NOT_ALLOWED | 403 | Origin not in the key's allowlist | Origin matches no entry of the key's allowlist. |
IP_NOT_ALLOWED | 403 | Client IP not in the key's allowlist | Request IP outside the configured IP/CIDR list. |
NOT_FOUND | 404 | Resource not found | Unknown ID or path; resource does not exist (anymore). |
METHOD_NOT_ALLOWED | 405 | HTTP method not allowed | e.g. PUT on an endpoint that only knows GET/POST. |
ALREADY_EXISTS | 409 | Resource already exists (conflict) | Uniqueness violation, e.g. slug already taken. |
CANNOT_DELETE_SELF | 409 | API key cannot delete itself | DELETE /api/v1/api-keys/{id} with the calling key's own ID. |
IDEMPOTENCY_KEY_REUSED | 409 | Idempotency-Key reused with a different body | Same Idempotency-Key, different request body. |
INVALID_STATUS_TRANSITION | 409 | Invalid application status transition | Illegal transition (e.g. accepted→new) or withdrawn application. |
BAD_REQUEST | 400 | Invalid request | Malformed parameters, oversized body, invalid ID form. |
VALIDATION_ERROR | 400 | Input validation failed | Zod schema violation; details under details.fields. |
RATE_LIMITED | 429 | Too many requests (DoS protection) | Rate limit of 1000 req/h exceeded. Respect Retry-After. |
QUOTA_EXCEEDED | 429 | Monthly quota exceeded | Plan monthly limit reached — only with QUOTA_ENFORCEMENT=true. |
INTERNAL_ERROR | 500 | Internal server error | Unexpected error; logged server-side. Safe to retry. |
SERVICE_UNAVAILABLE | 503 | Service temporarily unavailable | Dependent service (DB/Redis) down; retry with backoff. |
Pagination
The API has two pagination styles. Which one applies is stated in each endpoint's doc.
Cursor pagination (preferred for large/public lists)
Used e.g. by GET /api/v1/listings. Stable under concurrent inserts/deletes.
- Request:
?cursor=<opaque>&limit=<n>— thecursoris an opaque string from the previous response. - Response: the pagination metadata carries
next_cursor(string ornull) andhas_more(boolean):
{
"pagination": {
"next_cursor": "eyJpZCI6...",
"has_more": true,
"limit": 20
}
}While has_more === true, pass next_cursor as the next cursor.
Offset pagination (classic lists)
Used e.g. by GET /api/v1/organizations and GET /api/v1/appointments.
- Request:
?page=<n>&per_page=<n>(per_pageis capped, typically max 100). - Response
meta:total,page,perPage,hasMore:
{
"data": [],
"meta": {
"total": 42,
"page": 1,
"perPage": 20,
"hasMore": true,
"timestamp": "2026-06-03T10:30:00.000Z"
}
}Idempotency
For write endpoints (POST/PATCH/DELETE), a client can send an optional
header to prevent double execution on retries:
Idempotency-Key: <client-chosen, unique string>
- Window: a key is valid for 24 hours. Within that window, a retried request with the same key returns the same response without re-executing the action.
- Conflict: sending the same
Idempotency-Keywith a different body returns409 IDEMPOTENCY_KEY_REUSED. One key is bound to exactly one request body. - Recommendation: generate one UUID v4 per logical operation and reuse it unchanged on network retries.
Caching & ETag
Read endpoints (e.g. GET /api/v1/listings*) support conditional requests via
weak ETags.
- Response: every response carries an
ETag(weak, formW/"l<v>-<hash>") and aCache-Controlheader. - Conditional request: send the ETag back as
If-None-Match:
If-None-Match: W/"l3-1a2b3c4d5e6f7a8b"
304 Not Modified: if the ETag still matches, the API responds with304and no body — the client may reuse its cached copy.- Cache bust: the ETag embeds the DTO schema version. A DTO change automatically invalidates all caches.
Rate limits
For DoS protection, API keys are limited to 1000 requests per hour. Exceeding
it returns 429 RATE_LIMITED.
| Header | Meaning | Example |
|---|---|---|
X-RateLimit-Limit | Max requests in the window | 1000 |
X-RateLimit-Remaining | Requests left in the current window | 950 |
X-RateLimit-Reset | ISO time when the window resets | 2026-06-03T11:00:00.000Z |
Retry-After | Seconds until the next allowed request (429 only) | 42 |
Use exponential backoff and respect Retry-After.
Quotas
In addition to the rate limit, the API counts requests per organization per
month (Europe/Berlin). Currently tracking-only: nothing is blocked while
QUOTA_ENFORCEMENT=false (default). Every response carries informative headers.
| Header | Meaning | Example |
|---|---|---|
X-Quota-Limit | Monthly plan limit or unlimited | 10000 |
X-Quota-Used | Current monthly counter or unknown | 3450 |
X-Quota-Reset | ISO time of the monthly reset (Berlin) | 2026-07-01T00:00:00.000Z |
X-Quota-Status | ok / soft-warning / unavailable | soft-warning |
soft-warningis set at ≥ 80 % usage (no block).unavailablemeans the counter (Redis) is briefly unreachable — the API still responds normally.
For how the API evolves over time (additive changes, deprecation windows, DTO versions), see the Versioning & Deprecation Policy.