Appearance
The public API
Read and write Kiwire from your own systems. The specification itself is the Swagger reference; this page is how to use it.
Getting a key
API keys are issued per admin, not per tenant. Any admin can create one for themselves from My Profile › API Keys in the admin panel. Give the key a name and, optionally, an expiry date and a list of allowed source IPs. The key is stored in the tenant's own database, so authentication never has to reach outside the tenant to validate it.
The raw key — a 64-character random string — is shown exactly once, at creation time. Kiwire stores only its SHA-256 hash plus an 8-character prefix for display, so if you lose the raw value there is no way to recover it; delete the key and issue a new one.
Authentication
Every request to /api/v1/* needs two headers: X-Tenant-ID and X-Api-Key. Kiwire looks up the tenant by X-Tenant-ID (rejecting inactive tenants), hashes X-Api-Key with SHA-256 and matches it against the stored hash, then checks that the key has not expired and that the request's source IP is on the key's allowlist (when one is set — an empty allowlist means any IP). A key only ever exists in the database of the tenant it belongs to, so if the lookup succeeds the key is already authorised for that tenant; there is no separate per-request tenant check beyond that.
A request missing either header gets a 401 immediately. An unknown key, an expired key, an IP outside the allowlist, or an inactive admin account behind the key all fail closed with 401/403.
Rate limits
Keys are rate-limited per minute. The default is 25 requests/minute, but a tenant's licence can carry its own api_rate_limit value, which overrides the default for every key on that tenant (cached for 5 minutes so the licence doesn't have to be decoded on every call). Exceeding the limit returns HTTP 429 with X-RateLimit-Limit, X-RateLimit-Remaining: 0 and Retry-After: 60 headers, and the counter resets on the minute.
Accounts and vouchers
Most resources follow the same CRUD shape: a paginated list endpoint (GET /{resource}/{offset}/{limit}/{column}/{order}), a GET by natural key, POST to create, PATCH to update, and DELETE. This covers accounts (user), admins (admin), profiles, zones, roles, device controllers, captive-portal journeys and pages (including the enterprise variants), topup codes, and campaigns.
Vouchers are the one exception to the pattern: voucher supports create, look up a single voucher by username, list all vouchers in a batch (GET /voucher/bulk/{bulkId}), and delete — there is no update endpoint, since a voucher is a single-use code rather than a record you edit in place.
Sessions and disconnect
Session access is read-only today. GET /portal-session/lookup?session_id=... reads the live PHP session for a given session ID (if it is still alive) and returns the matching portal log lines alongside it; GET /portal-session/by-mac does the equivalent lookup by MAC address. There is no endpoint in the v1 API to disconnect or kick an active session — CoA (Change of Authorization) is configured per device controller (a coa_port, default 3799) and triggered internally by Kiwire itself (for example when a profile or campaign changes), not as a public API action.
Reports
There is no dedicated reporting/analytics endpoint in the v1 API — the dashboards under Admin › Reporting are not exposed here. The closest thing is portal-log: GET /portal-log/files lists the raw captive-portal log files for the authenticated tenant grouped by local date, GET /portal-log/read returns the last N lines of one file (optionally filtered with a case-insensitive grep), and GET /portal-log/tail returns lines appended since a byte offset, for polling. Treat this as a low-level trace tool for one tenant's captive-portal activity, not an aggregate-reporting API.
Where the specification lives
The full OpenAPI specification, generated from the API code itself so it cannot drift from what the endpoints accept, is browsable at /documentations/api. The underlying raw spec files are served at /api-docs.json and /api-docs.yaml — that path was deliberately moved out from under /docs, because /docs/ is now where the static documentation site you are reading lives, served directly by nginx, and would otherwise have swallowed requests for the spec before they reached PHP.