Skip to content

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.

  • 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.

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:

Terminal window
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 typeFires when
step_up_issuedA hold is created and waits for a decision. This is the event to page on.
step_up_decidedAn approver settles the hold - the payload’s decision field says whether it was approved or rejected.
step_up_consumedThe 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.

Each delivery is an HTTP POST with a JSON body and these headers:

HeaderContent
X-Caracal-EventThe event type, for routing before parsing.
X-Caracal-DeliveryUnique delivery id. Deliveries are at-least-once; use this id to deduplicate.
X-Caracal-SinkThe sink id, so one receiver can serve several sinks.
X-Caracal-TimestampUnix seconds when the delivery was signed. Reject stale timestamps to stop replays.
X-Caracal-Signaturev1= 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 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.

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.

Create alerts for abandoned deliveries and test the operator decision path in Human Approval.