Notification channels
Generated from core/api/router.go. Edit the router, not this file.
| Method | Path | Who can call it |
|---|---|---|
GET | /api/v1/notification-channels | Any authenticated user |
POST | /api/v1/notification-channels | Operator, admin |
PUT | /api/v1/notification-channels/:id | Operator, admin |
DELETE | /api/v1/notification-channels/:id | Admin |
POST | /api/v1/notification-channels/:id/test | Operator, 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 | |
|---|---|
name | Required. What names the channel in a delivery failure |
channel_type | slack, webhook, or email |
severity_threshold | INFO, WARNING (default), or CRITICAL — the minimum this channel delivers |
topics | Event topics to accept. Empty means all, which is the useful default. An unknown topic is rejected rather than accepted and ignored |
is_enabled | Defaults to true on create |
Configuration per type:
// 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:
{"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
{
"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-Event | The topic, so a receiver can route without parsing |
X-CertPilot-Timestamp | Unix seconds |
X-CertPilot-Signature | Hex 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.
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 it | Any authenticated user |
| Handler | notifHandler.List |
| Display token | Readable 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
| Status | Body |
|---|---|
200 | An object with data (NotificationChannel[]), supported_types, topics, total |
500 | { "error": … } |
Example request
curl -X GET 'https://certpilot.example.com/api/v1/notification-channels' \
-H 'Authorization: Bearer <token>'POST /api/v1/notification-channels
| Who can call it | Operator, admin |
| Handler | notifHandler.Create |
| Display token | Refused — not a viewer-safe GET |
Request body
| Field | Type | Description | |
|---|---|---|---|
name | string | required | |
channel_type | string | required | |
config | object | Config is the destination's settings — a Slack webhook URL, SMTP credentials. Validated by the notifier, sealed before it is stored, and never returned. | |
is_enabled | boolean | IsEnabled defaults to true on create: a channel someone has just gone to the trouble of configuring is one they want working. | |
severity_threshold | string | ||
topics | string[] |
Responses
| Status | Body |
|---|---|
201 | An object with data (NotificationChannel), next (string) |
400 | { "error": … } |
409 | { "error": … } |
Example request
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 it | Operator, admin |
| Handler | notifHandler.Update |
| Display token | Refused — not a viewer-safe GET |
Parameters
| Name | In | Default | |
|---|---|---|---|
id | path | required |
Request body
| Field | Type | Description | |
|---|---|---|---|
name | string | required | |
channel_type | string | required | |
config | object | Config is the destination's settings — a Slack webhook URL, SMTP credentials. Validated by the notifier, sealed before it is stored, and never returned. | |
is_enabled | boolean | IsEnabled defaults to true on create: a channel someone has just gone to the trouble of configuring is one they want working. | |
severity_threshold | string | ||
topics | string[] |
Responses
| Status | Body |
|---|---|
200 | An object with data (NotificationChannel) |
400 | { "error": … } |
404 | { "error": … } |
500 | { "error": … } |
Example request
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 it | Admin |
| Handler | notifHandler.Delete |
| Display token | Refused — not a viewer-safe GET |
Parameters
| Name | In | Default | |
|---|---|---|---|
id | path | required |
Responses
| Status | Body |
|---|---|
200 | An object with message (string) |
404 | { "error": … } |
500 | { "error": … } |
Example request
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 it | Operator, admin |
| Handler | notifHandler.Test |
| Display token | Refused — 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
| Name | In | Default | |
|---|---|---|---|
id | path | required |
Responses
| Status | Body |
|---|---|
200 | An object with delivered, message |
404 | { "error": … } |
502 | An object with delivered |
Example request
curl -X POST 'https://certpilot.example.com/api/v1/notification-channels/<id>/test' \
-H 'Authorization: Bearer <token>'