API and webhooks
CPalius lets modules open up to the outside world in two ways: the REST API (an external system reads/writes from you) and webhooks (you notify an external system or an external system notifies you).
REST API
All API requests pass through a single gateway: /api/*. Adding #[CpApi] to a service method makes it an endpoint; you do not need to write a controller.
final class PostsApi
{
#[CpApi(path: '/blog/posts/{id}', methods: ['GET'], public: false, capability: 'blog.api.read')]
public function show(int $id): array
{
return ['id' => $id, 'title' => '…'];
}
#[CpApi(path: '/blog/posts', methods: ['GET'], public: true)]
public function list(): array { /* public list */ }
}
| Parameter | Meaning |
|---|---|
path |
The path after /api (/blog/posts/{id}). Do not write the /api prefix |
methods |
Allowed HTTP methods (default ['GET']) |
public |
If true, no key is required (default false) |
capability |
Required capability. {name} tokens in the path are substituted; leaving it empty on a private endpoint gives 403 (safe default) |
If an endpoint fails, the gateway returns a 500 JSON and writes the error to the module_quarantine.log file; the gateway itself does not crash.
Private endpoints: API key
Private endpoints require the X-CP-API-KEY header. It works fail-closed: a request is rejected without a valid key and a resolved capability.
curl -H "X-CP-API-KEY: cpk_…" https://site.example/api/blog/posts/12
Keys are created from the AACP → Automation → API keys screen (permission: system.api.manage). Properties of a key:
- Label and capability list: the key can use only the capabilities it is given. The
*wildcard is never a valid key permission. - IP allowlist (optional): if filled in, only these client IPs can use it. The IP is found taking trusted proxies into account (
getClientIp); rawX-Forwarded-Foris not used. - Tenant (optional): in a multi-tenant installation binds the key to a tenant.
- On/off: can be disabled without deleting.
Only the key's SHA-256 digest and its last 4 characters are stored in the database. The plain key is shown once, the moment it is created; if you lose it, generate a new one.
Rate limit
A fixed-window (60 s) limiter:
| Bucket | Limit |
|---|---|
| Keyed request | 60 per minute |
| Anonymous (public endpoint) | 20 per minute |
| Incoming hooks | 30 per minute |
If the cache fails the limiter rejects (fail-closed).
Idempotency
For methods that change data (POST, PUT, PATCH, DELETE) the client can send an Idempotency-Key header. A repeated request with the same key returns the first response instead of doing the operation a second time (24 hours). If the same key arrives with a different body, 409 is returned. It is for safe retries after a network drop.
Access log
Every API access (action, key, path, status, duration ms, client IP) is added to the audit log; the plain key is never written.
Outgoing webhooks
When an event occurs, send a signed POST to an external URL. A subscription is created from the AACP → Automation → Webhooks screen (permission: system.webhooks.manage): a label, a URL and an event list (at most 32; an event name has the form module.event).
Secret
When a subscription is created or rotated, a signing secret with the whsec_ prefix is generated; it is shown only at that moment. What is stored is its encrypted form and the last 4 characters.
Envelope and signature
The body is a fixed envelope; the X-CP-Webhook-Signature header carries a timestamped HMAC. Verification on the receiving side:
$ok = $signer->verify($request->headers->get('X-CP-Webhook-Signature'), $rawBody, $secret, time());
// a timestamp older than 300 seconds is rejected (against replay attacks)
Verify on the raw body; do not re-serialize the JSON and sign it.
Delivery
WebhookEventEmitter::emit() only enqueues; the HTTP request is never made in the calling process. The queue worker does the actual sending, with a 5-second timeout, reading only the first 2 KB of the response. A failed delivery is retried with backoff. See Hooks, cron, queue.
$this->events->emit('shop.order.paid', ['order_id' => 42], resource: 'order');
SSRF protection
A webhook can be a tool for an attacker to make the server send requests into the internal network. That is why there are two independent layers:
- At write time: when the URL is saved (
SsrfGuard), the scheme, credentials, host and resolved IP are checked. Only https; private, link-local and cloud metadata addresses are rejected. - At send time: DNS can change between the two moments (DNS rebinding), so the client re-verifies the resolution.
Incoming webhooks
If an external system (a payment provider, CI) is going to notify you, the module defines an incoming endpoint:
final class StripeInbound implements InboundWebhookHandlerInterface
{
public function endpointId(): string { return 'stripe'; }
public function handle(array $payload): void
{
// The signature was verified IN THE CORE; this code receives a verified payload.
}
}
The endpoint is published under /hooks/<endpoint>. The core verifies the signature before module code runs, limits the body to 64 KB, applies the rate limit and throws the job into the queue; the handler runs in the queue worker. This way a slow or faulty handler does not keep the sending system waiting.
Security summary
| Risk | Measure |
|---|---|
| Key leak | Only the digest is stored; capability restriction; IP allowlist; on/off |
| Privilege escalation | * wildcard forbidden; empty capability = 403 |
| Excessive requests | Rate limit, fail-closed |
| Double processing | Idempotency-Key, 24 hours |
| SSRF | Separate checks at write and send; https only |
| Fake webhook | HMAC + timestamp tolerance |
| Slow handler | Queue; no delivery in the HTTP thread |