Agent skill

Semaphore CI Webhooks Skill

Receive and verify Semaphore CI webhooks (semaphore.io notifications). Use when setting up a Semaphore CI webhook endpoint, verifying the X-Semaphore-Signature-256 HMAC-SHA256 header, debugging 401 signature failures, or handling pipeline-finished notifications by pipeline.result (passed, failed, stopped, canceled).

Install this skill

npx skills add hookdeck/webhook-skills --skill semaphore-webhooks


Semaphore CI (semaphore.io) posts a JSON webhook to your URL when a pipeline matching a notification rule finishes. Notifications are created in the UI or with sem create notification --webhook-endpoint ..., with rules that filter on projects, branches, tags, pipelines and results. When you name an organization secret on the notification, Semaphore signs each delivery with X-Semaphore-Signature-256: the lowercase hex HMAC-SHA256 of the raw request body, keyed with that secret's WEBHOOK_SECRET value. The header value is the bare digest, with no prefix.

Hookdeck setup note: "Set a webhook secret on the Semaphore notification and paste its WEBHOOK_SECRET value. Without one, deliveries are unsigned and fail verification."

When to Use This Skill

  • How do I receive Semaphore CI webhooks?
  • How do I verify the X-Semaphore-Signature-256 header?
  • Why does my Semaphore webhook endpoint return 401?
  • How do I react to a failed or passed Semaphore pipeline?
  • Why do I get no signature header from Semaphore?

Verification (core)

const crypto = require('crypto');

function verifySemaphoreSignature(rawBody, signature, secret) {
  // rawBody: Buffer of the exact bytes received (never re-serialized JSON)
  if (!secret || typeof signature !== 'string' || !/^[0-9a-fA-F]{64}$/.test(signature)) return false;
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature.toLowerCase()));
}
// verifySemaphoreSignature(rawBody, req.headers['x-semaphore-signature-256'], process.env.SEMAPHORE_WEBHOOK_SECRET)
  • Header: X-Semaphore-Signature-256, the whole value is one lowercase hex digest (no sha256= prefix, no list). Hex is compared case-insensitively, so require exactly 64 hex characters first.
  • Signed content is the raw body bytes only. No timestamp, id or header is signed. Keep the body as bytes (express.raw({ type: '*/*' }), await request.arrayBuffer(), await request.body()) and parse it only after the check.
  • The HMAC key is the secret's UTF-8 bytes as-is (not hex- or base64-decoded). Python: hmac.compare_digest on bytes.
  • Missing, empty or malformed header, or a mismatch: 401. Secret not configured: 500 (fail closed).
  • There is no replay window to check: no timestamp is signed, so don't invent one from unsigned fields. Dedupe on the X-Semaphore-Webhook-Id header instead.
  • Signing is opt-in. Without a secret on the notification the header is not sent and every delivery fails verification.

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

HTTP Methods

Semaphore's docs don't name the HTTP method of the delivery. The examples therefore accept GET, POST, PUT, PATCH and DELETE on the webhook route, verify the signature first for every method, treat a missing body as zero bytes, and acknowledge a verified empty body with 200. Hookdeck's Semaphore CI source pre-selects POST without managing the method, so if Semaphore uses any other method, also allow that method on the Hookdeck source.

Events

There is no event-name field or header: every delivery is a finished pipeline with the keys version, organization, project, repository, revision, workflow, pipeline and blocks. Dispatch on pipeline.result:

pipeline.resultMeaning
passedThe pipeline finished successfully
failedA block or job failed (inspect blocks[].jobs[].result)
stoppedThe pipeline was stopped (see pipeline.result_reason)
canceledThe pipeline was canceled

See references/overview.md for the payload.

Delivery and Retries

The response timeout is set per notification (timeout in ms, default 500). Retries happen only on timeouts, and only if retries is set (default 0, max 5), with exponential backoff. Any response, of any status, ends delivery, so even a 401 is not retried. Acknowledge fast and process asynchronously.

Environment Variables

SEMAPHORE_WEBHOOK_SECRET=your_webhook_secret_value   # WEBHOOK_SECRET value of the org secret named on the notification

Local Development

Semaphore can't reach localhost. Tunnel with the Hookdeck CLI (no account required):

npx hookdeck-cli listen 3000 semaphore --path /webhooks/semaphore

Resources


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 13, 2026

View on GitHub →