Approval Notifications
Approval holds are decided fastest when the right person hears about them immediately. A notification sink is a zone-scoped webhook endpoint that Caracal pushes approval lifecycle events to: your relay turns the delivery into a page, a chat message, or a ticket in whatever system your team watches. Sinks are optional - a zone without one loses nothing except the push, since the web console badges pending holds and the Approvals page remains the decision surface.
Prerequisites
Section titled “Prerequisites”- A public HTTPS receiver with raw-body access, durable delivery-ID deduplication, and a secret manager.
- An operational owner for retries, abandoned deliveries, and secret rotation.
- Approval tiers already validated end to end without notifications.
Create a sink
Section titled “Create a sink”Open Settings → Notifications in the web console and add a sink with a name, an HTTPS endpoint, and the event types it should receive. The response reveals the signing secret exactly once - store it in your receiver’s secret manager before closing the dialog, because Caracal keeps only a sealed copy it cannot show again. For automation, the Admin API exposes the same lifecycle:
curl -X POST "$CARACAL_API_URL/v1/zones/$CARACAL_ZONE_ID/notification-sinks" \ -H "Authorization: Bearer $CARACAL_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Pied Piper on-call relay", "url": "https://hooks.hooli.example/caracal-approvals", "event_types": ["step_up_issued", "step_up_decided"] }'Endpoints must be HTTPS; plain HTTP is accepted only for loopback addresses during local development, and URLs may never embed credentials. Delivery resolves and pins DNS for each connection and rejects loopback, metadata, link-local, multicast, mapped, and NAT64-embedded addresses outright; private, unique-local, and CGNAT ranges are rejected too unless the receiver’s host is listed in CARACAL_PRIVATE_EGRESS_HOSTS, the same egress allowlist that governs provider token endpoints - so an on-premises relay stays reachable without ever opening a path to cloud metadata. Authentication is the signature, not the URL. A zone holds at most 20 sinks.
| Event type | Fires when |
|---|---|
step_up_issued | A hold is created and waits for a decision. This is the event to page on. |
step_up_decided | An approver settles the hold - the payload’s decision field says whether it was approved or rejected. |
step_up_consumed | The approved hold releases its mandate to the retrying agent. |
The step_up_ event-type prefix is the audit stream’s stable taxonomy: routing rules and SIEM pipelines keyed on these types stay valid across releases.
Verify every delivery
Section titled “Verify every delivery”Each delivery is an HTTP POST with a JSON body and these headers:
| Header | Content |
|---|---|
X-Caracal-Event | The event type, for routing before parsing. |
X-Caracal-Delivery | Unique delivery id. Deliveries are at-least-once; use this id to deduplicate. |
X-Caracal-Sink | The sink id, so one receiver can serve several sinks. |
X-Caracal-Timestamp | Unix seconds when the delivery was signed. Reject stale timestamps to stop replays. |
X-Caracal-Signature | v1= followed by hex HMAC-SHA256 of timestamp.body under the sink secret. |
Verify the signature over the raw request body before trusting anything in it:
import { createHmac, timingSafeEqual } from 'node:crypto'
function verifySink(secret: string, headers: Record<string, string>, rawBody: string): boolean { const timestamp = headers['x-caracal-timestamp'] if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false const expected = `v1=${createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex')}` const received = headers['x-caracal-signature'] ?? '' return received.length === expected.length && timingSafeEqual(Buffer.from(received), Buffer.from(expected))}The body carries the audit event verbatim - data is the same metadata the zone audit stream records for the hold, so a relay can render a complete approval prompt without calling back. The requesting run rides along as agent_session_id, and any labels its developer annotated at session start ride as agent_labels - the business context, such as a case or settlement reference, that lets an approver correlate the hold to the work behind it:
{ "id": "0195f2aa-7c31-7d4e-a01b-3f6c2d9e8b10", "type": "step_up_issued", "zone_id": "0195f2a9-1b22-7c3d-9e4f-5a6b7c8d9e0f", "decision": "pending", "occurred_at": "2026-01-01T12:00:00.000Z", "data": { "challenge_id": "0195f2aa-6d20-7b3c-8e1a-2f4d6c8b0a9e", "tier": "high", "approver_class": "operator", "privacy_mode": "identified", "binding": "9f3c…", "expires_at": "2026-01-01T12:30:00Z", "application_id": "0195f2a9-4e55-7a1b-bc2d-3e4f5a6b7c8d", "session_id": "0195f2aa-2c11-7e9f-8a0b-1c2d3e4f5a6b", "agent_session_id": "0195f2aa-9f10-7c2d-8b3e-4a5c6d7e8f90", "agent_labels": ["case:CASE-4021", "payout-execution"] }}Respond with any 2xx status within ten seconds. The response body is ignored - do your downstream work asynchronously and acknowledge fast.
Delivery semantics
Section titled “Delivery semantics”Delivery is at-least-once with per-sink ordering by the zone audit sequence. A non-2xx response or a timeout schedules a retry with exponential backoff from 30 seconds to a 15-minute ceiling; after 8 attempts the delivery is abandoned and the failure is visible on the sink’s delivery record in the web console. Pausing a sink stops deliveries without losing anything: the sink’s cursor into the audit stream holds its place, and resuming catches up from where it stopped. A run of consecutive failures flags the sink as failing in the web console but never disables it - fix the receiver and the backlog drains on its own.
Rotating the secret (Console or POST …/notification-sinks/{id}/rotate-secret) invalidates the old value immediately and reveals the replacement once; update the receiver in the same change window. Sink creation, edits, rotations, and deletions are themselves recorded in the zone audit stream, with secret material redacted.
Related pages: Human Approval and Tail and Query the Audit Stream.
Validate the sink
Section titled “Validate the sink”Send a test hold, verify the raw-body signature and timestamp, return a temporary failure, and confirm retry plus delivery-ID deduplication. Rotate the secret and confirm the old signature immediately fails. Expected result: the receiver acknowledges within ten seconds and performs slow paging or ticket creation asynchronously.
Next Step
Section titled “Next Step”Create alerts for abandoned deliveries and test the operator decision path in Human Approval.

