Skip to content

Open Beta – help us test! All listings are examples only.

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:

PrefixTypeUse whereAllowed scopes
sk_live_*SecretServer-to-serverAny scope (incl. write / delete / *)
pk_live_*PublishableBrowser / embedRead-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: delete implies write, and write implies read.
listings:*
├── listings:read
├── listings:write    (implies read)
└── listings:delete   (implies write + read)

Registry

ResourceScopes
organizationsorganizations:read · :write · :delete
membersmembers:read · :write · :delete
webhookswebhooks:read · :write · :delete
api-keysapi-keys:read · :write · :delete
listingslistings:read · :write · :delete
embedembed:read
appointmentsappointments:read · :write · :book · appointments:*
Full access*

Publishable whitelist

A pk_ key may carry only these read scopes:

  • organizations:read
  • listings:read
  • embed:read
  • appointments:read
  • appointments: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 the message text.
  • message — human-readable English summary.
  • status — HTTP status (mirrored in the body).
  • details — optional extra object (e.g. fields on validation errors).

Error code table (complete)

CodeHTTPMeaningTypical cause
UNAUTHORIZED401Authentication requiredX-API-Key header missing entirely.
INVALID_API_KEY401API key invalid or expiredKey unknown, revoked or past expires_at.
KEY_ROTATED_OUT401Key rotated, overlap window passedOld key used after rotation_expires_at.
FORBIDDEN403Access deniedKey valid, but resource belongs to another organization.
INSUFFICIENT_SCOPE403Key lacks the required scopee.g. listings:write requested, key only has listings:read.
PLAN_REQUIRED403Endpoint requires a higher subscription planPremium-gated endpoint called with a Free/Plus org.
CONSENT_REQUIRED403Org has not enabled API access to applicant dataApplications endpoint called without the org consent set.
ORIGIN_REQUIRED403Origin header required for pk_ keysPublishable key called without an Origin header.
ORIGIN_NOT_ALLOWED403Origin not in the key's allowlistOrigin matches no entry of the key's allowlist.
IP_NOT_ALLOWED403Client IP not in the key's allowlistRequest IP outside the configured IP/CIDR list.
NOT_FOUND404Resource not foundUnknown ID or path; resource does not exist (anymore).
METHOD_NOT_ALLOWED405HTTP method not allowede.g. PUT on an endpoint that only knows GET/POST.
ALREADY_EXISTS409Resource already exists (conflict)Uniqueness violation, e.g. slug already taken.
CANNOT_DELETE_SELF409API key cannot delete itselfDELETE /api/v1/api-keys/{id} with the calling key's own ID.
IDEMPOTENCY_KEY_REUSED409Idempotency-Key reused with a different bodySame Idempotency-Key, different request body.
INVALID_STATUS_TRANSITION409Invalid application status transitionIllegal transition (e.g. acceptednew) or withdrawn application.
BAD_REQUEST400Invalid requestMalformed parameters, oversized body, invalid ID form.
VALIDATION_ERROR400Input validation failedZod schema violation; details under details.fields.
RATE_LIMITED429Too many requests (DoS protection)Rate limit of 1000 req/h exceeded. Respect Retry-After.
QUOTA_EXCEEDED429Monthly quota exceededPlan monthly limit reached — only with QUOTA_ENFORCEMENT=true.
INTERNAL_ERROR500Internal server errorUnexpected error; logged server-side. Safe to retry.
SERVICE_UNAVAILABLE503Service temporarily unavailableDependent 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> — the cursor is an opaque string from the previous response.
  • Response: the pagination metadata carries next_cursor (string or null) and has_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_page is 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-Key with a different body returns 409 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, form W/"l<v>-<hash>") and a Cache-Control header.
  • 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 with 304 and 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.

HeaderMeaningExample
X-RateLimit-LimitMax requests in the window1000
X-RateLimit-RemainingRequests left in the current window950
X-RateLimit-ResetISO time when the window resets2026-06-03T11:00:00.000Z
Retry-AfterSeconds 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.

HeaderMeaningExample
X-Quota-LimitMonthly plan limit or unlimited10000
X-Quota-UsedCurrent monthly counter or unknown3450
X-Quota-ResetISO time of the monthly reset (Berlin)2026-07-01T00:00:00.000Z
X-Quota-Statusok / soft-warning / unavailablesoft-warning
  • soft-warning is set at ≥ 80 % usage (no block).
  • unavailable means 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.