Skip to content

Notification channels

Generated from core/api/router.go. Edit the router, not this file.

MethodPathWho can call it
GET/api/v1/notification-channelsAny authenticated user
POST/api/v1/notification-channelsOperator, admin
PUT/api/v1/notification-channels/:idOperator, admin
DELETE/api/v1/notification-channels/:idAdmin
POST/api/v1/notification-channels/:id/testOperator, admin

Three channel types have a notifier: slack, webhook, email. Migration 001 also permitted teams and pagerduty; neither is implemented and 004 narrowed the constraint, because a channel of a type nothing can deliver looks configured on the dashboard and silently drops every alert routed to it.

config carries the destination's settings and is write-only. It is validated by the notifier, sealed with the keyring, and never returned — not by the create response and not by the list. Omit it on an update to keep what is stored; send it to replace it wholesale.

Field
nameRequired. What names the channel in a delivery failure
channel_typeslack, webhook, or email
severity_thresholdINFO, WARNING (default), or CRITICAL — the minimum this channel delivers
topicsEvent topics to accept. Empty means all, which is the useful default. An unknown topic is rejected rather than accepted and ignored
is_enabledDefaults to true on create

Configuration per type:

jsonc
// slack — webhook_url must be https; it is a bearer credential
{"webhook_url": "https://hooks.slack.com/services/...", "username": "", "icon_emoji": ""}

// webhook
{"url": "https://receiver.example.com/hook",
 "signing_secret": "at least 16 characters",
 "headers": {"X-Tenant": "acme"},
 "allow_insecure_http": false}

// email
{"host": "smtp.example.com", "port": 587,
 "username": "", "password": "",
 "from": "pki@example.com", "to": ["oncall@example.com"],
 "encryption": "starttls",   // or "tls" (465) or "none"
 "insecure_skip_verify": false}

Testing a channel

POST /notification-channels/:id/test sends a real, clearly labelled test alert. It does not retry and returns the destination's own complaint verbatim:

json
{"delivered": false, "error": "Slack returned 403: invalid_token"}

The status is 502, not 500: CertPilot worked and the destination did not, and that distinction is the entire content of the answer.

Webhook payload and signature

json
{
  "severity": "CRITICAL",
  "topic": "ca.expiry_alert",
  "title": "CA expiring: Corporate Issuing CA",
  "summary": "Corporate Issuing CA expires in 9 days. Every certificate it has issued stops validating when it does.",
  "entity_id": "…",
  "fields": [{"label": "Days remaining", "value": "9 days"}],
  "timestamp": "2026-08-17T07:33:24Z",
  "source": "certpilot"
}
Header
X-CertPilot-EventThe topic, so a receiver can route without parsing
X-CertPilot-TimestampUnix seconds
X-CertPilot-SignatureHex HMAC-SHA256, present only when a signing secret is configured

The signed string is exactly <X-CertPilot-Timestamp> "." <raw request body>. Verify it in constant time and reject anything outside a few minutes' tolerance. The timestamp is inside the signature rather than merely alongside it: signing the body alone yields a signature that stays valid forever, so a captured delivery could be replayed indefinitely and the receiver could not tell.

python
import hashlib, hmac
want = hmac.new(secret, ts.encode() + b"." + body, hashlib.sha256).hexdigest()
ok = hmac.compare_digest(want, signature)

Delivery behaviour

The dispatcher subscribes to the event broker rather than being called inline, so a wedged destination loses its own place in the queue and can never apply backpressure to the CA health sweep. Deliveries retry three times with jittered exponential backoff, then stop — an endpoint that has refused three times inside a minute is down, and retrying past that turns one outage into a queue that outlives it.

Both outcomes are audited as notification.sent and notification.failed, and are queryable through /dashboard/activity?action=notification.failed.

Endpoint detail

GET /api/v1/notification-channels

Who can call itAny authenticated user
HandlernotifHandler.List
Display tokenReadable by an unattended screen

Reading is open to any authenticated user: the list carries names, types, and thresholds, never the sealed credentials. Writing is operator, deletion admin — removing a channel silently stops alerts reaching whoever depended on it.

Responses

StatusBody
200An object with data (NotificationChannel[]), supported_types, topics, total
500{ "error": … }
Example request
bash
curl -X GET 'https://certpilot.example.com/api/v1/notification-channels' \
  -H 'Authorization: Bearer <token>'

POST /api/v1/notification-channels

Who can call itOperator, admin
HandlernotifHandler.Create
Display tokenRefused — not a viewer-safe GET

Request body

FieldTypeDescription
namestringrequired
channel_typestringrequired
configobjectConfig is the destination's settings — a Slack webhook URL, SMTP credentials. Validated by the notifier, sealed before it is stored, and never returned.
is_enabledbooleanIsEnabled defaults to true on create: a channel someone has just gone to the trouble of configuring is one they want working.
severity_thresholdstring
topicsstring[]

Responses

StatusBody
201An object with data (NotificationChannel), next (string)
400{ "error": … }
409{ "error": … }
Example request
bash
curl -X POST 'https://certpilot.example.com/api/v1/notification-channels' \
  -H 'Authorization: Bearer <token>' \
  -H 'Content-Type: application/json' \
  -d '{
  "name": "<name>",
  "channel_type": "<channel_type>",
  "config": {},
  "is_enabled": false,
  "severity_threshold": "<severity_threshold>"
}'

PUT /api/v1/notification-channels/:id

Who can call itOperator, admin
HandlernotifHandler.Update
Display tokenRefused — not a viewer-safe GET

Parameters

NameInDefault
idpathrequired

Request body

FieldTypeDescription
namestringrequired
channel_typestringrequired
configobjectConfig is the destination's settings — a Slack webhook URL, SMTP credentials. Validated by the notifier, sealed before it is stored, and never returned.
is_enabledbooleanIsEnabled defaults to true on create: a channel someone has just gone to the trouble of configuring is one they want working.
severity_thresholdstring
topicsstring[]

Responses

StatusBody
200An object with data (NotificationChannel)
400{ "error": … }
404{ "error": … }
500{ "error": … }
Example request
bash
curl -X PUT 'https://certpilot.example.com/api/v1/notification-channels/<id>' \
  -H 'Authorization: Bearer <token>' \
  -H 'Content-Type: application/json' \
  -d '{
  "name": "<name>",
  "channel_type": "<channel_type>",
  "config": {},
  "is_enabled": false,
  "severity_threshold": "<severity_threshold>"
}'

DELETE /api/v1/notification-channels/:id

Who can call itAdmin
HandlernotifHandler.Delete
Display tokenRefused — not a viewer-safe GET

Parameters

NameInDefault
idpathrequired

Responses

StatusBody
200An object with message (string)
404{ "error": … }
500{ "error": … }
Example request
bash
curl -X DELETE 'https://certpilot.example.com/api/v1/notification-channels/<id>' \
  -H 'Authorization: Bearer <token>'

POST /api/v1/notification-channels/:id/test

Who can call itOperator, admin
HandlernotifHandler.Test
Display tokenRefused — not a viewer-safe GET

Sending a real alert to a real destination is an action, not a read, which is why it is a POST and gated at operator.

Parameters

NameInDefault
idpathrequired

Responses

StatusBody
200An object with delivered, message
404{ "error": … }
502An object with delivered
Example request
bash
curl -X POST 'https://certpilot.example.com/api/v1/notification-channels/<id>/test' \
  -H 'Authorization: Bearer <token>'