# 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.

```javascript
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:

```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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Cloudinary webhooks reliably in minutes.