Skip to content

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); raw X-Forwarded-For is not used.
  • Tenant (optional): in a multi-tenant installation binds the key to a tenant.
  • On/off: can be disabled without deleting.
The key is shown once

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:

  1. 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.
  2. 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
Last updated: 13.10.2026