Webhooks
Outbound webhooks
Sankofa emits ~40 event types — flag halted, release promoted, ticket created, survey submitted, etc. — to webhook URLs you configure. HMAC-SHA256 signed, retried with exponential backoff, dead-letter queue for failures.
Sankofa's outbound webhook system delivers product events to URLs you configure — Slack-bound automations, PagerDuty incident creators, custom CRM integrations, audit-log mirrors, anything you can run on a public HTTP endpoint.
The system is org-scoped. A single subscription receives events for every project in the organization, filtered by event-name pattern.
Architecture at a glance
| Property | Value |
|---|---|
| Delivery | POST to your URL with Content-Type: application/json |
| Signing | X-Sankofa-Webhook-Signature: t={timestamp},v1={hex-hmac-sha256} |
| Secret rotation | Per-subscription. Shown once at create; rotateable via PATCH |
| Retry | Exponential backoff, every 60 s up to 24 h |
| Dead-letter | After 10 failed attempts, moved to DLQ for manual review or replay |
| Idempotency | event_id on every payload — your endpoint can dedupe |
Manage subscriptions
All management endpoints live under /api/v1/admin/webhooks and require super-admin auth via the same JWT path used for org-level admin actions. Project-level admins do not have webhook access — to provision webhooks, contact your org admin or Sankofa support.
List subscriptions
{
"subscriptions": [
{
"id": "hook_abc123",
"organization_id": "org_xyz",
"name": "PagerDuty critical alerts",
"url": "https://events.pagerduty.com/v2/enqueue",
"events": "catch.alert.fired, deploy.release.disabled",
"enabled": true,
"created_at": "2026-04-12T10:00:00Z",
"created_by": "user_456"
}
]
}Create subscription
{
"name": "Slack release announcements",
"url": "https://hooks.slack.com/services/T0.../B0.../...",
"events": "deploy.release.promoted_to_100, plan.release.shipped"
}Response (201 Created):
{
"subscription": {
"id": "hook_def456",
"organization_id": "org_xyz",
"name": "Slack release announcements",
"url": "https://hooks.slack.com/services/...",
"events": "deploy.release.promoted_to_100, plan.release.shipped",
"enabled": true,
"created_at": "2026-05-09T14:32:01.482Z",
"created_by": "user_456"
},
"secret": "whsk_eyJhbGciOiJIUzI1NiI..."
}The secret is shown once only in this response. Store it securely — there's no recovery endpoint. To rotate, update the subscription's events (which forces a new secret) or delete and recreate.
Update subscription
Fields are individually optional:
{
"name": "PagerDuty — all alerts",
"events": "catch.*",
"enabled": false
}Delete subscription
Returns 200 with no body. Pending deliveries against the deleted subscription are dropped.
Test fire
Fires a synthetic event through the subscription's URL. Lets you verify connectivity, signing, and HTTPS without waiting for a real event.
Health summary
{
"total_deliveries": 142,
"successful": 138,
"failed": 4,
"success_rate": 97.18,
"last_failure": "2026-05-08T22:14:00Z",
"last_failure_reason": "timeout"
}24-hour health summary. Useful for monitoring + alerting on degraded webhook destinations.
Dead-letter queue
Lists deliveries that exhausted retries. Each entry includes the original event payload and the failure reasons across all attempts.
Manually replay a failed delivery. Resets the retry counter — on success, the delivery is marked complete; on failure, it goes back to the DLQ.
Event pattern syntax
The events field accepts comma-separated patterns:
| Pattern | Matches |
|---|---|
* | Every event Sankofa emits |
catch.* | Every Catch event |
catch.issue.* | Every Catch issue event (created, regressed, resolved, etc.) |
catch.issue.created | Exactly one event |
catch.alert.fired, deploy.release.disabled | Either of two specific events |
Patterns are matched against the event field of the outbound payload. There's no negation syntax (no NOT); subscribe to a narrower pattern instead.
List subscribed event types
Returns the canonical list of event types your subscriptions can match against. Useful for building dropdowns / docs.
Outbound payload
Every webhook delivery carries the same envelope:
POST /your-webhook-url HTTP/1.1
Content-Type: application/json
X-Sankofa-Webhook-Signature: t=1715183942,v1=8c2f7a9b...
User-Agent: Sankofa-Webhooks/1.0
Content-Length: 512
{
"event": "catch.issue.created",
"organization_id": "org_xyz",
"emitted_at": "2026-05-09T14:32:01.482Z",
"data": {
/* event-specific payload */
}
}Signature verification
Every delivery is signed with HMAC-SHA256:
import crypto from "crypto";
function verifySankofaSignature(
rawBody: string,
signatureHeader: string,
secret: string
): boolean {
// signatureHeader format: "t=1715183942,v1=8c2f7a9b..."
const parts = signatureHeader.split(",").reduce<Record<string, string>>((acc, p) => {
const [k, v] = p.split("=");
acc[k.trim()] = v.trim();
return acc;
}, {});
const timestamp = parseInt(parts.t, 10);
const signature = parts.v1;
if (!timestamp || !signature) return false;
// Reject replays older than 5 minutes
const ageSec = Math.abs(Date.now() / 1000 - timestamp);
if (ageSec > 300) return false;
const payload = `t=${timestamp}.${rawBody}`;
const expected = crypto.createHmac("sha256", secret).update(payload).digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(signature, "hex")
);
}The pattern matches Stripe's webhook signing; libraries that verify Stripe webhooks need only one swap (the header name and the secret).
Event catalog
The full list of events your subscriptions can match. Fields under data are documented per-event in the per-product reference pages.
Catch
catch.issue.createdcatch.issue.regressedcatch.issue.resolvedcatch.issue.assignedcatch.issue.ignoredcatch.issue.mergedcatch.issue.splitcatch.alert.firedcatch.release.health_degraded
Deploy
deploy.release.createddeploy.release.uploadeddeploy.release.promoteddeploy.release.promoted_to_100deploy.release.disableddeploy.release.rolled_backdeploy.schedule.advanceddeploy.schedule.halteddeploy.schedule.completed
Switch
switch.flag.createdswitch.flag.updatedswitch.flag.toggledswitch.flag.haltedswitch.flag.unhaltedswitch.flag.archivedswitch.variant.createdswitch.variant.updatedswitch.variant.deletedswitch.schedule.advanced
Plan
plan.ticket.createdplan.ticket.updatedplan.ticket.deletedplan.ticket.transitionedplan.ticket.commentedplan.sprint.createdplan.sprint.closed
Pulse
pulse.survey.submitted
Config
config.config.updated
Analytics
analytics.session.started
Example payload — catch.alert.fired
{
"event": "catch.alert.fired",
"organization_id": "org_xyz",
"emitted_at": "2026-05-09T14:32:01.482Z",
"data": {
"alert_id": "alt_123",
"alert_name": "checkout error rate > 1%",
"project_id": "proj_abc",
"environment": "live",
"issue_id": "iss_456",
"fingerprint": "TypeError:processData",
"first_seen_at": "2026-05-09T14:30:12Z",
"events_in_window": 247,
"users_affected": 89,
"evidence_url": "https://app.sankofa.dev/dashboard/catch/issues/iss_456",
"halt_actions": [
{ "type": "switch", "flag_key": "new_checkout", "halted": true }
]
}
}The halt_actions array is only present if the alert was configured to auto-halt flags. It documents what was done — useful for downstream notification.
Delivery semantics
- At-least-once delivery — your endpoint must be idempotent (use
event_idfrom the body to dedupe). - Out-of-order delivery — events can arrive out of order. Use
emitted_atfor ordering. - Retry policy: every 60 seconds with jitter, up to 10 attempts, then DLQ.
- Timeout: 10 seconds per delivery attempt.
- 2xx success: any 2xx HTTP status is accepted as success.
- 3xx: treated as failure (we don't follow redirects from webhook destinations).
- 4xx / 5xx: failure — retried unless 410 Gone (which moves to DLQ immediately).