API / Guides

Webhooks

Events, signatures and delivery behaviour.

Webhooks

What a webhook is

A webhook is a URL on your own server that we call when something happens.

Normally your tool asks us for things: you send a request, we send data back. A webhook turns that around. You give us a web address you control, and when a designer changes something in Designer Den, we send a message to that address straight away.

The alternative is polling: asking us every minute whether anything changed. Polling is wasteful, because almost every answer is "no", and it is slow, because you only find out at the next check. With a webhook you find out in seconds and make no wasted requests.

You would use one to keep your own copy of a designer's data current: they edit a card here, and your tool knows about it without anyone pressing refresh.

What you have to build

One page on your server that accepts a POST request. That is the whole requirement. It needs to:

  • Be reachable from the public internet over https.
  • Read the JSON we send it.
  • Check the signature, so you know the message really came from us.
  • Answer quickly, then do whatever work it needs to afterwards.

Because it must be reachable from the internet, an address like http://localhost:3000 will not work while you are developing: we reject private and internal addresses. Use a tunnelling tool that gives your local machine a temporary public URL, or a free request-capture service to watch the deliveries arrive, then register your real URL when you deploy.

The whole flow, once

  1. You tell us your URL and which events you care about. We reply with a secret, a random string, shown once.
  2. A designer changes something.
  3. We put a delivery on a queue, so their action is never slowed down by your server.
  4. We POST the event to your URL, along with a signature made using your secret.
  5. You recompute that signature yourself. If it matches, the message is genuinely from us.
  6. You reply 200 immediately, then do your work.

Step 1: register your URL

You do this once per designer, using their token. Your token needs the webhooks:write scope, which is the permission that lets an app manage webhooks at all.

bash
curl -X POST https://dustinsdesignerden.com/api/v1/webhooks \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
"url": "https://yourtool.example.com/hooks/ddd",
"events": ["components.changed", "project.updated"]
  }'

The reply contains a secret. Save it somewhere your server can read it later, the same way you would store a password. We show it exactly once and store only an encrypted copy, so we cannot show it to you again. If you lose it, delete the webhook and register a new one.

A webhook only ever receives events belonging to the designer whose token created it, and only your app can see or delete it.

Step 2: what arrives at your URL

Every delivery looks like this. The outer fields are always the same; the part that differs per event is inside data.

http
POST /hooks/ddd HTTP/1.1
Content-Type: application/json
User-Agent: DustinsDesignerDen-Webhook/1
X-DDD-Event: components.changed
X-DDD-Delivery: 6f1c1e7e-2a54-4a0d-9a5d-4b6e0b6a1d2f
X-DDD-Timestamp: 1785550000
X-DDD-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

{
  "id": "6f1c1e7e-2a54-4a0d-9a5d-4b6e0b6a1d2f",
  "event": "components.changed",
  "created_at": "2026-07-31T21:02:13+00:00",
  "data": {
    "project_id": 1,
    "type": "card",
    "created": 2,
    "updated": 1,
    "deleted": 0,
    "components": [
      { "id": 14591, "name": "Rusty Dagger", "type": "card" },
      { "id": 14592, "name": "Iron Shield",  "type": "card" }
    ]
  }
}
id A unique id for this one delivery. See "arriving twice" below for why you want it.
event Which thing happened. Branch on this to decide how to read data.
created_at When we sent it.
data The details of that event, described next.

Step 3: the events you can subscribe to

components.changed

Sent when components in a project were created, updated or deleted, from any source: your own writes, another connected app, a CSV or JSON import, or a Game Crafter sync. If a write happened but nothing actually changed, nothing is sent.

One delivery covers one write, not one component, so a single message can describe fifty cards at once. It tells you what changed and how many, but not the full details of each component. When you need those, read the components endpoint for that project.

project_idWhich project changed.
typeThe kind of component that write covered, for example card or token.
createdHow many were newly added.
updatedHow many already existed and were edited.
deletedHow many were removed.
componentsThe ones added or edited, each with its id, name and type. Deleted ones are counted but not listed.

project.updated

Sent when a project's own details are edited through the API, such as its name or description, and at least one field really changed. Component edits do not trigger this; they have their own event above.

json
{
  "project_id": 1,
  "changed": ["genre", "short_description"]
}

changed lists the names of the fields that were written, not their new values. Read the project back if you need those.

Step 4: check the signature

