https://waestaos.waestaltd.comSystems and licence API
How a connected system (Benofy, Webook, EduMaster, a client's own application...) stays linked to Waesta OS: it reports its health, asks whether its subscription is current, and learns when a newer version exists. New here? Read API.md first (keys, errors, rate limits). Machine-readable: openapi.json.
Key: wcp_... (the same key as the support tickets API) · Base: https://waestaos.waestaltd.com/api/v1
How it fits together
One key per system and environment links everything:
| Your system does this | Call | Waesta OS does this |
|---|---|---|
| Says "I am alive" every minute or two | POST /systems/heartbeat | Shows it on the *Operations* board with its version, errors and users; pages the duty person if it goes silent or reports down |
| Checks it is allowed to run | GET /subscription (or read licence in the heartbeat reply) | Answers from the linked subscription, so suspending a subscription in Waesta OS switches the system off by itself |
| Finds out about upgrades | latest_version and update_available in the heartbeat reply | Compares what it runs with the newest upgrade logged for that system |
| A user reports a problem | POST /support/tickets (guide) | Opens a case, runs the response clock, calls your webhook on every reply, asks the user to rate the fix |
| Sells or sends SMS | /sms/* (guide) | Credits, sends, refunds failures, calls your callback when credits arrive |
Websites that are not connected systems (a client's brochure site) have no key. Staff add them in *Operations → Add website or system*; they are then checked from outside every few minutes for availability, speed and SSL expiry. Their renewals (hosting, domain, subscription) are entered by hand and alerted automatically.
Heartbeat
POST /api/v1/systems/heartbeat
Send it from a scheduled job on your server (cron, a queue worker, a scheduler), every 1 to 5 minutes. The first call creates the system on the Operations board under the key's name.
| Field | Required | Meaning |
|---|---|---|
status | no (default ok) | ok, degraded (running but impaired) or down (you know something critical is broken) |
version | no | What you run, for example 2.4.1. Compared with our upgrade log |
environment | no | production, staging... (up to 20 characters) |
errors_24h | no | Whole number: errors logged in the last 24 hours |
active_users | no | Whole number: people active right now or today (say which in your own notes) |
message | no | A sentence shown to staff, mainly with degraded or down |
metrics | no | Object of up to 20 small values you want staff to see, for example {"queue_depth": 12, "db_ms": 18, "backup_ok": true}. Keys: letters, numbers, . _ -. Values: numbers, true/false or text up to 100 characters |
curl -X POST https://waestaos.waestaltd.com/api/v1/systems/heartbeat \
-H "Authorization: Bearer wcp_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"status":"ok","version":"2.4.1","environment":"production","errors_24h":3,"active_users":412,"metrics":{"queue_depth":12,"backup_ok":true}}'
Reply:
{
"ok": true,
"server_time": "2026-10-03T08:15:02+00:00",
"latest_version": "2.5.0",
"update_available": true,
"licence": {
"linked": true, "entitled": true, "status": "active",
"subscription": "SUB-00042", "plan": "Benofy school licence",
"renewal_date": "2026-12-01", "days_left": 59, "note": null
}
}
When we treat a system as down
| What we see | Result |
|---|---|
A heartbeat with status: "down" | Down at once |
A heartbeat with status: "degraded" | Shown as slow or degraded; no page |
| No heartbeat for longer than the silence limit (default 30 minutes, set per system by staff) | Down |
The next ok heartbeat | Recovered; the incident closes and staff are told |
Going down opens an incident: staff see it on *Operations*, the alert email and the duty phone (SMS) are told straight away, and if nobody presses "I am on it" within the escalation time (default 20 minutes) the escalation contacts are tried once. Your system does not need to do anything to make this happen: just keep sending heartbeats.
Licence check
GET /api/v1/subscription
curl https://waestaos.waestaltd.com/api/v1/subscription -H "Authorization: Bearer wcp_YOUR_KEY"
| Field | Meaning |
|---|---|
linked | false until staff link this key to the client's subscription (*Customer care → Systems → the system → Link licence*) |
entitled | true while the subscription is trial, active or pending_renewal; false when suspended, expired or cancelled; null when not linked |
status, subscription, plan | The subscription behind the answer |
renewal_date, days_left | When it renews and how many days are left (negative once past) |
plan_code | The short code of the plan the client is on (for example starter or pro), or null when the plan has none. The same code is used for every billing period of a tier |
billing_frequency | How often they are billed: monthly, quarterly, semi_annual, annual or biennial |
limits | What that plan allows, as an object such as {"members": 500, "sms_per_month": 1000, "white_label": false}. Waesta sets them per plan; your system enforces them |
pending_change | null, or {"plan_code", "plan", "effective_date", "limits"} when a downgrade or other change is already scheduled, so you can warn the client in good time |
exempt, account_kind | exempt: true for Waesta's own accounts (account_kind internal) and demo accounts (demo): they need no licence, so entitled is always true and status is "exempt". Real clients are account_kind: "client" and exempt: false |
Recommended behaviour in your system:
- Ask at start-up and then every 15 to 60 minutes (the heartbeat reply carries the same
licenceobject, so one call can do both). - Cache the last good answer. If our service is unreachable, keep running on the cached answer: never lock paying users out because of a network problem.
- When
entitledisfalse, stop new work and show a clear message ("Your subscription is suspended. Contact Waesta on ..."), but never delete or hide the customer's data. - When
days_leftis 14 or less, you may show a gentle renewal reminder. We also email and notify the client ourselves. entitled: nullmeans "not set up yet": treat it as allowed.- Plans and upgrades: read
plan_codeandlimitsfrom every answer and enforce them, rather than keeping a copy of your plans. When staff upgrade a client, the new limits arrive on your next check (an upgrade starts at once; a downgrade starts at the next renewal and is announced inpending_changefirst). Never lock someone out because they are above a new, lower limit: stop new additions and tell them. - Waesta HQ and demo tenants always answer
entitled: truewithexempt: true. Do not special-case them by name in your code: just obeyentitled. - A subscription can be suspended automatically when its invoice stays unpaid (a setting in Waesta OS) and is reactivated by itself when paid. You only ever see that as
entitledturningfalseand thentrueagain.
Plans and upgrades from your system
A client can be moved to another plan without anyone at Waesta touching it, so your "Upgrade" button can really upgrade. Both calls use the same wcp_ key and need the key to be licensed under a subscription.
GET /api/v1/plans lists the plans this client can move to:
{ "ok": true, "linked": true, "self_service": true, "subscription": "SUB-00042",
"plans": [
{ "plan_code": "starter", "name": "Benofy Starter", "price": "30000.00", "currency": "UGX", "billing_frequency": "monthly", "limits": { "members": 100 }, "current": true, "change": null, "selectable": false },
{ "plan_code": "team", "name": "Benofy Team", "price": "45000.00", "currency": "UGX", "billing_frequency": "monthly", "limits": { "members": 250 }, "current": false, "change": "upgrade", "selectable": true },
{ "plan_code": "pro", "name": "Benofy Pro", "price": "60000.00", "currency": "UGX", "billing_frequency": "monthly", "limits": { "members": 500, "white_label": true }, "current": false, "change": "upgrade", "selectable": true } ] }
price is what this client would pay per billing period (their own special price if they have one). A tier can be sold monthly, quarterly and yearly under the same plan_code, so each period is its own entry with its billing_frequency. change says whether moving there is an upgrade or a downgrade, comparing what a month costs; period_change: true means it changes how often they are billed, which always starts at the next renewal. Plans without a plan code are not listed, and selectable: false means staff must set the price (for example the plan is priced in another currency). If self_service is false, plan changes are made by Waesta and the next call is refused.
POST /api/v1/subscription/plan moves them:
curl -X POST https://waestaos.waestaltd.com/api/v1/subscription/plan \
-H "Authorization: Bearer wcp_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"plan_code":"pro","preview":true}'
| Field | Meaning |
|---|---|
plan_code | Required. One of the codes from GET /plans |
preview | true shows exactly what would happen and changes nothing. Always show this to the client before the real call |
billing_frequency | Optional. Which billing period of that plan_code you mean. Left out, the client keeps the period they are on |
when | auto (default) or renewal. auto starts an upgrade now and a downgrade at the next renewal; renewal puts either off until then. A system cannot force a cheaper plan to start now |
Reply (200):
{ "ok": true, "preview": false, "status": "applied",
"change": { "kind": "upgrade", "effective_date": "2026-10-05", "old_price": "30000.00", "new_price": "60000.00", "currency": "UGX", "billed_now": "26129.03",
"from": { "plan_code": "starter", "limits": { "members": 100 } }, "to": { "plan_code": "pro", "limits": { "members": 500 } } },
"invoice": { "invoice_no": "INV-2026-00002", "total": "26129.03", "currency": "UGX", "due_date": "2026-10-19", "pay_url": "https://waestaos.waestaltd.com/i/ab12..." },
"licence": { "linked": true, "entitled": true, "plan_code": "pro", "limits": { "members": 500 } } }
statusisapplied(live now) orscheduled(starts oneffective_date; the licence then shows it underpending_change).- An upgrade starts at once and invoices the price difference for the rest of the already-paid period (
billed_now). Send the payer toinvoice.pay_url. If it is not paid, the normal reminder and suspension rules apply. - A downgrade starts at the next renewal: nothing is refunded, and what they paid for stays until then.
- Waesta HQ and demo accounts change plan without any invoice (
billed_nowis0.00). - Errors:
422with a message (unknownplan_code, already on that plan, subscription not live, price needed),403when self-service is off,409when no subscription is linked,429after 10 changes an hour from one key. - Use a fresh
GET /subscription(or thelicencein the reply) after a change, and enforce the newlimits.
Licence webhooks
You do not have to wait for your next licence check to learn that something changed. If the key has a webhook address (Customer care, Systems, the key), we send **licence.changed** the moment the subscription is suspended, reactivated, expires, is cancelled, or its plan changes or is scheduled to change, or the key is linked or unlinked. It travels the same signed channel as ticket events (see SUPPORT_API.md: verify X-Waesta-Signature first, ignore repeated X-Waesta-Delivery ids), and your receiver tells it apart by event:
{ "event": "licence.changed", "sent_at": "2026-10-05T10:15:00+00:00", "reason": "suspended_unpaid",
"licence": { "linked": true, "entitled": false, "status": "suspended", "subscription": "SUB-00042", "plan_code": "pro", "limits": { "members": 500 }, "pending_change": null } }
reason is one of suspended, suspended_unpaid (suspended automatically for non-payment), reactivated, reactivated_paid, expired, cancelled, plan_changed, plan_change_scheduled, plan_change_cancelled, linked, unlinked or status_changed. The licence object is the same as GET /subscription, so the simplest receiver replaces its cached licence with it. Treat the webhook as a nudge to refresh, and keep checking GET /subscription regularly as well: a missed webhook must never leave a client wrongly switched on or off.
Examples
PHP: a heartbeat job (run from cron every 2 minutes)
$ch = curl_init('https://waestaos.waestaltd.com/api/v1/systems/heartbeat');
curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('WAESTA_KEY'), 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'status' => 'ok', 'version' => APP_VERSION, 'environment' => 'production',
'errors_24h' => count_recent_errors(), 'active_users' => count_active_users(),
]),
]);
$reply = json_decode((string) curl_exec($ch), true);
if (($reply['ok'] ?? false) && isset($reply['licence'])) {
file_put_contents(__DIR__ . '/storage/licence.json', json_encode($reply['licence'])); // cache for the app to read
}
Node.js
async function heartbeat() {
const res = await fetch('https://waestaos.waestaltd.com/api/v1/systems/heartbeat', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.WAESTA_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'ok', version: process.env.APP_VERSION, environment: 'production', errors_24h: await recentErrors() }),
signal: AbortSignal.timeout(10_000),
});
const body = await res.json();
if (body.ok) licence = body.licence; // keep the last good answer in memory or a file
}
setInterval(() => heartbeat().catch(() => {}), 120_000);
Python
import os, requests
def heartbeat():
r = requests.post("https://waestaos.waestaltd.com/api/v1/systems/heartbeat", timeout=10,
headers={"Authorization": "Bearer " + os.environ["WAESTA_KEY"]},
json={"status": "ok", "version": APP_VERSION, "environment": "production", "errors_24h": recent_errors()})
return r.json() if r.ok else None
**cron (any system with curl)**
*/2 * * * * curl -s -m 10 -X POST https://waestaos.waestaltd.com/api/v1/systems/heartbeat -H "Authorization: Bearer $WAESTA_KEY" -H "Content-Type: application/json" -d '{"status":"ok","version":"2.4.1"}' >/dev/null
Errors
| HTTP | Meaning |
|---|---|
| 401 | Missing, wrong or revoked key |
| 422 | A field is wrong (the message says which): status not one of ok, degraded, down; a count that is not a whole number; too many or badly named metrics |
| 429 | More than 120 calls a minute on this key: one heartbeat every minute or two is plenty |
Integration checklist for a new system
- ☐ Staff create a key for the system and environment (*Customer care → Systems → the system → Connect this system*) and link it to the client's subscription.
- ☐ The key is in the system's server environment, never in browser or mobile code.
- ☐ A scheduled job sends the heartbeat every 1 to 5 minutes with
version. - ☐ The system reads
licence(cached) and handlesentitled: falsekindly. - ☐ User problem reports go to the support API with
external_refandreporter, and the webhook receiver is in place. - ☐ If it sends SMS: it uses the SMS API with an
Idempotency-Key, and handles402. - ☐ Each release is logged in the system's upgrade log with its
version, so "update available" is accurate.