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

PropertyValue
DeliveryPOST to your URL with Content-Type: application/json
SigningX-Sankofa-Webhook-Signature: t={timestamp},v1={hex-hmac-sha256}
Secret rotationPer-subscription. Shown once at create; rotateable via PATCH
RetryExponential backoff, every 60 s up to 24 h
Dead-letterAfter 10 failed attempts, moved to DLQ for manual review or replay
Idempotencyevent_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

GET/api/v1/admin/webhooks?org_id=:org_id
JSON
{
"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

POST/api/v1/admin/webhooks?org_id=:org_id
JSON
{
"name": "Slack release announcements",
"url": "https://hooks.slack.com/services/T0.../B0.../...",
"events": "deploy.release.promoted_to_100, plan.release.shipped"
}

Response (201 Created):

JSON
{
"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

PATCH/api/v1/admin/webhooks/:id?org_id=:org_id

Fields are individually optional:

JSON
{
"name": "PagerDuty — all alerts",
"events": "catch.*",
"enabled": false
}

Delete subscription

DELETE/api/v1/admin/webhooks/:id?org_id=:org_id

Returns 200 with no body. Pending deliveries against the deleted subscription are dropped.

Test fire

POST/api/v1/admin/webhooks/:id/test?org_id=:org_id

Fires a synthetic event through the subscription's URL. Lets you verify connectivity, signing, and HTTPS without waiting for a real event.

Health summary

GET/api/v1/admin/webhooks/:id/health?org_id=:org_id
JSON
{
"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

GET/api/v1/admin/webhooks/dead-letters?org_id=:org_id

Lists deliveries that exhausted retries. Each entry includes the original event payload and the failure reasons across all attempts.

POST/api/v1/admin/webhooks/dead-letters/:id/replay?org_id=:org_id

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:

PatternMatches
*Every event Sankofa emits
catch.*Every Catch event
catch.issue.*Every Catch issue event (created, regressed, resolved, etc.)
catch.issue.createdExactly one event
catch.alert.fired, deploy.release.disabledEither 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

GET/api/v1/admin/webhooks/events?org_id=:org_id

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:

HTTP
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:

TypeScript
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.created
  • catch.issue.regressed
  • catch.issue.resolved
  • catch.issue.assigned
  • catch.issue.ignored
  • catch.issue.merged
  • catch.issue.split
  • catch.alert.fired
  • catch.release.health_degraded

Deploy

  • deploy.release.created
  • deploy.release.uploaded
  • deploy.release.promoted
  • deploy.release.promoted_to_100
  • deploy.release.disabled
  • deploy.release.rolled_back
  • deploy.schedule.advanced
  • deploy.schedule.halted
  • deploy.schedule.completed

Switch

  • switch.flag.created
  • switch.flag.updated
  • switch.flag.toggled
  • switch.flag.halted
  • switch.flag.unhalted
  • switch.flag.archived
  • switch.variant.created
  • switch.variant.updated
  • switch.variant.deleted
  • switch.schedule.advanced

Plan

  • plan.ticket.created
  • plan.ticket.updated
  • plan.ticket.deleted
  • plan.ticket.transitioned
  • plan.ticket.commented
  • plan.sprint.created
  • plan.sprint.closed

Pulse

  • pulse.survey.submitted

Config

  • config.config.updated

Analytics

  • analytics.session.started

Example payload — catch.alert.fired

JSON
{
"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_id from the body to dedupe).
  • Out-of-order delivery — events can arrive out of order. Use emitted_at for 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).

What's next

Edit this page on GitHub