Skip to content

Khable API

The Khable API lets your own software publish and read posts as a Khable account. It speaks JSON, is versioned under /api/v1, and is authenticated with a bearer token.

Base URL: https://khable.com/api/v1

Authentication

Every request carries an API key as a bearer token:

GET /api/v1/me HTTP/1.1
Host: khable.com
Authorization: Bearer 7|khb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Accept: application/json

A key acts as the account that holds it. Anything it publishes appears under that account's name, and it reaches exactly what its holder could reach in the browser — no more. Suspending an account stops its keys writing immediately, whatever abilities the key still carries.

A key looks like 7|khb_…. The khb_ marker is there so a key that ends up in a repository or a log is recognisable on sight and by automated secret scanners. Send the whole string, pipe included, and treat all of it as the secret — do not split it.

There are two ways to get one:

  • Connect with Khable — your software sends the account holder to a consent page and receives a key without anybody copying a secret by hand. This is the supported path for a product with more than one user. See Connecting an app below.
  • Paste a key — the account holder mints one at Settings › API and pastes it into your software. Minting a key this way needs API access granted by an administrator.

Scopes

A key carries a subset of these abilities. Ask for the half you need; a key that can do both when it needs one leaks both.

Scope What it allows
posts:read List and read the holder's posts, including drafts.
posts:write Create, update and delete the holder's posts.
directory:read List and read approved directory listings.

A request to an endpoint whose scope the key does not carry is a 403 with type forbidden. GET /me requires no scope at all, so a client can always verify a key before it knows what it can do.

Rate limits

Reads are metered per key at 120 requests a minute. Writes are metered per account at 60 an hour, so minting a second key does not buy a second budget. A 429 carries Retry-After when the ceiling is a fixed window; honour it.

Endpoints

GET /me

Who this key acts as. Call it at connect time to prove a key works and to learn the account behind it.

{
    "data": {
        "id": 42,
        "name": "KH Events",
        "email_verified": true,
        "profile": {
            "username": "khevents",
            "display_name": "KH Events",
            "avatar_url": "https://…",
            "is_suspended": false
        },
        "key": {
            "id": 7,
            "name": "Postendar · Acme",
            "scopes": ["posts:read", "posts:write"],
            "last_used_at": "2026-09-03T10:00:00+00:00",
            "expires_at": null,
            "client": "postendar",
            "client_reference": "workspace:12"
        },
        "limits": {
            "post": {
                "body_max": 500,
                "images_max": 4,
                "image_bytes_max": 8388608,
                "image_alt_max": 160
            },
            "article": {
                "title_max": 160,
                "excerpt_max": 300,
                "body_min": 20,
                "body_max": 20000,
                "images_max": 1,
                "image_bytes_max": 8388608
            },
            "upload_total_bytes_max": 25165824,
            "tags_max": 8,
            "tags_field_max": 200,
            "visibilities": ["public", "unlisted", "private"],
            "types": ["post", "article"]
        }
    }
}

The email address is deliberately absent: holding a valid key does not entitle the holder to the address it was minted from.

key.client and key.client_reference are null for a key pasted in by hand, and name the connecting software and connection for a key issued through the consent page.

limits is authoritative — read it rather than hard-coding the numbers, so a client validating locally cannot drift from what the server accepts.

GET /communities

Communities the caller can post into, their own first. Requires posts:read or posts:write.

Query: q (name contains, max 60 characters), per_page (default 100, max 200), cursor.

{
    "data": [
        {
            "id": 3,
            "slug": "tech",
            "name": "Tech",
            "members_count": 1240,
            "is_member": true
        }
    ],
    "meta": { "next_cursor": null }
}

Ordered by membership, then size, then name — so a picker shows the communities the account actually belongs to at the top.

GET /posts

The caller's own posts, newest first, cursor-paginated.

Query: status (draft, published, hidden, removed), updated_since (ISO-8601; matches updated_at >= value, inclusive), per_page, cursor. An unrecognised status or an unparseable updated_since is a 422 rather than an ignored parameter.

