Gareth Wilson Gareth Wilson

Guide to DatoCMS Webhooks: Features and Best Practices

Published


DatoCMS webhooks tell your other systems when content changes: a record is published, an upload is deleted, a model changes or a build finishes deploying. If you run a site or app on DatoCMS, the headless CMS, webhooks are how you trigger rebuilds, purge caches, update a search index or sync content into another system as soon as an editor hits publish.

This guide covers how DatoCMS webhooks and their triggers are configured, the payload and record lifecycle events, how to protect them with HTTP Basic credentials, DatoCMS's timeouts and retries, and the best practices for production.

What are DatoCMS webhooks?

A DatoCMS webhook is a destination URL plus one or more triggers. Each trigger names an entity, such as records, uploads or build triggers, and the events you care about, optionally narrowed to particular models, records or environments. When a trigger matches, DatoCMS sends an HTTP POST to the URL with a JSON body describing the event.

You can replace that body with your own template. With Send a custom payload? turned on, DatoCMS renders a Mustache template using the original payload as its data, and the result doesn't have to be JSON.

DatoCMS doesn't sign its webhooks. A webhook can carry HTTP Basic credentials and custom headers, which DatoCMS sends with each call, and those are how your receiver tells a real call from a forged one.

DatoCMS webhook features

FeatureDetails
ConfigurationProject Settings > Webhooks, or the Content Management API webhook resource
TriggersAny number per webhook, by entity and event, optionally filtered by model, record or environment
Method and formatPOST. JSON by default, or a Mustache custom payload in any format
Event identifierentity_type plus event_type in the default body
AuthenticationHTTP Basic (http_basic_user, http_basic_password) and custom headers. No signature
Success responseAny 2xx
Timeouts2 seconds to connect, 8 seconds in total
RetriesOff by default. Automatic Retry resends up to 7 times over about three days
Manual resendResend now in the Webhooks activity log
Idempotency keywebhook_call_id
Source IPsNot published

Common events

Entity (entity_type)Events (event_type)Notes
Record (item)create, update, delete, publish, unpublishCan be limited to specific records or models
Model (item_type)create, update, deleteChanges to a model's fields also fire
Upload (upload)create, update, delete
Build trigger (build_trigger)deploy_started, deploy_succeeded, deploy_failed
Environment (environment)deploy_started, deploy_succeeded, deploy_failed
Maintenance Mode (maintenance_mode)changeAn admin turns maintenance mode on or off
SSO User (sso_user)createAn SSO user is added as a collaborator
CDA Cache Tags (cda_cache_tags)invalidateCache tags for the Content Delivery API need invalidating

The default body carries the same top-level fields for every event. Here is the shape of DatoCMS's Record > Update example, trimmed:

{
  "site_id": "example-site-id",
  "webhook_id": "123",
  "environment": "foo-bar",
  "is_environment_primary": true,
  "webhook_call_id": "456",
  "event_triggered_at": "2024-08-26T14:30:00Z",
  "attempted_auto_retries_count": 3,
  "entity_type": "item",
  "event_type": "update",
  "entity": {
    "id": "39830648",
    "type": "item",
    "attributes": { "name": "Mark Smith" },
    "relationships": {
      "item_type": { "data": { "id": "810928", "type": "item_type" } }
    },
    "meta": { "status": "published", "current_version": "117626080" }
  },
  "previous_entity": {
    "id": "39830648",
    "type": "item",
    "attributes": { "name": "John Smith" },
    "meta": { "status": "published", "current_version": "117626079" }
  },
  "related_entities": [
    { "id": "810928", "type": "item_type", "attributes": { "name": "Author", "api_key": "author" } }
  ]
}

entity is the full Content Management API serialization of the thing that changed. previous_entity appears only on Record > Update and holds the record before the change, so you can diff the two to see which fields moved. related_entities includes the objects the entity points to, such as its model.

