# Guide to Semaphore CI Webhooks: Features and Best Practices

Semaphore is a hosted CI/CD service that runs pipelines from a `.semaphore/semaphore.yml` file in your repository. Its notifications post to Slack or to any webhook URL when a pipeline finishes, which is how you'd mark a release as tested, start a deploy elsewhere, or log build results to your own dashboard.

This guide covers Semaphore's notification rules, the pipeline payload, opting in to `X-Semaphore-Signature-256`, and the default delivery settings: a 500 ms timeout with no retries.

## What are Semaphore CI webhooks?

In Semaphore, a webhook is a notification. Each notification has one or more rules, and each rule filters on projects, branches, tags, pipeline files and results. When a pipeline finishes and matches a rule, Semaphore sends the whole pipeline record to the rule's endpoint. There's only one kind of delivery, so there's no event name to route on.

Notifications are managed in the UI or with the `sem` CLI, and the timeout, retry and secret settings live in the notification's YAML.

## Semaphore CI webhook features

| Feature | Details |
| --- | --- |
| Signature | hex HMAC-SHA256 |
| Signature header | `x-semaphore-signature-256` |
| Body | JSON |

From Semaphore's [notifications documentation](https://docs.semaphoreci.com/using-semaphore/notifications):

* Rules filter by project, branch, tag and pipeline file, with regular expressions in slashes such as `/.*/`, and by result: `passed`, `failed`, `stopped` or `canceled`.
* Headers are `Content-type: application/json`, `User-Agent: Semaphore-Webhook`, `X-Semaphore-Webhook-Id` and, with a secret, `X-Semaphore-Signature-256`.
* `X-Semaphore-Webhook-Id` is a UUID per delivery, and Semaphore reuses it on retries.
* The response timeout defaults to 500 ms. Retries default to 0 and go up to 5, and only happen after a timeout, with exponential backoff and longer timeouts on each attempt.
* Semaphore's open-source sender treats any HTTP response as the end of delivery, whatever its status.

## Events and payloads

Every delivery is one finished pipeline. Branch on its outcome:

| `pipeline.result` | Triggered When | Common Use Cases |
| --- | --- | --- |
| `passed` | A pipeline finishes successfully | Mark a commit green, trigger a deploy |
| `failed` | A block or job in the pipeline failed | Alert the team, open an issue |
| `stopped` | The pipeline was stopped (`pipeline.result_reason`, for example `user`) | Record the stop |
| `canceled` | The pipeline was cancelled | Record the cancellation |

Which results you receive depends on the `results` filter on the rule. An abridged version of the payload in Semaphore's docs:

```json
{
  "version": "1.0.0",
  "organization": { "name": "semaphore", "id": "36360e31-fee6-42b2-9f6c-999d4c06ce81" },
  "project": { "name": "notifications", "id": "91e34570-bebe-42b6-b47a-ca710b2b8927" },
  "repository": { "url": "https://github.com/semaphoreci/myproject", "slug": "semaphoreci/notifications" },
  "revision": {
    "tag": null,
    "reference_type": "branch",
    "reference": "refs/heads/rw/webhook_impl",
    "pull_request": null,
    "commit_sha": "2d9f5fcec1ca7c68fa7bd44dd58ec4ff65814563",
    "branch": { "name": "rw/webhook_impl" }
  },
  "workflow": { "id": "acabe58e-4bcc-4d39-be06-e98d71917703", "created_at": "2019-12-10T13:09:54Z" },
  "pipeline": {
    "yaml_file_name": "semaphore.yml",
    "state": "done",
    "result": "stopped",
    "result_reason": "user",
    "id": "fa02c7bd-7a8b-42e0-8d6e-aa0d8a194e19",
    "done_at": "2019-12-10T13:10:28Z"
  },
  "blocks": [
    {
      "name": "List & Test & Build",
      "result": "stopped",
      "jobs": [
        { "name": "Test", "status": "finished", "result": "stopped", "index": 1 },
        { "name": "Lint", "status": "finished", "result": "passed", "index": 0 }
      ]
    }
  ]
}

```

`revision.tag` and `revision.pull_request` are `null` for branch pushes. Every block and job carries its own `result`, so a failed pipeline tells you which job failed.

## Setting up Semaphore CI webhooks

1. Create an organisation secret holding the signing key: `sem create secret semaphore-webhook-secret -e WEBHOOK_SECRET=<long random value>`. The value of `WEBHOOK_SECRET`, not the secret's name, is the key. Store the same value as `SEMAPHORE_WEBHOOK_SECRET` in your app.
2. Create the notification, in the UI under **Notifications > New Notification** or with the CLI:

```bash
sem create notification pipeline-results \
  --projects "my-project" \
  --branches "main" \
  --webhook-endpoint "https://example.com/webhooks/semaphore" \
  --webhook-secret "semaphore-webhook-secret"

```

1. Run `sem edit notification pipeline-results` to set `results`, and raise `timeout` and `retries` if your endpoint needs them:

```yaml
notify:
  webhook:
    endpoint: https://example.com/webhooks/semaphore
    timeout: 1000   # ms, default 500
    retries: 3      # on timeouts only, default 0, max 5
    secret: semaphore-webhook-secret

```

To receive through Hookdeck, set the Hookdeck source URL as `--webhook-endpoint` and give a Semaphore CI source the same value. Set a webhook secret on the Semaphore notification and paste its WEBHOOK_SECRET value. Without one, deliveries are unsigned and fail verification. If you leave a notification unsigned, leave verification off on the source. To test locally:

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

