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
422is 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
422with typeidempotency-key-reusedrather 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
409with typeidempotency-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.
1. Send the account holder to the consent page
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.