Developers

Webhooks

DM Handled calls your endpoint when something happens.

Set up

  1. Build an endpoint that accepts POST over https and answers with any 2xx status within 8 seconds. Do the slow work after answering.
  2. Register it in Settings → Webhooks, or with POST /api/v1/webhooks. Store the signing secret (whsec_…) shown once.
  3. Verify every call before trusting it (below).

Events

EventWhen
contact.confirmedA customer confirmed their email address (opened the link) or WhatsApp number (sent their code from it) in the chat
consent.grantedA customer gave a consent in the chat
consent.withdrawnA consent was withdrawn — the customer replied STOP on WhatsApp, or your team pressed Withdraw
handover.createdA customer needs a person; carries how to reach them, if they said
conversation.needs_humanA conversation was handed to a person
message.receivedA person sent a message
message.sentThe assistant replied

What you receive

POST /your/endpoint
Content-Type: application/json
X-DMH-Event: consent.granted
X-DMH-Event-Id: 57
X-DMH-Timestamp: 1790600000
X-DMH-Signature: sha256=5f2c…

{
  "id": 57,
  "event": "consent.granted",
  "at": "2026-09-28T10:05:12.345678+00:00",
  "data": {
    "contact": {"ref": "1042", "dmh_id": 311, "name": "Ana García", "email": "ana@example.com",
                "email_confirmed": true, "phone": "+34600111222", "phone_confirmed": false,
                "lang": "es", "created_at": "…", "updated_at": "…"},
    "consent": {"purpose": "reply_whatsapp", "granted": true,
                "text": "Do you agree that Flamingo Real Estate may contact you on WhatsApp at +34600111222 about your enquiry, also after today? …",
                "lang": "en", "given_at": "…", "source": "chat", "withdrawn_at": null,
                "withdrawn_by": null, "origin": "chat", "active": true},
    "conversation": {"id": 812, "channel": "web", "url": "https://app.dmhandled.com/app/c/812/"}
  }
}

contact.ref is your id when the chat knows the customer; otherwise it is null and dmh_id identifies them — send dmh_id with your first PUT /contacts/{ref} to attach your id.

handover.created

{
  "id": 58,
  "event": "handover.created",
  "at": "2026-09-28T10:06:40+00:00",
  "data": {
    "contact": {"ref": null, "dmh_id": 312, "name": "", "email": "ana@example.com",
                "email_confirmed": true, "phone": null, "phone_confirmed": false, "lang": "es", …},
    "reach": {"method": "email", "email": "ana@example.com", "email_verified": true,
              "whatsapp": "", "whatsapp_verified": false, "consent": false},
    "message": "Is Villa Mar still available in May?",
    "reason": "nobody_answered",
    "conversation": {"id": 813, "channel": "web", "url": "https://app.dmhandled.com/app/c/813/"}
  }
}

reach.method is email, whatsapp or empty when the customer did not say. message is what they wrote that led to the hand-over.

Verify the signature

The signature is an HMAC-SHA256, keyed with your secret, over {timestamp}.{raw body}. Compute it on the raw bytes before parsing, compare in constant time, and refuse a timestamp more than 5 minutes old.

Python

import hashlib, hmac, time

def verified(secret: str, headers, raw_body: bytes) -> bool:
    ts = headers["X-DMH-Timestamp"]
    expected = "sha256=" + hmac.new(secret.encode(), f"{ts}.".encode() + raw_body,
                                    hashlib.sha256).hexdigest()
    fresh = abs(time.time() - int(ts)) < 300
    return fresh and hmac.compare_digest(expected, headers["X-DMH-Signature"])

Node.js

const crypto = require("crypto");

function verified(secret, headers, rawBody /* Buffer */) {
  const ts = headers["x-dmh-timestamp"];
  const expected = "sha256=" + crypto.createHmac("sha256", secret)
    .update(Buffer.concat([Buffer.from(ts + "."), rawBody])).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
  const a = Buffer.from(expected), b = Buffer.from(headers["x-dmh-signature"] || "");
  return fresh && a.length === b.length && crypto.timingSafeEqual(a, b);
}

Retries and duplicates

Anything but a 2xx within 8 seconds is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours, with the same id. Skip an id you have already processed. Missed events can be fetched with GET /api/v1/events?after=….