WWaesta Enterprises U Ltd +256750081491 [email protected]
Base address for every call: https://waestaos.waestaltd.com

Systems 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:

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.

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

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"

Recommended behaviour in your system:

  1. Ask at start-up and then every 15 to 60 minutes (the heartbeat reply carries the same licence object, so one call can do both).
  2. 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.
  3. When entitled is false, stop new work and show a clear message ("Your subscription is suspended. Contact Waesta on ..."), but never delete or hide the customer's data.
  4. When days_left is 14 or less, you may show a gentle renewal reminder. We also email and notify the client ourselves.
  5. entitled: null means "not set up yet": treat it as allowed.
  6. Plans and upgrades: read plan_code and limits from 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 in pending_change first). Never lock someone out because they are above a new, lower limit: stop new additions and tell them.
  7. Waesta HQ and demo tenants always answer entitled: true with exempt: true. Do not special-case them by name in your code: just obey entitled.
  8. 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 entitled turning false and then true again.

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}'

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 } } }
  • status is applied (live now) or scheduled (starts on effective_date; the licence then shows it under pending_change).
  • An upgrade starts at once and invoices the price difference for the rest of the already-paid period (billed_now). Send the payer to invoice.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_now is 0.00).
  • Errors: 422 with a message (unknown plan_code, already on that plan, subscription not live, price needed), 403 when self-service is off, 409 when no subscription is linked, 429 after 10 changes an hour from one key.
  • Use a fresh GET /subscription (or the licence in the reply) after a change, and enforce the new limits.

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

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 handles entitled: false kindly.
  • ☐ User problem reports go to the support API with external_ref and reporter, and the webhook receiver is in place.
  • ☐ If it sends SMS: it uses the SMS API with an Idempotency-Key, and handles 402.
  • ☐ Each release is logged in the system's upgrade log with its version, so "update available" is accurate.