URL-encode updated_since: the + in a timezone offset like +07:00 decodes to a space and the timestamp will not parse. 2026-09-03T10:00:00Z avoids the problem entirely.

GET /posts/{slug}

One post the caller owns. Another account's slug answers 404, not 403, so slugs cannot be enumerated.

POST /posts

Creates a post. The body is multipart/form-data, because images are real file uploads — the same path and the same validation rules the web composer uses, so a payload accepted here would have been accepted there in the same words.

Common fields:

Field Rules
type post or article
body quick post: at most 500 characters. Article: 20–20000 characters.
visibility public, unlisted or private
publish 1 to publish, omit or 0 to leave a draft
community_id optional; an id from GET /communities
tags optional; comma-separated, at most 200 characters. The first 8 are kept.
images[i], image_alts[i] up to 4 for a quick post, each at most 8 MB, 24 MB combined

An article additionally takes title (required, at most 160 characters) and excerpt (optional, at most 300), and carries at most one image, sent as cover_image with cover_image_alt. Alternative text is required on an article's image.

The response is 201 with the created post. data.slug is its stable id and data.url is the public URL — link to data.url when it is present.

POST /posts/{slug}

Updates a post. POST, not PATCH: PHP only populates the file bag for POST, so a multipart PATCH would arrive with no images at all.

DELETE /posts/{slug}

Deletes a post. 204.

DELETE /me/key

Revokes the calling key. 204. No scope required. Call it when your side of the connection is torn down, so the account is not left holding a credential nothing uses.

Idempotency

Send an Idempotency-Key header on any write and a repeated request replays the first response instead of acting twice. This is what makes a scheduler's retry safe after a timeout that arrived only because the response was lost.

  • The window is 24 hours.
  • Only successful responses are recorded. A 422 is a payload to fix and resend, not an outcome worth freezing — the same key works again for the corrected request.
  • The payload is fingerprinted alongside the response. Reusing one key for a different payload is a 422 with type idempotency-key-reused rather than the first response handed back with a slug that was never created for the second.
  • A retry that arrives while the first request is still running is a 409 with type idempotency-in-flight. Retry in a moment.
  • A replayed response carries Idempotency-Replayed: true.

Use one key per logical publication and keep it stable across retries.

Self-hosting note: the replay window and the connect codes below live in a cache store, which must be shared across processes (redis or database). Set API_IDEMPOTENCY_STORE, or leave it blank and point CACHE_STORE at one. A per-process store makes both features silently ineffective; Khable refuses to boot in production without one.

Connecting an app

"Connect with Khable" is an authorization-code flow. It exists so nobody has to copy a long-lived secret out of one product and into another, and so the account holder sees, and can withdraw, exactly what they granted.

You need a client id, a client secret and at least one redirect URI registered with Khable. Redirect URIs are matched as exact strings — scheme, host, port and path all have to agree, and there are no wildcards.

GET https://khable.com/connect/postendar
  ?redirect_uri=https://yourapp.example/connect/khable/callback
  &state=<opaque, 1–512 chars>
  &reference=workspace:12
  &label=Acme%20Marketing
  &scope=posts:read%20posts:write
Parameter Rules
redirect_uri required; must equal one registered entry exactly
state required; opaque to Khable and echoed back unchanged. Bind it to the browser session on your side — it is what makes the callback verifiable.
reference required; at most 120 characters of A-Z a-z 0-9 : _ -; the stable identity of the thing connecting
label required; at most 120 characters; shown on the consent page and used in the key's name
scope optional; space-separated; a subset of what your client is allowed. Defaults to all of it.

Khable never redirects on a validation failure. An unknown or disabled client renders a 404 page and a bad redirect_uri or any other invalid parameter renders a 400 page, both on Khable. Only a valid request reaches the consent page. The holder signs in, confirms their password, and sees which account they are about to publish as.

2. Receive the code

  • Authorised: 302 {redirect_uri}?code={code}&state={state}
  • Cancelled: 302 {redirect_uri}?error=access_denied&state={state}

