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_updateandstack_preview:kind(the operation) andresult(succeededorfailed)deployment,drift_detection,drift_remediation:statusstack: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.
{
"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
- In Pulumi Cloud, open the webhook settings for your organisation or stack and create a webhook.
- Choose the generic JSON destination.
- Enter your endpoint URL and the shared secret.
- 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:
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.
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.
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 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
versionor 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 describes.
Expect manual redeliveries
Redelivered payloads are identical to the originals. Record what you've processed, as the idempotency guide 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 adds the retries Pulumi doesn't have and keeps every infrastructure event on record.
Get started with Hookdeck for free and handle Pulumi Cloud webhooks reliably in minutes.