Record events depend on whether the model uses the draft/publish system. With draft/publish on, each action sends one event: saving a new record sends create, publishing sends publish, and deleting a published record sends unpublish then delete. With draft/publish off, DatoCMS still sends the implicit publish events, so every action sends two: create and publish for a new record, update and publish for a change, unpublish and delete for a deletion. If you trigger rebuilds on both events, you'll build twice.

Setting up DatoCMS webhooks

  1. Choose a username and a long random password, for example with openssl rand -hex 32.
  2. In DatoCMS, go to Project Settings > Webhooks and create a webhook. Enter your HTTPS URL, the username and password under HTTP basic authentication, and the triggers you need.
  3. Turn on Automatic Retry unless you have a reason not to. It's off by default.
  4. Leave Send a custom payload? off unless you need it. A custom template can drop entity_type and event_type, which your routing may rely on.
  5. In Hookdeck. Create a source with the DatoCMS type, enter the same username and password, and use the source URL as the webhook URL.

DatoCMS's docs don't say whether it sends the Basic credentials with the first request or only after a server answers 401 with a challenge. Hookdeck never sends a challenge, so the credentials have to be on the first request. Trigger a test event and check that it's accepted. If it's marked as failed verification, add the header yourself in the webhook's custom headers, as Authorization with the value Basic followed by the base64 of username:password, so it goes out on every call.

Securing DatoCMS webhooks

Decode the Authorization header, split it on the first colon, and compare the username and password with your stored values in constant time.

const crypto = require('crypto');
const express = require('express');

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on buffers of different lengths
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

function verifyBasicAuth(header, user, pass) {
  if (!user || !pass) return false; // fail closed when unconfigured
  const match = /^basic\s+(\S+)\s*$/i.exec(header || '');
  if (!match) return false;
  const decoded = Buffer.from(match[1], 'base64').toString('utf8');
  const i = decoded.indexOf(':'); // the password may contain ':'
  if (i === -1) return false;
  const userOk = safeEqual(decoded.slice(0, i), user);
  const passOk = safeEqual(decoded.slice(i + 1), pass);
  return userOk && passOk;
}

const app = express();

app.post('/webhooks/datocms', express.raw({ type: '*/*' }), (req, res) => {
  const ok = verifyBasicAuth(
    req.get('authorization'),
    process.env.DATOCMS_WEBHOOK_USERNAME,
    process.env.DATOCMS_WEBHOOK_PASSWORD
  );
  if (!ok) return res.sendStatus(401);

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8')); // default payload is JSON
  } catch {
    return res.sendStatus(400);
  }
  // Skip event.webhook_call_id values you've already handled, then queue
  // event.entity_type / event.event_type for a background job.
  res.sendStatus(200);
});

The same check in Python:

import base64
import binascii
import hmac
import re


def verify_basic_auth(header, user, password) -> bool:
    if not user or not password:
        return False  # fail closed
    match = re.fullmatch(r"(?i)basic\s+(\S+)\s*", header or "")
    if not match:
        return False
    try:
        decoded = base64.b64decode(match.group(1), validate=True)
    except (binascii.Error, ValueError):
        return False
    received_user, sep, received_pass = decoded.partition(b":")
    if not sep:
        return False
    user_ok = hmac.compare_digest(received_user, user.encode("utf-8"))
    pass_ok = hmac.compare_digest(received_pass, password.encode("utf-8"))
    return user_ok and pass_ok

Basic credentials prove the caller knows them, nothing more. They don't show that the body is unchanged, and a captured request can be replayed later and still pass, because DatoCMS sends no signature and no timestamp. Serve the endpoint over HTTPS only so the credentials can't be read in transit, use a long random password, and change it in DatoCMS and in your configuration if it may have leaked.

Make DatoCMS webhooks production-ready. Hookdeck Event Gateway checks the Basic credentials on every call, answers inside DatoCMS's timeout, and queues each event durably with replay.

DatoCMS webhook limitations and pain points

Eight seconds, and no retries by default