The code is single-use, valid for ten minutes, and bound to the client and the redirect URI it was issued for. The key itself never travels in a URL.

3. Exchange the code for a key

Server-to-server, no bearer token, Content-Type: application/json:

POST /api/v1/connect/exchange
{
  "client_id": "postendar",
  "client_secret": "…",
  "code": "…",
  "redirect_uri": "https://yourapp.example/connect/khable/callback"
}
{
    "data": {
        "token": "7|khb_…",
        "token_type": "Bearer",
        "me": { "id": 42, "…": "the same object GET /me returns for this key" }
    }
}

The response carries Cache-Control: no-store. Store the token as a secret; it is not retrievable again.

Reconnecting the same reference for the same account replaces the previous key: the old one stops working the moment the new one is issued. The exchange is rate limited to 10 requests a minute per IP address.

Send the secret in the request body only. A client_secret in the query string is refused outright — query strings end up in access logs, browser history and referrer headers.

The account holder can withdraw a connection at any time from Settings › Connected apps. Your next request then fails with 401; treat that as the connection being gone rather than as a transient error.

Errors

Every failure is an RFC 9457 problem document with Content-Type: application/problem+json:

{
    "type": "https://khable.com/docs/api#errors-validation-error",
    "title": "The request payload is invalid.",
    "status": 422,
    "detail": "The body field is required.",
    "instance": "/api/v1/posts",
    "errors": { "body": ["Write something or add a photo."] }
}

type is the stable machine-readable part — branch on it, not on title. errors carries field-keyed validation messages in the same words the web composer uses, and is safe to show an author verbatim.

Problem documents are translated: send Accept-Language: km and title and detail come back in Khmer.

Errors — unauthenticated

401. No key, an unknown key, or a key that has expired or been revoked. The connection is dead; do not retry with the same credential.

Errors — forbidden

403. The key is valid but may not do this: a scope it does not carry, or an account that may not publish — unverified email, or a suspended profile. Stop and tell the account holder; retrying will not help.

Errors — not-found

404. No such resource, or one belonging to another account. Khable answers 404 rather than 403 for another holder's content precisely so slugs cannot be enumerated.

Errors — conflict

409. The request conflicts with current state.

Errors — payload-too-large

413. The upload exceeded what the server accepts. Check limits on GET /me.

Errors — unsupported-media-type

415. Writes are multipart/form-data; the exchange endpoint is application/json.

Errors — validation-error

422. The payload is invalid. errors names the fields. Fix and resend — the same Idempotency-Key may be reused for the corrected request.

Errors — email-unverified

423, from the mobile sign-in. The password was right but the address has not been verified yet. Follow the link in the verification email, then sign in again.

Errors — mobile-upgrade-required

426, for the mobile app only. The build's X-Khable-Version is below the X-Khable-Min-Version every response carries. X-Khable-Store-Url names where to update. Nothing else the build sends will be accepted until it does.

Errors — rate-limited

429. Too many requests. Honour Retry-After when present, and back off otherwise.

Errors — server-error

500. A fault on Khable's side. Retry with backoff.

Errors — invalid-idempotency-key

400. The Idempotency-Key header is longer than 255 characters.

Errors — idempotency-key-reused

422. This Idempotency-Key was already used for a different payload. Send a new key per distinct request.

Errors — idempotency-in-flight

409. A request with this key is still being processed. Retry in a moment.

Errors — invalid-client

401, from the exchange endpoint. Unknown client_id or wrong client_secret. The two are deliberately indistinguishable.

Errors — invalid-grant

400, from the exchange endpoint. The code is unknown, expired, already used, or was issued for a different client or redirect URI. Codes are consumed by any attempt that reaches this check, including a failed one — start the flow again.

Errors — invalid-request

400, from the exchange endpoint. The request is malformed in a way that cannot be corrected by resending it as-is — for example a client_secret passed in the query string.

Errors — http-error

4xx/5xx with no more specific slug. Read status and title.