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:
- Rules filter by project, branch, tag and pipeline file, with regular expressions in slashes such as
/.*/, and by result:passed,failed,stoppedorcanceled. - Headers are
Content-type: application/json,User-Agent: Semaphore-Webhook,X-Semaphore-Webhook-Idand, with a secret,X-Semaphore-Signature-256. X-Semaphore-Webhook-Idis 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:
{
"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
- Create an organisation secret holding the signing key:
sem create secret semaphore-webhook-secret -e WEBHOOK_. The value ofSECRET=<long random value> WEBHOOK_SECRET, not the secret's name, is the key. Store the same value asSEMAPHORE_WEBHOOK_SECRETin your app. - Create the notification, in the UI under Notifications > New Notification or with the CLI:
sem create notification pipeline-results \
--projects "my-project" \
--branches "main" \
--webhook-endpoint "https://example.com/webhooks/semaphore" \
--webhook-secret "semaphore-webhook-secret"
- Run
sem edit notification pipeline-resultsto setresults, and raisetimeoutandretriesif your endpoint needs them:
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:
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
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.
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 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
timeoutwithsem 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.resultandpipeline.yaml_file_namein 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 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.
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 answers Semaphore in time and retries delivery to your services.
Get started with Hookdeck for free and handle Semaphore CI webhooks reliably in minutes.