# Pulumi Webhooks

## When to Use This Skill

* How do I receive Pulumi Cloud webhooks?
* How do I verify the `Pulumi-Webhook-Signature` header?
* How do I tell a `stack_update` from a `deployment` or `drift_detection` webhook?
* Why is my Pulumi webhook signature verification failing, or why is there no signature header at all?

## Setup note

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

## Essential Code (USE THIS)

Pulumi Cloud sends `Pulumi-Webhook-Signature: <hex>` only when the webhook has a shared secret. The value is the bare lowercase hex HMAC-SHA256 of the **raw request body**, keyed with the secret's UTF-8 bytes. There is no prefix and nothing else is signed (no timestamp, no id).

### Verification (Node.js)

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

// rawBody: Buffer of the exact request bytes (express.raw({ type: '*/*' }), not express.json)
function verifyPulumi(rawBody, signature, secret) {
  if (!secret || typeof signature !== 'string') return false;
  // 64 hex chars first: Buffer.from(x, 'hex') silently drops invalid characters
  if (!/^[0-9a-f]{64}$/i.test(signature)) return false;
  const expected = Buffer.from(crypto.createHmac('sha256', secret).update(rawBody).digest('hex'));
  return crypto.timingSafeEqual(Buffer.from(signature.toLowerCase()), expected);
}

```

### Verification (Python)

```python
import hashlib, hmac, re

def verify_pulumi(raw_body: bytes, signature, secret: str) -> bool:
    if not secret or not signature or not re.fullmatch(r"[0-9a-fA-F]{64}", signature):
        return False
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest().encode()
    # bytes on both sides: str arguments to compare_digest raise TypeError on non-ASCII
    return hmac.compare_digest(signature.lower().encode(), expected)

```

Pulumi's own docs disagree with themselves: the header table and the Go and TypeScript snippets say hex, but the docs' Python snippet base64-encodes the digest, and the JavaScript snippet doesn't run as printed. Use hex, as above. A missing, empty or malformed header, or a mismatch, is a `401`.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/pulumi-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/pulumi-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/pulumi-webhooks/examples/fastapi/).

### No replay window

No timestamp is signed, so there is nothing to check for freshness, and the examples don't invent one from unsigned headers or body fields. Pulumi doesn't guarantee delivery order and doesn't describe automatic retries (failed deliveries can be redelivered unchanged from the console), so make handlers idempotent.

## Event Types

The body has no event-name field. Dispatch on the `Pulumi-Webhook-Kind` header:

| `Pulumi-Webhook-Kind` | Body says what happened in | Notes |
| --- | --- | --- |
| `stack` | `action` (for example `created`) | Stack created or deleted |
| `stack_update` | `kind` (`update`, `refresh`, ...) and `result` (`succeeded`, `failed`) | Has `updateUrl`, `resourceChanges`, `isPreview` |
| `stack_preview` | `kind` and `result` | Same shape as `stack_update` with `isPreview: true` |
| `deployment` | `status` (for example `running`) | Has `deploymentUrl`, `version`, `operation` |
| `drift_detection` | `status`, `driftDetected` | Has `driftRunId`, `resourceChanges`, `referenceUrl` |
| `drift_remediation` | `status` | Has `resourceChanges`, `referenceUrl` |
| `policy_violation` |  | The examples log it; add a case to handle it |

The 21 subscription filters you tick in Pulumi Cloud (such as `update_succeeded` and `deployment_failed`) are not sent as such: the filter only decides which deliveries you get, and within a kind the body tells you the outcome. See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/pulumi-webhooks/references/overview.md).

## Important Headers

| Header | Description |
| --- | --- |
| `Pulumi-Webhook-Signature` | Hex HMAC-SHA256 of the raw body. Only set if the webhook has a shared secret |
| `Pulumi-Webhook-Kind` | The event kind (`stack`, `stack_update`, ...) |

## Environment Variables

```bash
PULUMI_WEBHOOK_SECRET=your_shared_secret   # the secret you set on the Pulumi webhook

```

## Local Development

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 pulumi --path /webhooks/pulumi

```

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/pulumi-webhooks/references/overview.md) - Pulumi Cloud webhook concepts, kinds, payloads, delivery
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/pulumi-webhooks/references/setup.md) - Creating the webhook and setting the shared secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/pulumi-webhooks/references/verification.md) - Signature details, doc discrepancies, and debugging