# Semaphore CI 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)

```javascript
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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/semaphore-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/semaphore-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/semaphore-webhooks/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.result` | Meaning |
| --- | --- |
| `passed` | The pipeline finished successfully |
| `failed` | A block or job failed (inspect `blocks[].jobs[].result`) |
| `stopped` | The pipeline was stopped (see `pipeline.result_reason`) |
| `canceled` | The pipeline was canceled |

See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/semaphore-webhooks/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

```bash
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):

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

```

## Resources

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/semaphore-webhooks/references/overview.md) - What Semaphore notifications are, payload and results
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/semaphore-webhooks/references/setup.md) - Create the secret and notification, Hookdeck source
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/semaphore-webhooks/references/verification.md) - Signature details, gotchas, debugging 401s
* [examples/](https://github.com/hookdeck/webhook-skills/tree/main/skills/semaphore-webhooks/examples/) - Runnable Express, Next.js and FastAPI handlers with tests