March 12, 2026 (3mo ago)
Written by Temps Team
Last updated March 12, 2026 (3mo ago)
Deployment webhooks are HTTP POST requests that your CI/CD platform sends to a URL you configure whenever a deployment event occurs — build started, container ready, health check passed, rollback triggered. Without them, your Slack channel stays quiet while production changes, your monitoring dashboard lags, and your team discovers outages from users rather than alerts.
This guide covers the six event types you need, how to build a secure receiver with HMAC-SHA256 verification, how to handle retries and idempotency, and how platforms like Temps fire webhooks natively at every stage of the deployment lifecycle.
TL;DR: Deployment webhooks give every system — Slack, PagerDuty, status pages, custom automations — real-time visibility into what your pipeline is doing. The three things that break webhook receivers in practice: not returning 200 fast enough, verifying signatures against parsed JSON instead of the raw body, and ignoring duplicate delivery IDs. Platforms like Temps handle the sending side with HMAC-SHA256 signatures, per-delivery logs, and manual replay — so you only need to get the receiver right.
A deployment webhook fires when your infrastructure changes, not just when code changes. Git webhooks (GitHub's push and pull_request events) trigger your pipeline. Deployment webhooks report what the pipeline did — which container was started, whether health checks passed, which version was rolled back to.
The difference between polling and webhooks is the difference between refreshing your email every minute and getting a push notification. Polling wastes compute and introduces latency. Webhooks are instantaneous and cheap.
A typical deployment pipeline creates five or six events worth tracking:
Git Push → Build Start → Build Complete → Deploy Start → Health Check → Live
↓ ↓ ↓ ↓ ↓ ↓
webhook webhook webhook webhook webhook webhook
Without webhooks at each stage, the only way to track deployment status is to tail logs. That works when you're the one deploying. It breaks completely when deploys run automatically on merge and stakeholders need real-time status.
Git webhooks fire when code changes. Deployment webhooks fire when infrastructure changes. They carry different information: container IDs, health check results, deployment preview URLs, rollback reasons.
You need both. Git webhooks kick off the pipeline. Deployment webhooks report back from it.
A complete webhook system needs at least five event types to cover the deployment lifecycle. Here are the events that matter:
Fires when a new deployment is queued. This is your team's first signal that code is moving toward production.
{
"id": "evt_a1b2c3d4",
"event": "deployment.created",
"timestamp": "2026-06-06T14:22:01Z",
"deployment_id": "dep_a1b2c3d4",
"app": "api-service",
"environment": "production",
"commit": {
"sha": "f4c8e2a",
"message": "fix: resolve timeout in payment handler",
"author": "dana@example.com",
"branch": "main"
},
"triggered_by": "git_push"
}
Fires when the container passes health checks and traffic is routed to it. This is the real "deploy is done" signal — not when the build finished, but when the new container is actually serving requests.
Fires when any stage fails — build error, health check timeout, container crash. Includes the failure reason so your alerting can be specific rather than sending a generic "deploy failed" message.
Fires when the deployment is fully live and all routing is updated. In zero-downtime scenarios, this fires after the old container finishes draining connections.
Fires when a deployment is cancelled mid-flight — either manually or because a newer commit triggered a replacement deployment.
The most underused event in practice is deployment.succeeded versus the health check signal. Teams set up notifications for failure but not for the moment a new deployment becomes healthy. That gap means nobody gets a "deploy is live" confirmation unless they check the dashboard manually.
A reliable webhook receiver has three jobs: respond fast, verify the signature, handle duplicates. Getting any one of them wrong creates either security holes or operational noise.
Here's a minimal Express.js receiver that gets all three right:
const express = require('express');
const crypto = require('crypto');
const app = express();
// Important: use raw body for signature verification — JSON.parse changes the bytes
app.use('/webhooks', express.raw({ type: 'application/json' }));
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
app.post('/webhooks/deploy', async (req, res) => {
// 1. Verify signature FIRST — before any other work
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
const deliveryId = req.headers['x-webhook-delivery'];
if (!verifySignature(req.body, signature, timestamp)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 2. Respond immediately — sender retries on timeout
res.status(200).json({ received: true });
// 3. Process asynchronously — avoid blocking the response
const event = JSON.parse(req.body);
event.delivery_id = deliveryId;
handleDeployEvent(event).catch(console.error);
});
function verifySignature(payload, signature, timestamp) {
// Reject timestamps older than 5 minutes — prevents replay attacks
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
return false;
}
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${timestamp}.${payload}`)
.digest('hex');
const expectedFull = `sha256=${expected}`;
// Constant-time comparison — prevents timing attacks
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedFull)
);
}
async function handleDeployEvent(event) {
switch (event.event) {
case 'deployment.created':
await notifySlack(`Deploying ${event.app}: ${event.commit.message}`);
break;
case 'deployment.succeeded':
await notifySlack(`${event.app} is live in ${event.environment}`);
break;
case 'deployment.failed':
await notifySlack(`FAILED: ${event.app} — ${event.error}`);
await createIncident(event);
break;
case 'deployment.cancelled':
await notifySlack(`Deployment cancelled: ${event.app}`);
break;
}
}
app.listen(3000);
Three things in that code deserve attention.
Return 200 within a few seconds. If your receiver takes too long, the sender times out and retries — now you're processing the same event twice. Accept the payload, acknowledge receipt, then process asynchronously.
Express's JSON parser modifies the bytes. If you verify the HMAC against the parsed-and-re-stringified body, the signatures won't match. Always capture the raw bytes before parsing. The express.raw({ type: 'application/json' }) middleware handles this.
Using === to compare signature strings leaks information through timing differences. crypto.timingSafeEqual compares in constant time, eliminating the timing side channel. This is a real attack vector, not a theoretical one.
HMAC-SHA256 is the standard method for authenticating webhook payloads. Without signature verification, anyone who discovers your webhook URL can send forged events — triggering false alerts, fake rollbacks, or bogus incident records.
The verification flow works in four steps:
When you register a webhook endpoint, the platform generates a shared secret. Both sides know it. It never appears in the webhook payload itself. Store it as an environment variable — never commit it to source control.
The sender creates an HMAC-SHA256 hash of a message composed of the timestamp and request body, separated by a period: {timestamp}.{body}. The signature is sent in an HTTP header alongside the timestamp:
X-Webhook-Signature: sha256=a1b2c3d4e5f6...
X-Webhook-Timestamp: 1749254521
X-Webhook-Delivery: 42
X-Webhook-Event: deployment.succeeded
Your receiver computes the same HMAC using the shared secret and the raw request body. If the computed signature matches the header, the payload is authentic and hasn't been tampered with:
function verifyWebhookSignature(secret, payload, signature, timestamp) {
const signedContent = `${timestamp}.${payload}`;
const computed = crypto
.createHmac('sha256', secret)
.update(signedContent)
.digest('hex');
const computedFull = `sha256=${computed}`;
return crypto.timingSafeEqual(
Buffer.from(computedFull, 'utf8'),
Buffer.from(signature, 'utf8')
);
}
Reject webhooks with timestamps older than 5 minutes. This prevents replay attacks where an attacker captures a legitimate webhook and resends it later. A 5-minute window accounts for clock drift and network latency while keeping the replay window small.
Networks fail. Receivers crash. Timeouts happen. A robust webhook system retries failed deliveries with exponential backoff. Your receiver will occasionally receive the same event twice — whether due to retries or a brief network partition. It must handle duplicates without creating duplicate Slack messages, duplicate incidents, or duplicate database rows.
Retries should follow an exponential backoff pattern. The first retry fires after a few seconds, subsequent retries after longer intervals. Adding random jitter prevents thundering herd problems when many webhooks fail simultaneously.
A typical retry schedule looks like:
Attempt 1: immediate
Attempt 2: 10 seconds + random(0-5s)
Attempt 3: 30 seconds + random(0-10s)
Attempt 4: 2 minutes + random(0-30s)
Attempt 5: 10 minutes + random(0-60s)
Most platforms retry 3–6 times over a few hours before marking a delivery as failed.
Every webhook payload includes a unique delivery ID — from Temps this comes in the X-Webhook-Delivery header. Store these IDs and check for duplicates before processing:
const processedEvents = new Set(); // Use Redis with TTL in production
async function handleWebhook(event) {
if (processedEvents.has(event.delivery_id)) {
console.log(`Duplicate delivery ${event.delivery_id}, skipping`);
return;
}
processedEvents.add(event.delivery_id);
await routeEvent(event);
}
In production, use Redis with a 48-hour TTL instead of an in-memory Set. In-memory state doesn't survive restarts.
The sender considers a webhook delivered when it receives an HTTP 2xx response. A 4xx, 5xx, timeout, or connection refused all trigger retries. Some status codes carry special meaning: a 410 Gone response tells most webhook senders to permanently deactivate the endpoint. Don't return 410 by accident — a simple 500 is the right error code if your receiver is temporarily unhealthy.
Slack and Discord are the two most common webhook destinations for deployment notifications. Setting up deployment notifications for either takes under five minutes once your receiver is wired up.
Format deployment events into Slack's Block Kit structure:
async function notifySlack(event, webhookUrl) {
const color = event.event === 'deployment.succeeded' ? '#36a64f'
: event.event === 'deployment.failed' ? '#ff0000'
: '#3498db';
const payload = {
attachments: [{
color,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*${event.event.replace('.', ' ').toUpperCase()}*\n` +
`App: \`${event.app}\`\n` +
`Environment: ${event.environment}\n` +
`Commit: ${event.commit?.message || 'N/A'}`
}
},
{
type: 'context',
elements: [{
type: 'mrkdwn',
text: `Triggered by ${event.triggered_by} at ${event.timestamp}`
}]
}
]
}]
};
await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}
Discord uses embeds instead of blocks:
async function notifyDiscord(event, webhookUrl) {
const color = event.event === 'deployment.succeeded' ? 0x36a64f
: event.event === 'deployment.failed' ? 0xff0000
: 0x3498db;
const payload = {
embeds: [{
title: event.event.replace('.', ' ').toUpperCase(),
color,
fields: [
{ name: 'App', value: event.app, inline: true },
{ name: 'Environment', value: event.environment, inline: true },
{ name: 'Commit', value: event.commit?.message || 'N/A' }
],
timestamp: event.timestamp
}]
};
await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}
Not every event needs the same audience. Build events go to #dev-deploys. Failures go to #incidents. Your webhook receiver becomes a router:
const CHANNELS = {
'deployment.created': process.env.SLACK_DEV_CHANNEL,
'deployment.succeeded': process.env.SLACK_DEV_CHANNEL,
'deployment.ready': process.env.SLACK_DEV_CHANNEL,
'deployment.failed': process.env.SLACK_INCIDENTS_CHANNEL,
'deployment.cancelled': process.env.SLACK_DEV_CHANNEL,
};
Temps is a self-hosted deployment platform (Apache 2.0) that replaces Vercel, PostHog, FullStory, Sentry, Pingdom, managed databases, and transactional email in a single Rust binary. Temps Cloud runs on Hetzner at cost plus 30% — roughly $6/month — with no per-seat fees and no bandwidth bills.
Temps fires webhooks at every stage of the deployment lifecycle. Because Temps controls the full pipeline from git push through health check, it has visibility into events that external CI systems can't observe.
Temps supports these deployment webhook event types out of the box:
deployment.created — deployment queueddeployment.succeeded — container healthy, traffic routeddeployment.failed — any stage failed with reasondeployment.cancelled — deployment stopped before completiondeployment.ready — fully live, connections drainedPlus project events (project.created, project.deleted), domain events (domain.created, domain.provisioned), and email tracking events (email.delivered, email.bounced, email.complained).
Every webhook Temps sends includes four headers:
X-Webhook-Signature: sha256=<hmac-sha256-of-timestamp.body>
X-Webhook-Timestamp: <unix-timestamp>
X-Webhook-Delivery: <delivery-id>
X-Webhook-Event: deployment.succeeded
The signing format matches the pattern shown earlier: HMAC-SHA256(secret, "{timestamp}.{body}"). The result is prefixed with sha256= so you can verify the algorithm at a glance.
Secrets are encrypted at rest using Temps's built-in encryption service. Webhook deliveries use a 30-second timeout and disable redirect following to prevent SSRF via redirect chains.
Failed deliveries retry automatically. Every webhook delivery is logged with the full request payload, response status code, and delivery latency. When a receiver returns 500, you can see the exact payload that was sent and replay it from the Temps dashboard without re-registering the endpoint.
# Register a webhook endpoint via the Temps CLI
bunx @temps-sdk/cli webhooks create \
--project-id <your-project-id> \
--url https://your-app.com/webhooks/deploy \
--events deployment.created,deployment.succeeded,deployment.failed \
--secret whsec_your_signing_secret
Or via the REST API:
curl -X POST https://your-temps-instance.com/api/projects/{project_id}/webhooks \
-H "Authorization: Bearer $TEMPS_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/deploy",
"events": ["deployment.created", "deployment.succeeded", "deployment.failed", "deployment.cancelled", "deployment.ready"],
"secret": "whsec_your_signing_secret",
"enabled": true
}'
| Feature | Temps | GitHub Actions + Webhooks | Custom middleware |
|---|---|---|---|
| Event coverage | All deployment lifecycle events | Git events only | Whatever you build |
| HMAC signing | Built-in, encrypted secrets | Configurable | Build it yourself |
| Delivery logs | Per-delivery, with replay | None | Build it yourself |
| Retry logic | Automatic exponential backoff | None | Build it yourself |
| Health check events | Yes (deployment.ready) | No | Possible with polling |
| Timeout | 30 seconds | N/A | Configurable |
| Setup time | ~5 minutes | Hours of glue code | Days |
| Cost | Self-host free, Cloud ~$6/mo | See GitHub pricing page | Infrastructure cost |
GitHub Actions knows when a workflow succeeds but doesn't know whether the deployed container passed its health check. Temps runs the health check and fires the webhook when the container is actually serving traffic — not just when the deploy command exited.
Temps supports five deployment lifecycle events (deployment.created, deployment.succeeded, deployment.failed, deployment.cancelled, deployment.ready), two project events, two domain events, and three email tracking events. You subscribe to specific event types when registering a webhook endpoint.
Temps allows up to 10 webhook endpoints per application, each subscribing to different event types. If you need to fan out to more systems, route through a single aggregator receiver that dispatches to multiple destinations — this also centralizes your signature verification and delivery logging.
Temps retries with exponential backoff. If all retries fail, the delivery is marked as failed in the dashboard. You can manually replay failed deliveries once your receiver is healthy again without re-triggering the original deployment event. Design your receiver to be idempotent — process the same delivery ID only once — so replayed events don't create duplicate alerts.
Yes, but be careful with false positives. Build a receiver that watches for deployment.succeeded events, runs smoke tests against the new deployment URL, and calls the Temps rollback API if the tests fail. A slow response during a cold start could trigger a rollback of a healthy container. Start with alerting and graduate to automated rollbacks once you trust your smoke tests.
Use a tunneling tool like ngrok or Cloudflare Tunnel to expose your local receiver to the public internet. Register the tunnel URL in your Temps webhook configuration, push a commit, and watch the events arrive. For unit testing, capture example payloads from Temps's delivery logs and replay them with curl. Always test signature verification separately against raw body bytes — it's the part most likely to break when you change how you parse the request.
Temps uses HMAC-SHA256 with the message format {unix-timestamp}.{raw-body}. The result appears in the X-Webhook-Signature header as sha256={hex-encoded-hash}. The timestamp is sent separately in X-Webhook-Timestamp. Verify the timestamp is within 5 minutes of your current time before computing the HMAC to prevent replay attacks.