# Guide to Pulumi Cloud Webhooks: Features and Best Practices

Pulumi lets teams define cloud infrastructure in general-purpose languages, and Pulumi Cloud is the hosted service that stores stack state, runs Pulumi Deployments, checks for drift and enforces policy packs. Its webhooks report what happened to your stacks: an update that failed, a deployment that finished, drift that was detected or a policy that was violated. Teams use them to alert on-call engineers, annotate pull requests or open tickets automatically.

This guide covers Pulumi Cloud's generic JSON webhooks: the event kinds and filters, the payload fields that say what happened, the optional signature, and the absence of automatic retries.

## What are Pulumi Cloud webhooks?

Webhooks can be attached to an organisation or to a single stack, from **Settings > Webhooks**, the Pulumi Service provider in code, or the REST API. Pulumi can deliver to Slack, Microsoft Teams, Pulumi Deployments or a generic JSON URL; only the last sends to an endpoint you run, and it's the format this guide covers.

You choose which events to receive with filters such as `update_failed` or `drift_detected`, or with groups such as `stacks` and `deployments`. On delivery, the `Pulumi-Webhook-Kind` header gives the broader kind (`stack_update`, `deployment` and so on), and fields in the body give the specifics.

Signing is optional. If you set a shared secret, Pulumi adds `Pulumi-Webhook-Signature`, a hex HMAC-SHA256 of the payload.

## Pulumi Cloud webhook features

| Feature | Details |
| --- | --- |
| Configuration | Settings > Webhooks, Pulumi Service provider, or REST API |
| Scope | Organisation or stack |
| Destinations | Generic JSON (covered here), Slack, Teams, Deployments |
| Signature | hex HMAC-SHA256 |
| Signature header | `pulumi-webhook-signature` |
| Signed content | The raw request payload |
| Key | A shared secret you choose. No header without one |
| Body | JSON |
| Event routing | Header field `Pulumi-Webhook-Kind` |
| Delivery ID | `Pulumi-Webhook-Id` |
| Retries | None automatic; manual redelivery from console or CLI |
| Ordering | Not guaranteed |

## Events and payloads

| Event (`Pulumi-Webhook-Kind`) | Triggered When | Common Use Cases |
| --- | --- | --- |
| `stack` | A stack is created or deleted (`action`) | Inventory sync, audit trail |
| `stack_update` | An update, refresh, destroy or similar run finishes (`kind`, `result`) | Notify on failure, post the `updateUrl` |
| `stack_preview` | A preview finishes (`isPreview: true`) | Pull request feedback |
| `deployment` | A Pulumi Deployments run changes state (`status`) | Track deployment progress |
| `drift_detection` | A drift detection run completes (`status`, `driftDetected`) | Alert when infrastructure has drifted |
| `drift_remediation` | A drift remediation run completes (`status`) | Confirm that drift was corrected |
| `policy_violation` | A policy pack is violated | Compliance alerts |

### Subscription filters vs what is sent

The filters you subscribe to (`update_succeeded`, `deployment_failed`, `drift_detected` and others) aren't sent on the wire. Within a kind, the body says what happened:

* `stack_update` and `stack_preview`: `kind` (the operation) and `result` (`succeeded` or `failed`)
* `deployment`, `drift_detection`, `drift_remediation`: `status`
* `stack`: `action`

Pulumi's filter table spells one filter `refresh failed`, with a space, next to `refresh_succeeded`. That only matters when you configure webhooks through the API or provider; check the accepted value there.

### Event Payload Structure

Every body carries `organization`, and most also include `user`, `projectName`, `stackName` and a link back to Pulumi Cloud (`updateUrl`, `deploymentUrl` or `referenceUrl`). `deployment` adds `version`, `operation` and `status`; `drift_detection` adds `driftDetected`, `driftRunId`, `status` and `resourceChanges`; `drift_remediation` adds `status` and `resourceChanges`; `stack` adds `action`. `policy_violation` has no published example.

### Published payload example

A `deployment` delivery for a running update, from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/pulumi/latest/deployment.json).

```json
{
  "user": {
    "name": "Morty Smith",
    "githubLogin": "morty",
    "avatarUrl": "https://crazy-adventures.net/morty.png"
  },
  "organization": {
    "name": "Crazy Adventures",
    "githubLogin": "crazy-adventures",
    "avatarUrl": "https://crazy-adventures.net/logo.png"
  },
  "projectName": "website",
  "stackName": "website-prod",
  "deploymentUrl": "https://app.pulumi.com/crazy-adventures/website/website-prod/deployments/127",
  "version": 127,
  "operation": "update",
  "status": "running"
}

```

## Setting up Pulumi Cloud webhooks

### Prerequisites

* A Pulumi Cloud organisation, or a stack, you can add webhooks to
* A public `https://` endpoint

### Set a shared secret

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

You choose the secret, for example with `openssl rand -hex 32`. Store it as `WEBHOOK_SECRET` and use it as plain UTF-8 text, without decoding.

### Register Your Endpoint

1. In Pulumi Cloud, open the webhook settings for your organisation or stack and create a webhook.
2. Choose the generic JSON destination.
3. Enter your endpoint URL and the shared secret.
4. Select the filters or groups to receive, for example failed updates, deployments, drift detection and policy violations.

For infrastructure-as-code setups, the Pulumi Service provider can manage the webhook alongside your stacks, with the secret coming from your secrets configuration.

### Test a delivery

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

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

