# Guide to Spacelift Webhooks: Features and Best Practices

[Spacelift](https://spacelift.io) orchestrates infrastructure-as-code workflows (Terraform, OpenTofu, Pulumi, CloudFormation and others) across stacks and spaces. Its webhooks report when a run changes state and when Spacelift hits an internal error, such as a policy that tries to trigger a stack that doesn't exist. Teams use them to post run results to chat, update a deployment tracker, or page someone when an apply fails.

This guide covers named webhooks and the notification policies that feed them, the two default payload shapes, verification of the GitHub-style `X-Signature-256` header, and the delivery behaviour to design for: optional retries on `5xx` only, out-of-order updates, and webhooks that switch themselves off after repeated failures.

## What are Spacelift webhooks?

A **named webhook**, created under **Integrations -> Webhooks** on a paid account, is an endpoint plus a space, an optional secret, labels and any custom headers you want added. It receives nothing on its own. A **notification policy** decides which messages go to which webhook, by webhook ID, and can also rewrite the payload completely.

The older **per-stack webhooks** are still supported for backwards compatibility. They fire on every run state change of their stack and need no policy.

Spacelift doesn't name the HTTP method in its docs. Hookdeck's Spacelift source pre-selects POST without managing the method, so allow any other method the sender uses on the source too.

## Spacelift webhook features

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

## Events and payloads

With default payloads there are two kinds of message, and neither carries an event name. You tell them apart by which keys are present.

**Run events** have `account`, `state`, `stateVersion`, `timestamp`, `run` and `stack`. `state` is the run's state when the notification was sent (for example `FINISHED`), `run` and `stack` describe what ran, and `timestamp` is when the state changed. Updates for a run can arrive out of order, and `stateVersion` is how you put them back in sequence.

**Internal error events** have `title`, `body`, `error`, `severity` (`INFO`, `WARNING` or `ERROR`) and `account`. They report problems Spacelift found while acting for you, often caused by a policy.

If a notification policy supplies its own payload, neither shape applies and the body is whatever the policy author wrote. Document custom payloads next to the policy that produces them.

### Published payload example

This is the `internal-error` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/spacelift/latest/internal-error.json), taken from Spacelift's documentation:

```json
{
  "title": "Invalid Stack Slug Triggered",
  "body": "policy tried to trigger Stack 'this-is-not-a-stack' which either doesn't exist or this policy doesn't have access to",
  "error": "policy triggered for Stack that doesn't exist",
  "severity": "ERROR",
  "account": "spacelift-io"
}

```

## Setting up Spacelift webhooks

### Prerequisites

* A paid Spacelift account with permission to manage integrations
* Your application's public endpoint URL (use the Hookdeck CLI locally)

### Create the Webhook and Secret

Set a secret on the Spacelift webhook. Without one, deliveries are unsigned and fail verification.

1. Go to **Integrations -> Webhooks** and create a webhook in the space that owns the stacks you care about.
2. Enter your endpoint URL, e.g. `https://your-app.example.com/webhooks/spacelift`.
3. Enter a **Secret**. The UI marks it optional, but Hookdeck's Spacelift source needs it.
4. Save, and note the webhook ID for the notification policy.
5. Set `WEBHOOK_SECRET` in your app to the same value, used as UTF-8 bytes.
6. Turn on **Retry on failure** if you want up to 3 retries for `5xx` responses.

Plan for rotation now: the secret can't be edited later. Changing it means deleting the webhook, creating a new one and updating the policy with the new ID.

### Route Messages with a Notification Policy

