Gareth Wilson Gareth Wilson

Guide to Cloudinary Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationGlobal notification URL and/or per-request notification_url; per-trigger settings
Signature headerX-Cld-Signature, plus X-Cld-Timestamp
Signature schemePlain hash SHA(raw_body + timestamp + api_secret) (not a keyed HMAC)
AlgorithmSHA-1 by default; SHA-256 opt-in via signature_algorithm
FreshnessA recommendation (2-hour / 7200s window), not server-enforced
Retries3 retries at 3, 6, 9 minutes; expects 200; 20-second timeout; not auto-disabled
SDKnpm/pip cloudinary with verifyNotificationSignature / verify_notification_signature

Common events

Cloudinary uses a notification_type discriminator. The common types:

notification_typeFires when
upload / eagerAn asset uploads, or an eager transformation completes
delete / rename / moveAn asset is deleted, renamed, or moved
create_folder / delete_folder / move_or_rename_asset_folderA folder changes
resource_tags_changed / resource_context_changed / resource_metadata_changedAsset tags, context, or metadata change
resource_display_name_changed / access_control_changedDisplay name or access control changes
related_assets / multi / explode / proof_status_changedOther 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; never createHmac.

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_algorithm to 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_scheme your triggers use; if eddsa_v2 is in play, verify the X-Cld-Signature_v2 EdDSA 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_for window yourself, and understand the additive interaction 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.