Messaging API
Messaging API v1
The Messaging API turns in-app chat on for your apps. You subscribe to a Messaging plan on this site, create a Messaging API key on the Messaging page of your dashboard and paste it into the chat settings of your app. The app checks the key here and reports its usage once a day. Messages themselves stay on the server that runs your app; this service never receives them.
Works with apps that include a Messaging-compatible chat module.
A Messaging key starts with msg_live_ and is sent only as Authorization: Bearer to
/api/messaging/v1; anything else is refused before any lookup.
Every response carries manage_url: the page where the key owner manages the plan, key and apps. Link the app owner there.
https://chat.zeltacode.com/api/messaging/v1 Authorization: Bearer msg_live_YOUR_MESSAGING_KEY Content-Type: application/json Accept: application/json
Identifiers and scheduling
install_id: a stable UUID generated once per installation and stored. Do not use a hostname: if the domain changes, every app would bind again and take new slots.app_id: the app's id inside the installation (sent as a string).- Spread the daily calls over the day, never all at the same minute: run each app's
verifyat its own offset,crc32(install_id + "|" + app_id) % 86400seconds after midnight UTC, and the installation'susagereport (yesterday's counts) atcrc32(install_id) % 86400seconds after midnight UTC. - The first verify after a key is saved: at most one app per second (the limit is 60 requests per minute per key and installation).
- Treat
429like a5xx: keep the last good answer, wait the number of seconds inRetry-After, then retry with exponential backoff and jitter.
POST /verify
Checks the key for one app and binds that app to the key owner's account (up to the plan's apps limit). Call it when the key is saved and then about once a day.
Cache the answer until valid_until.
POST /api/messaging/v1/verify
{
"install_id": "4f1c2b8e-6a0d-4c4e-9a43-7d2f0b1e9c55",
"app_id": "42",
"app_name": "City Classifieds"
}
{
"success": true,
"status": "active",
"plan": { "code": "messaging-starter-ab12cd", "name": "Starter" },
"metric": "active_users",
"limit": 500,
"used": 123,
"usage_percent": 24,
"period": { "start": "2026-10-01", "end": "2026-10-31" },
"apps_limit": 3,
"apps_used": 1,
"grace_days": 7,
"over_limit": false,
"over_limit_behaviour": "soft",
"blocked": false,
"features": { "text": true, "images": true, "files": true, "voice": false },
"cancel_at_period_end": false,
"current_period_end": "2026-11-18T10:00:00+00:00",
"warnings": [],
"app": { "install_id": "4f1c2b8e-6a0d-4c4e-9a43-7d2f0b1e9c55", "app_id": "42", "app_name": "City Classifieds", "bound": true },
"valid_until": "2026-10-19T10:00:00+00:00",
"check_again_after": 86400,
"entitlement": "v2.eyJrZXlfaWQiOi....",
"entitlement_key_id": "3f2a9c1d0b7e4a65",
"server_time": "2026-10-18T10:00:00+00:00",
"manage_url": "https://chat.zeltacode.com/messaging"
}
metricisactive_users(distinct chat users in the calendar month) ormessages(messages in the calendar month). It is set per plan.- Usage is counted per calendar month in UTC (the 1st 00:00 to the last day 23:59:59 UTC), not per billing period.
limitandapps_limitarenullwhen the plan is unlimited.grace_days(0 to 30, set per plan): see Offline grace.featuressays what app users may send on this plan:text(text and emoji, alwaystrue),images(photos),filesandvoice(voice notes). Offer only what istrue, and refuse the others on your server too. It follows plan changes at the next verify. Treat a missingfeatures(or a missing key inside it) asfalse: text only.warningscan containpayment_past_due,subscription_canceling,usage_80_percent,usage_90_percent,usage_100_percentorover_limit.- Usage arrives once a day, so
used,over_limitand a block can lag the real use by up to a day or two.
Signed entitlement
The entitlement is v2.<claims>.<signature>: claims is the base64url (no padding) JSON of the answer
(key_id, iss, install_id, app_id, status, plan, metric,
limit, used, apps_limit, grace_days, over_limit, blocked, features, iat, exp), and
signature is the base64url Ed25519 signature (64 bytes) of the ASCII text v2.<claims>. Check it offline with the public key below.
Never trust a cached answer without a valid entitlement.
New claims can be added within v2 (as features was): ignore claims you do not know. A claim is never removed or given a new meaning within v2; that would be a new version.
Not configured yet key_id: -
function messagingEntitlement(?string $token, string $publicKey, string $keyId, string $installId, string $appId): ?array
{
$parts = explode('.', (string) $token);
if (count($parts) !== 3 || $parts[0] !== 'v2') {
return null;
}
$b64 = fn (string $v) => base64_decode(strtr($v, '-_', '+/'), true);
$signature = $b64($parts[2]);
if ($signature === false || strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES) {
return null; // also avoids a SodiumException on a damaged token
}
if (! sodium_crypto_sign_verify_detached($signature, 'v2.'.$parts[1], base64_decode($publicKey, true))) {
return null;
}
$claims = json_decode((string) $b64($parts[1]), true);
if (! is_array($claims)
|| ($claims['iss'] ?? null) !== 'chatflow-messaging'
|| ($claims['key_id'] ?? null) !== $keyId
|| (string) ($claims['install_id'] ?? '') !== (string) $installId
|| (string) ($claims['app_id'] ?? '') !== (string) $appId) {
return null;
}
return $claims; // then apply the offline grace rule to $claims['exp'] and $claims['grace_days']
}
Offline grace
One rule:
- Always check the signature,
iss,key_id,install_idandapp_id(snippet above). - Chat is on while
now <= expand the claims allow it (blockedfalse). - When the service cannot be reached (timeout,
5xx,429, or503 entitlement_unavailable), keep accepting the last entitlement untilexp + grace_days × 86400, usinggrace_daysfrom the signed claims. After that, turn chat off and tell the owner, until a verify succeeds. - An explicit answer (
401,403,409) ends access at once, whatever the cached entitlement says.
POST /usage
Reports daily counts for apps that are bound. Send a batch once a day; split larger sets into batches of at most 400 rows. A day that is sent again
replaces that day; it is never added twice, so it is safe to resend after an error (retry a 5xx). Days older than 35 days or in the future are rejected.
active_users_mtd (distinct chat users from the first of the month up to that day) is optional but recommended: it is what the active_users metric counts.
Unbinding an app frees its slot only: its figures for the month keep counting, and they are attached to the app again when it verifies again.
POST /api/messaging/v1/usage
{
"install_id": "4f1c2b8e-6a0d-4c4e-9a43-7d2f0b1e9c55",
"reports": [
{ "app_id": "42", "date": "2026-10-17", "active_users": 37, "active_users_mtd": 121, "messages": 940 },
{ "app_id": "43", "date": "2026-10-17", "active_users": 5, "active_users_mtd": 18, "messages": 60 }
]
}
{
"success": true,
"accepted": 1,
"rejected": [
{ "index": 1, "app_id": "43", "date": "2026-10-17", "error": "app_not_bound" }
],
"status": "active",
"metric": "active_users",
"limit": 500,
"used": 121,
"usage_percent": 24,
"period": { "start": "2026-10-01", "end": "2026-10-31" },
"over_limit": false,
"over_limit_behaviour": "soft",
"blocked": false,
"warnings": [],
"manage_url": "https://chat.zeltacode.com/messaging"
}
Per-row rejections: app_not_bound (verify the app first), date_too_old, date_in_future,
value_too_large (a value far above the plan limit; check the counting).
GET /status
Returns the same plan and usage fields as verify, plus the list of bound apps with their usage this month. It changes nothing and binds nothing.
DELETE /apps
Releases an app's slot when the app is deleted in the installation. Its usage this month keeps counting. Calling it for an app that is not bound is fine ("unbound": false).
DELETE /api/messaging/v1/apps
{ "install_id": "4f1c2b8e-6a0d-4c4e-9a43-7d2f0b1e9c55", "app_id": "42" }
{ "success": true, "unbound": true, "apps_limit": 3, "apps_used": 0, "manage_url": "https://chat.zeltacode.com/messaging" }
Errors
Every error has the same shape. Errors about the subscription or limits also include the plan and usage fields shown above.
Show the app owner your own text for each error code and link to manage_url; message is for developers.
{ "success": false, "error": "invalid_key", "message": "Invalid or revoked Messaging API key.", "manage_url": "https://chat.zeltacode.com/messaging" }
| HTTP | error | Meaning and what the app should do |
|---|---|---|
| 401 | invalid_key | Missing, unknown, rotated or wrong-product key. Turn chat off and ask the owner for a valid key. |
| 403 | subscription_inactive | No active subscription. status says why: none, pending, expired, canceled, incomplete, incomplete_expired, unpaid, refunded, disputed, needs_attention or inactive. Turn new messages off; keep old conversations readable. |
| 403 | over_limit | The monthly limit is used up and the plan is set to block. Stop new messages until the next period or an upgrade. Plans set to soft never return this error; they return 200 with over_limit: true. |
| 409 | apps_limit_reached | All app slots are taken. The owner can unbind an app or choose a larger plan at manage_url. |
| 422 | validation_failed | The request body is not valid; see errors. |
| 429 | rate_limited | Too many requests. Treat like an outage: keep the last good answer, wait Retry-After, back off. |
| 503 | entitlement_unavailable | The service cannot sign answers right now. Treat like an outage (offline grace). |
| 5xx / timeout | - | The service could not be reached. Apply the offline grace rule. |
Rate limits: 60 requests per minute per key and installation, and 600 per key overall.
How a chat module uses it
- The app owner gets a Messaging key from this site and pastes it into the chat module of their app.
- The installation calls
verifyright away and then about once a day per app (at its own offset), and caches the answer and its entitlement. - Chat follows the offline grace rule above.
- On
invalid_keyorsubscription_inactivenew messages are turned off at once; existing conversations stay readable. - Once a day the installation sends yesterday's (and any missed) daily counts per app to
usage. Resending is safe. - When an app is deleted, the installation calls
DELETE /appsto free its slot.