```

Point a stack webhook at the CLI's URL and run `pulumi preview` or `pulumi up` on a test stack. Check `Pulumi-Webhook-Kind` and the `result` in the body.

## Securing Pulumi Cloud webhooks

Compute HMAC-SHA256 of the raw payload with the shared secret, hex-encode it, and compare it with `Pulumi-Webhook-Signature` in constant time. Pulumi's header table and Go and TypeScript snippets use hex. Its Python snippet ends by base64-encoding the digest, and its JavaScript snippet references undefined variables; follow the header table. See the [headers section](https://www.pulumi.com/docs/deployments/concepts/webhooks/#headers).

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

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['pulumi-webhook-signature'];
  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);
  });
}

```

The handler routes on the header and alerts only on failures, using `Pulumi-Webhook-Id` to avoid double alerts.

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

function createPulumiApp({ seenDelivery, alert, record }) {
  const app = express();
  app.post('/webhooks/pulumi', 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);
    const webhookKind = req.headers['pulumi-webhook-kind'];
    let payload;
    try {
      payload = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.sendStatus(400);
    }
    if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
      return res.sendStatus(400);
    }
    if (typeof webhookKind !== 'string') return res.sendStatus(400);
    try {
      if (await seenDelivery(req.headers['pulumi-webhook-id'])) return res.sendStatus(200);
      const stack = `${payload.projectName}/${payload.stackName}`;
      if ((webhookKind === 'stack_update' || webhookKind === 'stack_preview') && payload.result === 'failed') {
        await alert(`${payload.kind} failed on ${stack}`, payload.updateUrl);
      } else if (webhookKind === 'deployment' && payload.status === 'failed') {
        await alert(`Deployment ${payload.version} failed on ${stack}`, payload.deploymentUrl);
      } else if (webhookKind === 'drift_detection' && payload.driftDetected) {
        await alert(`Drift detected on ${stack}`, payload.referenceUrl);
      } else if (webhookKind === 'policy_violation') {
        await alert(`Policy violation on ${stack}`);
      }
      await record(webhookKind, payload);
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  });
  return app;
}

```

Check `status` values such as `failed` against your own deliveries; Pulumi's example shows `running`.

Nothing time-based is signed, so a replayed delivery verifies. Pulumi's console redelivers payloads unchanged, so deduplicate on the delivery ID and, where it matters, on the run's URL or version.

On a Pulumi Cloud source, Hookdeck checks `Pulumi-Webhook-Signature` against the raw payload with your secret. Pulumi receives `200` every time; a request that fails the check is recorded as failed verification and goes no further. There's no timestamp tolerance. If you've chosen not to set a secret, leave verification off on the source.

> **Make Pulumi Cloud webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) gives Pulumi's webhooks the retries they lack, verifies each delivery, and routes failures, drift and policy events to different channels.

## Pulumi Cloud webhook limitations and pain points

### No automatic retries

**The Problem:** A failed update or detected drift goes unnoticed because your endpoint was briefly down.

**Why It Happens:** Pulumi marks a non-`2xx` or unanswered delivery as failed and doesn't retry it. Someone has to redeliver it from the console or CLI.

**Workarounds:**

* Check the webhook's delivery log after incidents and redeliver failures.
* Keep the endpoint simple and highly available.

**How Hookdeck Can Help:** Hookdeck accepts each delivery when Pulumi sends it and retries your service automatically, so a short outage doesn't lose the event.

### Optional signing

**The Problem:** Unsigned webhooks can be forged to trigger automation or false alerts.

**Why It Happens:** The shared secret is optional, and without it there's no signature header.

**Workarounds:**

* Set a secret on every generic JSON webhook, including those managed through the provider.

**How Hookdeck Can Help:** With verification enabled, Hookdeck records unsigned requests as failed and doesn't forward them.

### Contradictory code samples

**The Problem:** Verification fails when copied from Pulumi's Python or JavaScript examples.

**Why It Happens:** The Python snippet base64-encodes the digest, unlike the header table, and the JavaScript snippet uses undefined variables.

**Workarounds:**

* Use hex, as the header table and Go and TypeScript code do.

**How Hookdeck Can Help:** Hookdeck's Pulumi source already uses hex, so the source can serve as a reference while you debug your own verifier.

### No ordering guarantee

**The Problem:** A "succeeded" event is processed before the "started" event for the same deployment.

**Why It Happens:** Pulumi states that webhooks don't guarantee event order.

**Workarounds:**

* Compare `version` or run identifiers before updating state.
* Treat terminal statuses as final.

**How Hookdeck Can Help:** Hookdeck's event history shows the arrival order per stack, which makes ordering issues visible.

## Best practices

### Route by kind, then by result

Use `Pulumi-Webhook-Kind` to pick a handler, then read `result`, `status` or `action` in the body. Don't expect the filter names in the payload.

### Use stack webhooks for noisy stacks

Organisation webhooks receive events for every stack. Put high-traffic or experimental stacks on their own stack webhooks so production alerts stay readable.

### Respond quickly

Pulumi doesn't state a timeout, and a slow response can count as a failure that won't be retried. Store the payload, return `2xx` and act on it in the background, as our [asynchronous processing guide](/webhooks/guides/why-implement-asynchronous-processing-webhooks) describes.

### Expect manual redeliveries

Redelivered payloads are identical to the originals. Record what you've processed, as the [idempotency guide](/webhooks/guides/implement-webhook-idempotency) explains.

## Conclusion

Pulumi Cloud's webhooks give useful visibility into stack updates, deployments, drift and policy. The signature is straightforward once you ignore the base64 in the Python sample, but with no automatic retries and no ordering guarantee, a dependable receiver needs to be highly available, idempotent and careful about state.

[Hookdeck Event Gateway](https://hookdeck.com) adds the retries Pulumi doesn't have and keeps every infrastructure event on record.

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