# CI/CD Webhooks: HMAC Verification Done Right

> Unsigned webhooks are an open door for attackers. Event types, HMAC signature verification, idempotency and Slack or Discord integration done securely.

David Viejo · Published 2026-03-12 · Updated 2026-10-23 · 11 min read

Canonical: https://temps.sh/blog/how-to-deploy-webhooks-ci-cd-lifecycle

Deployment webhooks are HTTP POST requests that your CI/CD platform sends to a URL you configure whenever a deployment event occurs — deployment created, container ready, deployment succeeded, failed or cancelled. 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 five deployment 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.

---

## What Are Deployment Webhooks and Why Do They Matter?

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. Outbound webhooks are also one of the four ways a platform can be extended; [how to build a plugin system](https://temps.sh/blog/how-to-build-plugin-system-for-your-platform) compares them with in-process, scripting-engine and sidecar plugins.

A typical deployment pipeline has a handful of moments worth reporting:

```
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.

### What Makes Deployment Webhooks Different From Git Webhooks

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.

---

## What Event Types Should Your Deployment Webhooks Cover?

Five event types cover the deployment lifecycle. Here are the events that matter:

### deployment.created

Fires when a new deployment is queued. This is your team's first signal that code is moving toward production.

```json
{
  "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"
}
```

### deployment.succeeded

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.

### deployment.failed

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.

### deployment.ready

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.

### deployment.cancelled

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.

---

## How Do You Build a Webhook Receiver?

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:

```javascript
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
  const a = Buffer.from(signature);
  const b = Buffer.from(expectedFull);
  // timingSafeEqual throws a RangeError when lengths differ
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

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.

### Respond Before Processing

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.

### Use the Raw Body for Signatures

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.

### Timing-Safe Comparison

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.

---

## How Do You Verify Webhook Signatures With HMAC-SHA256?

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:

### Step 1: Share a Secret

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.

### Step 2: Sign the Payload

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
```

### Step 3: Verify on Receipt

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:

```javascript
function verifyWebhookSignature(secret, payload, signature, timestamp) {
  const signedContent = `${timestamp}.${payload}`;

  const computed = crypto
    .createHmac('sha256', secret)
    .update(signedContent)
    .digest('hex');

  const computedFull = `sha256=${computed}`;

  const a = Buffer.from(computedFull, 'utf8');
  const b = Buffer.from(signature, 'utf8');
  // timingSafeEqual throws a RangeError when lengths differ
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}
```

### Step 4: Reject Stale Timestamps

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.

---

## How Do You Handle Retries and Idempotency?

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.

### Exponential Backoff With Jitter

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. Temps itself makes a single attempt and lets you replay a failed delivery manually, so your receiver should still be idempotent.

### Making Your Receiver Idempotent

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:

```javascript
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.

### What Counts as a Successful Delivery?

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.

---

## How Do You Send Deployment Webhooks to Slack and Discord?

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.

### Slack Integration

Format deployment events into Slack's Block Kit structure:

```javascript
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 Integration

Discord uses embeds instead of blocks:

```javascript
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)
  });
}
```

### Routing Events to the Right Channel

Not every event needs the same audience. Build events go to `#dev-deploys`. Failures go to `#incidents`. Your webhook receiver becomes a router:

```javascript
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,
};
```

---

## How Does Temps Handle Deployment Webhooks?

Temps is a self-hosted deployment platform (MIT / Apache 2.0) that replaces seven SaaS tools — Vercel, PostHog, Sentry, Pingdom, Datadog, Resend and E2B — with a single Rust binary, free to self-host, with no per-seat fees and no bandwidth bills. Temps Cloud, a hosted control plane for your own servers, is coming soon from $29/month. The [webhooks docs](https://temps.sh/docs/webhooks) list every event and payload.

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. A deployment that a newer push supersedes fires `deployment.cancelled` — see [how Temps cancels stale deployments](https://temps.sh/blog/how-to-cancel-stale-deployments-automatically) — and the build output for any event is in the [deployment logs](https://temps.sh/blog/how-to-stream-docker-build-logs-to-browser).

### Real Event Types

Temps supports these deployment webhook event types out of the box:

- `deployment.created` — deployment queued
- `deployment.succeeded` — container healthy, traffic routed
- `deployment.failed` — any stage failed with reason
- `deployment.cancelled` — deployment stopped before completion
- `deployment.ready` — fully live, connections drained

Project, domain and email-tracking events (`project.created`, `domain.provisioned`, `email.bounced` and so on) appear in the event picker too, but as of v0.1.0-beta.56 Temps only delivers the five `deployment.*` events above.

### Built-In HMAC-SHA256 Signing

Every webhook Temps sends with a secret configured includes four headers (without a secret, the signature header is left out):

```
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.

### Delivery Logs and Manual Replay

Temps records every webhook delivery with the full request payload, response status code, and delivery latency. A failed request is not retried automatically. When a receiver returns 500, you can inspect the exact payload and manually replay the delivery from the Temps dashboard, API, or CLI without re-registering the endpoint.

### Registering a Webhook

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

```bash
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
  }'
```

---

## Temps vs. GitHub Actions Webhooks vs. Self-Built Webhook Middleware

| Feature             | Temps                                     | GitHub Actions workflow notifications | 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         | Manual replay from dashboard, API, or CLI | 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 coming soon         | See GitHub pricing page               | Infrastructure cost   |

The GitHub column refers to notifying from an Actions workflow; GitHub's repository and organisation webhooks (for git events) do have a Recent Deliveries log with manual redelivery. 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.

---

## FAQ

### What webhook event types does Temps support?

As of v0.1.0-beta.56, Temps delivers five deployment lifecycle events: `deployment.created`, `deployment.succeeded`, `deployment.failed`, `deployment.cancelled` and `deployment.ready`. Project, domain and email-tracking event types appear in the event picker, but they are not delivered yet. You subscribe to specific event types when registering a webhook endpoint.

### How many webhook endpoints can I register per project?

There's currently no enforced limit on the number of webhook endpoints per project, each subscribing to different event types. If you're registering a large number of endpoints, consider routing through a single aggregator receiver that dispatches to multiple destinations — this also centralizes your signature verification and delivery logging.

### What happens if my webhook receiver is down?

Temps makes a single delivery attempt, marks it as failed if your receiver doesn't return a 2xx, and records it in the dashboard. It does not retry automatically. Once your receiver is healthy again, manually replay the delivery from the dashboard, API, or CLI 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.

### Can I use webhooks to trigger automatic rollbacks?

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.

### How do I test webhooks during local development?

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.

### How does Temps sign webhooks?

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.

---

Source: https://temps.sh/blog/how-to-deploy-webhooks-ci-cd-lifecycle · Index of all pages: https://temps.sh/llms.txt