```

## Securing Semaphore CI webhooks

With a secret, `X-Semaphore-Signature-256` holds the HMAC-SHA256 of the raw body, keyed with the `WEBHOOK_SECRET` value. The docs name only the algorithm; Semaphore's open-source signer shows the encoding is lowercase hex. Nothing precedes the digest, and nothing time-based is signed.

### Node.js verification

```javascript
const crypto = require('node:crypto');

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-semaphore-signature-256'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature];
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

### HTTP method

Semaphore's docs don't name the HTTP method. Its open-source sender uses the notification's `action` setting and falls back to `POST` when that's empty. Hookdeck's Semaphore CI source pre-selects `POST` without managing the method, so allow any other method you see on the source. The receiver below accepts any method and treats a missing body as empty bytes.

```javascript
const express = require('express');

const app = express();

app.all('/webhooks/semaphore', express.raw({ type: '*/*' }), async (req, res) => {
  const secret = process.env.SEMAPHORE_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500);
  const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
  if (!verifySignature(body, req.headers, secret)) return res.sendStatus(401);

  const webhookId = req.get('x-semaphore-webhook-id');
  if (!body.length) return res.sendStatus(200);

  let parsed;
  try {
    parsed = JSON.parse(body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return res.sendStatus(400);
  const { project, revision, pipeline } = parsed;
  const blocks = Array.isArray(parsed.blocks) ? parsed.blocks : [];
  const failedJobs = blocks
    .flatMap((b) => (Array.isArray(b?.jobs) ? b.jobs : []))
    .filter((j) => j?.result === 'failed');

  try {
    if (await seen(webhookId)) return res.sendStatus(200);
    await queue.add('semaphore-pipeline', {
      webhookId,
      project: project?.name,
      commit: revision?.commit_sha,
      result: pipeline?.result,
      failedJobs: failedJobs.map((j) => j.name),
    });
    await markSeen(webhookId);
  } catch {
    return res.sendStatus(503);
  }

  res.sendStatus(200);
});

```

The handler only queues the result. Alerts, deploys and anything else slow run in a worker, because the default timeout is 500 ms.

Hookdeck's Semaphore CI source checks the same header. It answers `200` immediately, which keeps you inside Semaphore's timeout, and records any mismatch as failed verification. Semaphore signs no timestamp, and Hookdeck applies no time window.

> **Make Semaphore CI webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) responds to Semaphore well within 500 ms and adds the retries Semaphore doesn't make, so a pipeline result isn't lost when your service is briefly down.

## Semaphore CI webhook limitations and pain points

### A 500 ms default timeout

**The Problem:** Endpoints that do any real work miss the deadline.

**Why It Happens:** Each notification's response timeout defaults to 500 ms, and it's only changed by editing the notification YAML.

**Workarounds:**

* Raise `timeout` with `sem edit notification`.
* Reply before doing anything slow.

**How Hookdeck Can Help:** Hookdeck answers Semaphore immediately and gives your service a longer timeout.

### No retries unless you ask, and never on errors

**The Problem:** A `500` or a refused connection loses the notification.

**Why It Happens:** `retries` defaults to 0. Even when set, Semaphore only retries timeouts, and its sender treats any response, whatever the status, as delivered.

**Workarounds:**

* Set `retries` (up to 5) to cover timeouts.
* After an outage, check pipeline results in Semaphore for the gap.

**How Hookdeck Can Help:** Hookdeck accepts the notification and retries your service on errors as well as timeouts, with manual replay for anything that still failed.

### Signing is opt-in

**The Problem:** Notifications without a secret can't be authenticated.

**Why It Happens:** `X-Semaphore-Signature-256` is only sent when the notification names a secret.

**Workarounds:**

* Always create the notification with `--webhook-secret`.
* Reject requests without the header in production.

**How Hookdeck Can Help:** With verification on, Hookdeck flags unsigned or mis-signed requests in its log.

### No event name

**The Problem:** You can't route by event type, because there's only one.

**Why It Happens:** Semaphore sends the pipeline record. The outcome is in `pipeline.result`, and the project, branch and pipeline file are in other fields.

**Workarounds:**

* Use rule filters so each endpoint receives only the results it cares about.
* Branch on `pipeline.result` and `pipeline.yaml_file_name` in your handler.

**How Hookdeck Can Help:** Hookdeck filters can match on `pipeline.result` or `project.name` and route each to a different destination.

## Best practices

### Dedupe on X-Semaphore-Webhook-Id

Retries resend the same ID. Store each one you've handled, as Semaphore suggests. Our [idempotency guide](/webhooks/guides/implement-webhook-idempotency) covers storage options.

### Filter in Semaphore first

Rules on project, branch and result cut traffic at the source. A deploy hook usually only needs `passed` on `main`.

### Acknowledge, then act

With a 500 ms default, there's no room for synchronous work. See [how to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Report the failing job

Use `blocks[].jobs[]` to name the job that failed, rather than just the pipeline, in alerts and tickets.

## Conclusion

Semaphore CI sends one notification per finished pipeline, filtered by rules, with the full pipeline record and an opt-in hex HMAC-SHA256 in `X-Semaphore-Signature-256`. The defaults are strict: 500 ms to respond, no retries, and no retry on errors at all, so either change them or put something fast in front.

[Hookdeck Event Gateway](https://hookdeck.com) answers Semaphore in time and retries delivery to your services.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Semaphore CI webhooks reliably in minutes.