Write a notification policy with rules that target the webhook's ID for the run states and internal errors you want. Without a matching rule a named webhook receives nothing, which is the most common reason a new webhook stays silent. Spacelift's [notification policy docs](https://docs.spacelift.io/concepts/policy/notification-policy) cover the rule syntax and custom payloads.

### HTTP Method

Spacelift's docs don't name the HTTP method. Hookdeck's Spacelift source pre-selects POST without managing the method, so any other method the sender uses must also be allowed on the source. The linked skill examples accept `GET`, `POST`, `PUT`, `PATCH` and `DELETE`.

### Failure Behaviour

After 10 or more consecutive failed deliveries, Spacelift disables the webhook. It stays off until someone re-enables it, and messages routed to it in the meantime aren't delivered. Each webhook's page lists its deliveries and their status codes, which is the first place to look when messages stop.

### Test a delivery

Start a local receiver and forward requests with the [Hookdeck CLI](/docs/cli):

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

```

Point a webhook at the CLI URL, route a test stack's run states to it with a notification policy, and trigger a run. The first delivery shows the method, both signature headers and the `run` and `stack` objects for your setup.

## Securing Spacelift webhooks

Spacelift says it signs payloads with "the exact same mechanism as GitHub". `X-Signature-256` is HMAC-SHA256 of the raw body keyed with the webhook secret, and `X-Signature` is the SHA-1 equivalent. The docs don't print the header value, so the verifier below accepts GitHub's `sha256=<hex>` form and a bare hex digest. Use `X-Signature-256` only: it is the stronger hash and the only one sent in Spacelift's FedRAMP environment. See [validating the payload](https://docs.spacelift.io/integrations/webhooks#validate-payload).

### 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-signature-256'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature.startsWith('sha256=') ? signature.slice(7) : 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);
  });
}

```

The handler accepts any method, since the method is undocumented, and checks the signature before reading the body. It separates run events from internal errors by key, discards run updates older than the latest `stateVersion` it has stored for that run, and returns `503` on failure so that **Retry on failure** can try again.

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

function createSpaceliftApp({ latestStateVersion, saveRunState, recordError, notify }) {
  const app = express();
  app.all('/webhooks/spacelift', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
    if (!req.body.length) return res.sendStatus(200);
    let message;
    try {
      message = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.sendStatus(400);
    }
    if (!message || typeof message !== 'object' || Array.isArray(message)) {
      return res.sendStatus(400);
    }
    if (message.run !== undefined && (!message.run || typeof message.run !== 'object')) {
      return res.sendStatus(400);
    }
    try {
      if (message.run && typeof message.stateVersion === 'number') {
        const runId = message.run.id;
        const known = await latestStateVersion(runId);
        if (known !== null && message.stateVersion <= known) return res.sendStatus(200);
        await saveRunState(runId, message.stateVersion, message.state, message);
        if (message.state === 'FAILED') await notify(message);
      } else if (message.severity) {
        await recordError(message);
        if (message.severity === 'ERROR') await notify(message);
      } else {
        console.log('Custom Spacelift payload', Object.keys(message));
      }
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  });
  return app;
}

```

The `stateVersion` check doubles as deduplication for run events: a retry carries the same version and is skipped. Internal errors have no ID or version, so make `recordError` safe to call twice.

When a Hookdeck Spacelift source sits in front, it checks `X-Signature-256` against the HMAC of the body, with or without the `sha256=` prefix. Spacelift gets a `200` either way, so the webhook never counts towards auto-disable because of a signature problem; the mismatch is recorded as failed verification and not forwarded. If you run a webhook without a secret on purpose, leave verification off on the source.

> **Keep Spacelift notifications flowing.** [Hookdeck Event Gateway](/event-gateway) acknowledges every Spacelift delivery, so your outages don't add up to an auto-disabled webhook, then retries and replays run notifications to your service.

## Spacelift webhook limitations and pain points

### Webhooks disable themselves

**The Problem:** A run of failures switches the webhook off, and everything routed to it afterwards is missed until someone notices.

**Why It Happens:** Spacelift disables a webhook after 10 or more consecutive failed deliveries and requires a manual re-enable.

**Workarounds:**

* Monitor the webhook's delivery list, or alert when no run notification has arrived for longer than usual.
* Turn on **Retry on failure** so brief `5xx` errors don't count as quickly.

**How Hookdeck Can Help:** Hookdeck accepts deliveries even while your service is down, so Spacelift sees successes and the webhook stays enabled. Hookdeck holds the events and retries them to you.

### Limited retries

**The Problem:** Timeouts, `4xx` responses and connection errors aren't retried, and `5xx` gets at most three more attempts.

**Why It Happens:** **Retry on failure** is opt-in and only covers `5xx`. Spacelift doesn't document a timeout or retry spacing.

**Workarounds:**

* Return `5xx` for any temporary problem, never `4xx`.
* Reconcile run state from the Spacelift API after an outage.

**How Hookdeck Can Help:** Hookdeck's retry policy to your service is yours to set, independent of Spacelift's three attempts.

### No event type

**The Problem:** Routing depends on guessing the message kind from its keys.

**Why It Happens:** Default payloads carry no event name or type header, and policy-defined payloads can have any shape.

**Workarounds:**

* Check for `run` and `stateVersion` for run events and `severity` for internal errors.
* Include an explicit `kind` field in any custom payload you write in a notification policy.

**How Hookdeck Can Help:** Hookdeck filters can match on body fields, so run events and internal errors can go to different destinations.

### Secrets can't be rotated in place

**The Problem:** Changing a secret means recreating the webhook and updating every policy rule that targets it.

**Why It Happens:** The secret is fixed when the webhook is created.

**Workarounds:**

* Create the replacement webhook first, add it to the policy alongside the old one, switch the receiver's secret, then remove the old webhook.

**How Hookdeck Can Help:** Point the new webhook at a second Hookdeck source with the new secret, and both can feed the same destination during the changeover.

### Signature format by reference only

**The Problem:** The exact header value isn't printed in Spacelift's docs.

**Why It Happens:** The docs describe the signing as identical to GitHub's and link there.

**Workarounds:**

* Accept both `sha256=<hex>` and bare hex, as the verifier above does, and confirm the format from your first delivery.

**How Hookdeck Can Help:** Hookdeck's Spacelift source accepts both forms.

## Best practices

### Order by stateVersion

Store the highest `stateVersion` per run and ignore anything lower. Don't use `timestamp` for ordering: it records the state change, not the delivery, and isn't signed separately.

### Keep notification policies in version control

The policy decides what each webhook receives and what the body looks like. Review policy changes like code, and keep a sample of each custom payload with the policy.

### Ignore X-Signature

The SHA-1 header is weaker and missing in FedRAMP. Verify `X-Signature-256` everywhere so the same code works in every Spacelift environment.

### Watch for silence

A webhook that stops sending may have been auto-disabled or have lost its policy rule. Alert on gaps in run notifications rather than waiting for someone to notice.

## Conclusion

Spacelift's webhooks are flexible because notification policies choose and shape every message, but the defaults leave work to the receiver: no event type, out-of-order updates, retries only on `5xx`, a secret you can't change, and a webhook that turns itself off after ten failures.

[Hookdeck Event Gateway](https://hookdeck.com) keeps the webhook healthy by acknowledging each delivery, then handles retries and replay to your service.

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