Building JSON APIs
- Overview
- Stateless Routes
- The
ApiActionBase Class - CORS
- Health Probes
- Capability Tokens
- Bearer Authentication
- Request Bodies
- Testing JSON Responses
- Request Correlation
Overview
Sukarix includes a small set of building blocks for machine-facing JSON endpoints, as opposed to the session-backed, form-submitting web actions covered elsewhere in these docs:
- Stateless routing — opt a path prefix out of session creation entirely.
Sukarix\Actions\ApiAction— a CSRF-exempt base action answering every error path with a JSON envelope.Sukarix\Http\Cors— origin allow-listing and preflight handling.Sukarix\Actions\Health\Probe— liveness/readiness endpoints for orchestrators and load balancers.Sukarix\Security\CapabilityToken— HMAC-signed, short-lived tokens for machine-to-machine calls.
None of this replaces session-based authentication for browser-facing routes; it is meant for endpoints called by other services.
Stateless Routes
By default every request boots a session. ApiAction is normally paired with a route under
SECURITY.stateless.prefixes (/api by default), which skips that step entirely — see
Session-Free Routes for how to configure it, including how to turn it off
for an API that authenticates through the session itself.
Note
$this->sessionbecomes nullable on a stateless route
Boot::$sessionisnullfor the lifetime of a stateless request.Action::beforeroute()andgetRole()already guard every access, but any code you write that reaches for$this->sessionon such a route must check fornullfirst.
The ApiAction Base Class
Sukarix\Actions\ApiAction extends Action with defaults suited to a JSON API:
use Sukarix\Actions\ApiAction;
class Users extends ApiAction
{
public function show($f3, $params): void
{
// ...
$this->json(['id' => $params['id'], 'name' => $user->name]);
}
}
$csrfExemptistrueby default — a machine caller holds no session token to send.onAccessAuthorizeDeny()answers a denied route with a JSON403instead of the HTML flowActionuses.onError()is a static handler you can register asONERRORfor API routes; it turns any uncaught error into the same JSON envelope, usingERROR.codeandERROR.textfrom the hive:
{ "success": false, "message": "Not Found", "status": 404 }
Wire it up for your API prefix from your own Bootstrap, for example by checking PATH before calling
Debugger::enable() in handleException().
CORS
Sukarix\Http\Cors::handle() answers a preflight OPTIONS request for an allow-listed origin and tags every response
Vary: Origin:
[api.cors]
origins[] = https://app.example.com
origins[] = https://admin.example.com
ApiAction::options() calls it automatically whenever api.cors.origins is configured, and falls back to a plain
204 otherwise:
Cors::handle(
$f3,
$allowedOrigins,
allowedMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
);
An origin not on the allow list is simply ignored — no CORS headers are sent, and the browser enforces the same-origin policy as usual.
Health Probes
Sukarix\Actions\Health\Probe exposes liveness and readiness checks, both CSRF-exempt and, when reached under a
stateless prefix, session-free:
[routes]
GET @health_live : /api/health/live = Sukarix\Actions\Health\Probe->liveness
GET @health_ready : /api/health/ready = Sukarix\Actions\Health\Probe->readiness
liveness()answers200with{ "status": "ok", "request_id": "..." }— use it to know the process is up.readiness()additionally pings Redis whenCACHEis Redis-backed (redis=...), answering503with"status": "degraded"when a dependency is unreachable.
Capability Tokens
Sukarix\Security\CapabilityToken issues and verifies compact, HMAC-signed tokens for a machine caller to present as
a Bearer token — no session, no database round trip to verify:
use Sukarix\Security\CapabilityToken;
// Issuing side
$token = CapabilityToken::issue($secret, ['iss' => 'billing', 'aud' => 'jobs'], ttl: 3600);
// Verifying side
$claims = CapabilityToken::verify($secret, $token, audience: 'jobs');
if (null === $claims) {
$this->error('Unauthorized', 401);
}
A token is a base64url payload and signature joined by a dot (payload.signature), signed with HMAC-SHA256.
verify() returns null — never throws — when the signature does not match, the token is malformed, it has expired
(exp claim), or it was issued for a different audience.
Warning
The signing secret is the only thing standing between “verified” and “forged”
Keep it out of version control (an untracked
.inifile, per Configuration) and rotate it if it ever leaks.CapabilityTokendoes not manage secret storage or rotation for you.
Bearer Authentication
Action::parseHeaderAuthorization() strips both the Basic and Bearer schemes from the Authorization header.
isApiUserVerified() still assumes Basic-encoded user:password credentials (see
Authentication); for a Bearer-scheme token, read HEADERS.Authorization directly
in your action and hand the token to CapabilityToken::verify().
Request Bodies
Action::getDecodedBody() accepts a JSON body, a form-encoded body, or nothing at all — a form-encoded body is
detected by inspecting the first byte, and an empty body falls back to POST. This keeps
data validation endpoints working the same way whether the caller is a browser form,
a JSON API client, or a Statera test.
Testing JSON Responses
Response::json() calls exit after writing the body, which is normally what you want — nothing accidentally runs
after a response has already been sent. Under Environment::isTest() that exit is skipped, so a Statera test can
call an action and make assertions afterward.
When an action needs to compose the body itself — wrapping it, streaming it, or handing it to something else —
Response::renderJson() behaves like json() but returns the encoded string instead of echoing and exiting:
$body = $this->response->renderJson(['status' => 'ok']);
Request Correlation
Every request is stamped with a request id that flows into your logs; see Logging for how to read and forward it.