Guide to Cloudinary Webhooks: Features and Best Practices
Cloudinary webhooks, which Cloudinary calls notifications, tell your application when media is uploaded, transformed, deleted, or its metadata changes. If you're building on Cloudinary, notifications are how you react to asset activity without polling.
This guide covers how Cloudinary notifications work, the types you'll handle, how to verify the X-Cld-Signature (it's a plain hash, not an HMAC), the EdDSA v2 variant that can silently break receivers, and the best practices for production.
What are Cloudinary webhooks?
Cloudinary notifications are JSON POSTs delivered to a URL you configure. The signature is not a keyed HMAC: it's a plain hash of a secret-suffixed concatenation, SHA(raw_body + timestamp + api_secret), sent in the X-Cld-Signature header alongside X-Cld-Timestamp. Passing the secret as an HMAC key will never match. The hash algorithm is SHA-1 by default in the SDKs; SHA-256 is an opt-in account setting (and Enterprise accounts can be restricted to SHA-256), so an integration that assumes SHA-256 fails against a default account.
Cloudinary webhook features
| Feature | Details |
|---|---|
| Configuration | Global notification URL and/or per-request notification_url; per-trigger settings |
| Signature header | X-Cld-Signature, plus X-Cld-Timestamp |
| Signature scheme | Plain hash SHA(raw_body + timestamp + api_secret) (not a keyed HMAC) |
| Algorithm | SHA-1 by default; SHA-256 opt-in via signature_algorithm |
| Freshness | A recommendation (2-hour / 7200s window), not server-enforced |
| Retries | 3 retries at 3, 6, 9 minutes; expects 200; 20-second timeout; not auto-disabled |
| SDK | npm/pip cloudinary with verifyNotificationSignature / verify_notification_signature |
Common events
Cloudinary uses a notification_type discriminator. The common types:
notification_type | Fires when |
|---|---|
upload / eager | An asset uploads, or an eager transformation completes |
delete / rename / move | An asset is deleted, renamed, or moved |
create_folder / delete_folder / move_or_rename_asset_folder | A folder changes |
resource_tags_changed / resource_context_changed / resource_metadata_changed | Asset tags, context, or metadata change |
resource_display_name_changed / access_control_changed | Display name or access control changes |
related_assets / multi / explode / proof_status_changed | Other asset operations |
The envelope varies by type but generally carries notification_type, notification_context{triggered_at, triggered_by{source, id}}, and signature_key.
Setting up Cloudinary webhooks
Set a global Notification URL in your Cloudinary settings, and/or pass a per-request notification_url on individual API calls. Store your api_secret as CLOUDINARY_API_SECRET, and set signature_algorithm (sha1 or sha256) to match your account. Be aware of the additive flag on triggers (below) and the per-trigger auth_scheme that controls which signature headers are sent.
Securing Cloudinary webhooks
Use the official SDK's verifyNotificationSignature / verify_notification_signature, which computes the plain hash of the raw body plus the X-Cld-Timestamp plus your api_secret and compares it, enforcing a valid_for freshness window (default 7200 seconds). Match the algorithm to your account (SHA-1 by default). Verify against the raw body, don't parse and re-serialize.
const cloudinary = require("cloudinary").v2;
cloudinary.config({
api_secret: process.env.CLOUDINARY_API_SECRET,
signature_algorithm: process.env.CLOUDINARY_SIGNATURE_ALGORITHM || "sha1", // 'sha256' if enabled
});
app.post("/webhooks/cloudinary", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8"); // exact bytes; do not JSON.parse then re-stringify
const valid = cloudinary.utils.verifyNotificationSignature(
rawBody,
Number(req.get("x-cld-timestamp")),
req.get("x-cld-signature")
// valid_for defaults to 7200 seconds
);
if (!valid) return res.sendStatus(401);
res.sendStatus(200); // acknowledge fast (within the 20s timeout)
processQueue.add(JSON.parse(rawBody)); // branch on notification_type, async
});
The same check in Python:
import os
import cloudinary
from cloudinary.utils import verify_notification_signature
cloudinary.config(
api_secret=os.getenv("CLOUDINARY_API_SECRET"),
signature_algorithm=os.getenv("CLOUDINARY_SIGNATURE_ALGORITHM", "sha1"),
)
def verify(raw_body: str, timestamp: str, signature: str) -> bool:
return verify_notification_signature(raw_body, int(timestamp), signature) # valid_for=7200 default
Cloudinary webhook limitations and pain points
It's a plain hash, not an HMAC
The Problem: The signature is SHA(raw_body + timestamp + api_secret), a plain digest with the secret appended, not a keyed HMAC. Using the secret as an HMAC key never matches, even though Cloudinary's docs sometimes call it "HMAC-SHA1".
Why It Happens: Cloudinary appends the secret to the hashed material rather than keying an HMAC.
Workarounds:
- Use the SDK's verify helper, or a plain
SHA(body + timestamp + secret)hash; nevercreateHmac.
How Hookdeck Can Help: Hookdeck verifies Cloudinary's scheme at the edge, so your app doesn't hand-roll a plain-hash construction.
SHA-1 by default, SHA-256 opt-in
The Problem: The default algorithm is SHA-1; SHA-256 is an opt-in account setting. An integration hard-coded to SHA-256 fails against a default account, and vice versa.
Why It Happens: Cloudinary defaults to SHA-1 and lets accounts enable SHA-256.
Workarounds:
- Set
signature_algorithmto match your account, and confirm which one is active.
How Hookdeck Can Help: Hookdeck verifies with the configured algorithm at the edge, so your app isn't coupled to it.
The EdDSA v2 variant can silently break receivers
The Problem: A per-trigger auth_scheme setting controls which signature headers are sent: default sends both headers, legacy_hmac sends the v1 X-Cld-Signature only, and eddsa_v2 sends a new X-Cld-Signature_v2 (Ed25519/EdDSA) and stops sending the legacy X-Cld-Signature. A receiver that only reads X-Cld-Signature gets nothing under eddsa_v2 and fails every delivery.
Why It Happens: eddsa_v2 replaces the legacy header rather than adding to it.
Workarounds:
- Know which
auth_schemeyour triggers use; ifeddsa_v2is in play, verify theX-Cld-Signature_v2EdDSA signature rather than the legacy header.
How Hookdeck Can Help: Hookdeck can verify the scheme in use at the edge, so a change in auth_scheme doesn't silently break your endpoint.
Freshness isn't enforced, and routing can go quiet
The Problem: The 2-hour window is a recommendation the SDK enforces, not a server rule, Cloudinary won't reject stale deliveries for you. Separately, a per-call notification_url with the trigger's additive flag false (the default) suppresses the global triggers for that event, so a global listener can go quiet because of an unrelated per-call parameter.
Why It Happens: Freshness is receiver-side, and non-additive per-call URLs override global triggers.
Workarounds:
- Enforce the
valid_forwindow yourself, and understand theadditiveinteraction when mixing global and per-call URLs.
How Hookdeck Can Help: Hookdeck enforces freshness and gives you delivery visibility, so you can see when and why events route where they do.
Best practices
Verify the plain hash with the SDK
Use verifyNotificationSignature / verify_notification_signature over the raw body, matching your account's algorithm (SHA-1 by default).
Handle the auth_scheme variants
If any trigger uses eddsa_v2, verify X-Cld-Signature_v2 (EdDSA); don't read only the legacy header.
Enforce your own freshness window
The 2-hour window isn't server-enforced, so reject stale timestamps via valid_for.
Acknowledge within the timeout, process asynchronously
Return 200 within the 20-second timeout and defer work to a queue; dedupe since notifications aren't auto-disabled and can repeat. See why to process webhooks asynchronously.
Make Cloudinary webhooks production-ready
Hookdeck verifies X-Cld-Signature, deduplicates, and durably queues every notification
Conclusion
Cloudinary notifications are verified with an X-Cld-Signature that's a plain hash of raw_body + timestamp + api_secret (not a keyed HMAC), SHA-1 by default with SHA-256 opt-in. Use the SDK's verify helper, match the algorithm to your account, watch for the eddsa_v2 auth_scheme that stops sending the legacy header, enforce the freshness window yourself, and mind the additive flag when mixing global and per-call URLs.
Hookdeck verifies the signature, deduplicates, and durably queues every notification at the edge, so your app only ever processes verified, unique events.
Get started with Hookdeck for free and handle Cloudinary webhooks reliably in minutes.