The Problem: A rebuild or search reindex that runs inside the handler times out, and the call is marked Failed with nothing sent again.

Why It Happens: DatoCMS allows 2 seconds to connect and 8 seconds in total. Automatic Retry is optional, so on a webhook without it, a single failure is final unless someone clicks Resend now in the activity log.

Workarounds:

  • Turn on Automatic Retry. It resends up to 7 times, after 2 minutes, 6 minutes, 30 minutes, 1 hour, 5 hours, 1 day and 2 days.
  • Do what DatoCMS recommends: validate quickly and synchronously, then hand the rest to a background job.
  • Review the Webhooks activity log, or query webhook calls through the API, for anything Failed.

How Hookdeck Can Help: Hookdeck accepts each call immediately, so DatoCMS's timeout is never the limit, and then delivers it to your service with retries and replay that don't depend on the Automatic Retry setting.

Retries use the latest settings

The Problem: After you change a webhook's URL, credentials or payload template, old failed calls are retried against the new configuration.

Why It Happens: DatoCMS says each retry "will use the most recent webhook settings", and a manual resend does the same.

Workarounds:

  • When rotating credentials, accept both old and new values on your side for a few days, which covers the retry schedule.
  • Avoid changing a custom payload template while calls are still being retried.

How Hookdeck Can Help: Hookdeck keeps the original request it received, so a replay from Hookdeck sends exactly what DatoCMS sent at the time.

Basic credentials are the only proof

The Problem: Anyone who learns the username and password can send convincing fake publish events, and a changed body goes unnoticed.

Why It Happens: DatoCMS doesn't sign webhooks. The credentials are static and the same on every call.

Workarounds:

  • Use HTTPS only and a long random password, and keep the credentials out of logs.
  • Deduplicate on webhook_call_id.
  • For high-impact actions, fetch the record from the Content Management API by entity.id instead of trusting the body.

How Hookdeck Can Help: Hookdeck's DatoCMS source type checks the Basic username and password on every request. Hookdeck answers DatoCMS with a 200 either way, then records a request with missing or wrong credentials as failed verification, so it never reaches your destination.

Two events per action

The Problem: Each save fires your handler twice, and your site rebuilds twice.

Why It Happens: On models without draft/publish, DatoCMS sends both the explicit event and the implicit publish or unpublish event, so you get the same record change under two event_type values.

Workarounds:

  • Subscribe to the events you act on, not all of them. For rebuilds, publish and unpublish are usually enough.
  • Debounce rebuilds, so several events within a few seconds start one build.

How Hookdeck Can Help: Hookdeck filters can pass only the event_type values you want, and deduplication can drop repeats of the same record within a time window before they reach your build system.

Best practices

Route on entity_type and event_type

Dispatch on the pair, not on event_type alone, because create and deploy_succeeded mean different things for different entities. Acknowledge combinations you don't handle.

Deduplicate on webhook_call_id

Automatic retries and manual resends repeat the same call. Store each webhook_call_id before processing. See our guide to webhook idempotency.

Use previous_entity for diffs

On Record > Update, compare entity with previous_entity to find what changed, and skip work when the fields you index haven't moved.

Check the environment

environment and is_environment_primary tell you whether the change happened in your primary environment or a sandbox. Ignore sandbox events in production pipelines unless you mean to act on them.

Acknowledge fast and process asynchronously

Verify, store the event, return 200, and run builds and reindexes in a background job. See why to process webhooks asynchronously.

Conclusion

DatoCMS sends a JSON POST for each trigger you configure, protected by HTTP Basic credentials rather than a signature, with an 8-second limit and retries only if you turn them on. Check the credentials in constant time over HTTPS, confirm they arrive on the first request, route on entity_type and event_type, deduplicate on webhook_call_id, and plan for two events per action on models without draft/publish.

Hookdeck Event Gateway sits in front of your receiver, checks the Basic credentials, answers DatoCMS straight away, and keeps every event until your service has processed it.

Get started with Hookdeck for free and handle DatoCMS 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.