Your URL is a public address. Anyone who learns it can send it a fake message. The signature is how you tell a real delivery from a fake one.

It works because only you and we know the secret. Before sending, we combine the timestamp and the exact body of the message with your secret and run them through a one-way function (HMAC-SHA256), producing a string of hex. You do the same calculation on what you received. If your result matches the X-DDD-Signature header, the message came from someone holding the secret, and the body was not altered on the way. If it does not match, throw the request away.

Two things to be careful of. Sign the raw body exactly as received: many frameworks parse JSON for you, and re-encoding it changes the bytes slightly, which changes the signature and makes every delivery look fake. And check the timestamp: the timestamp is signed along with the body, so an attacker who captured a real delivery cannot replay it later, as long as you reject anything with an old timestamp. Five minutes is a reasonable window.

$timestamp = $request->header('X-DDD-Timestamp');
$signature = $request->header('X-DDD-Signature');

// getContent() is the RAW body, not the parsed array. That matters.
$expected = 'sha256=' . hash_hmac(
    'sha256',
    $timestamp . '.' . $request->getContent(),
    $secret
);

abort_unless(hash_equals($expected, (string) $signature), 400);
abort_if(abs(time() - (int) $timestamp) > 300, 400);   // too old, refuse it

// Genuine. Acknowledge now, do the slow work on a queue.
HandleDesignerDenEvent::dispatch(json_decode($request->getContent(), true));

return response()->noContent();
const crypto = require('crypto');

const timestamp = req.headers['x-ddd-timestamp'];
const signature = req.headers['x-ddd-signature'] || '';

// rawBody must be the untouched request body. In Express, capture it with
// express.json({ verify: (req, res, buf) => { req.rawBody = buf.toString(); } })
const expected = 'sha256=' + crypto
  .createHmac('sha256', secret)
  .update(timestamp + '.' + rawBody)
  .digest('hex');

// timingSafeEqual THROWS when the buffers differ in length, so compare
// lengths first. Without this, a request with a short or missing signature
// header crashes the handler instead of being rejected.
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
  return res.status(400).end();
}
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
  return res.status(400).end();   // too old, refuse it
}

// Genuine. Acknowledge now, do the slow work off the request.
res.status(200).end();
queue.push(JSON.parse(rawBody));
import hashlib, hmac, time

timestamp = request.headers.get('X-DDD-Timestamp', '')
signature = request.headers.get('X-DDD-Signature', '')

# request.data is the RAW body. Do not use request.json here: re-encoding
# it changes the bytes and the signature will never match.
expected = 'sha256=' + hmac.new(
    secret.encode(),
    f'{timestamp}.'.encode() + request.data,
    hashlib.sha256,
).hexdigest()

if not hmac.compare_digest(expected, signature):
    return '', 400
if abs(time.time() - int(timestamp)) > 300:
    return '', 400   # too old, refuse it

# Genuine. Acknowledge now, do the slow work on a worker.
enqueue(request.get_json())
return '', 200

Step 5: answer quickly

Send back any 2xx status as soon as you have checked the signature. Do not download images, rebuild your database or call other services first: put the message on your own queue and reply. You have 10 seconds before we give up and count it as a failure.

When things go wrong

  • You are down, or too slow. A 5xx, a timeout or a connection error is retried up to 4 attempts, waiting 10 seconds, then 1 minute, then 5 minutes. A short outage will not lose the event.
  • You reject it. Any 4xx other than 429 is taken as a deliberate refusal and is never retried, on the assumption you meant it. A delivery you rejected for a bad signature will not come back.
  • You stay broken. After 15 failures in a row we switch the endpoint off rather than keep hammering a dead URL. The listing endpoint reports is_active and consecutive_failures so you can spot this. Register again once it is healthy.
  • It arrives twice. Retries mean the same event can reach you more than once, and messages are not guaranteed to arrive in the order they happened. Record the X-DDD-Delivery id and ignore ones you have already handled, and make sure handling the same message twice does no harm.

Managing them later

GET /api/v1/webhooks lists the ones your app registered, including whether each is still active and when it last delivered. DELETE /api/v1/webhooks/{id} removes one. There is no way to edit a webhook: to change its URL or its events, delete it and register again, which also issues a fresh secret. See the Webhooks reference for the exact request and response shapes.

Something unclear or missing? Tell us and we will fix the docs.
Previous Rate limits & quotas Next Account