Agent skill

Pulumi Webhooks Skill

Receive and verify Pulumi Cloud webhooks. Use when setting up Pulumi webhook handlers, debugging Pulumi-Webhook-Signature verification, or handling stack_update, deployment, drift_detection and policy_violation events.

Install this skill

npx skills add hookdeck/webhook-skills --skill 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)

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)

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/, examples/nextjs/, 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-KindBody says what happened inNotes
stackaction (for example created)Stack created or deleted
stack_updatekind (update, refresh, ...) and result (succeeded, failed)Has updateUrl, resourceChanges, isPreview
stack_previewkind and resultSame shape as stack_update with isPreview: true
deploymentstatus (for example running)Has deploymentUrl, version, operation
drift_detectionstatus, driftDetectedHas driftRunId, resourceChanges, referenceUrl
drift_remediationstatusHas resourceChanges, referenceUrl
policy_violationThe 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.

Important Headers

HeaderDescription
Pulumi-Webhook-SignatureHex HMAC-SHA256 of the raw body. Only set if the webhook has a shared secret
Pulumi-Webhook-KindThe event kind (stack, stack_update, ...)

Environment Variables

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

Local Development

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

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 13, 2026

View on GitHub →