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.

Base URL and headers
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 verify at its own offset, crc32(install_id + "|" + app_id) % 86400 seconds after midnight UTC, and the installation's usage report (yesterday's counts) at crc32(install_id) % 86400 seconds 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 429 like a 5xx: keep the last good answer, wait the number of seconds in Retry-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.

Request
POST /api/messaging/v1/verify

{
  "install_id": "4f1c2b8e-6a0d-4c4e-9a43-7d2f0b1e9c55",
  "app_id": "42",
  "app_name": "City Classifieds"
}
Response 200
{
  "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"
}
  • metric is active_users (distinct chat users in the calendar month) or messages (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.
  • limit and apps_limit are null when the plan is unlimited.
  • grace_days (0 to 30, set per plan): see Offline grace.
  • features says what app users may send on this plan: text (text and emoji, always true), images (photos), files and voice (voice notes). Offer only what is true, and refuse the others on your server too. It follows plan changes at the next verify. Treat a missing features (or a missing key inside it) as false: text only.
  • warnings can contain payment_past_due, subscription_canceling, usage_80_percent, usage_90_percent, usage_100_percent or over_limit.
  • Usage arrives once a day, so used, over_limit and 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.

Public key (Ed25519, base64)
Not configured yet
key_id: -
Verify in PHP (sodium)
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_id and app_id (snippet above).
  • Chat is on while now <= exp and the claims allow it (blocked false).
  • When the service cannot be reached (timeout, 5xx, 429, or 503 entitlement_unavailable), keep accepting the last entitlement until exp + grace_days × 86400, using grace_days from 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.

Request
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 }
  ]
}
Response 200
{
  "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).

Request and response
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.

Error body
{ "success": false, "error": "invalid_key", "message": "Invalid or revoked Messaging API key.", "manage_url": "https://chat.zeltacode.com/messaging" }
HTTPerrorMeaning and what the app should do
401invalid_keyMissing, unknown, rotated or wrong-product key. Turn chat off and ask the owner for a valid key.
403subscription_inactiveNo 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.
403over_limitThe 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.
409apps_limit_reachedAll app slots are taken. The owner can unbind an app or choose a larger plan at manage_url.
422validation_failedThe request body is not valid; see errors.
429rate_limitedToo many requests. Treat like an outage: keep the last good answer, wait Retry-After, back off.
503entitlement_unavailableThe 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

  1. The app owner gets a Messaging key from this site and pastes it into the chat module of their app.
  2. The installation calls verify right away and then about once a day per app (at its own offset), and caches the answer and its entitlement.
  3. Chat follows the offline grace rule above.
  4. On invalid_key or subscription_inactive new messages are turned off at once; existing conversations stay readable.
  5. Once a day the installation sends yesterday's (and any missed) daily counts per app to usage. Resending is safe.
  6. When an app is deleted, the installation calls DELETE /apps to free its slot.