Guides
Webhooks
Endpoints, the payload, the event catalogue, signature verification in Node and Python, and retries.
Webhooks tell your server what happened in DM Hero as it happens: a lead replied, booked a call, opted out. Each event is an HTTPS POST with a flat JSON body, signed so you can prove it came from us.
Endpoints
Create an endpoint in the app (Settings, Integrations, Webhooks) or with POST /v1/webhook-endpoints (scope webhooks:manage). An endpoint has a URL, the event types it receives, and optional filters by campaign and by rep. A workspace holds up to 15 endpoints.
- The URL must be HTTPS on port 443 and resolve to a public address.
- The signing secret is shown once, when the endpoint is created or its secret rotated.
- An optional auth header (for example a bearer token your receiver expects) is stored encrypted and sent with every delivery.
- Events caused by a mass operation (an import, a bulk status change) reach only endpoints with
include_bulk: true, so a 30,000 row import does not flood a receiver that did not ask for it. - A rep filter (
ig_account_ids) passes only events about those reps. An event about no rep, such as a campaign change or a mass lead change, does not reach a rep-filtered endpoint, with one exception:job.completedanddiscovery.run.*arrive for a job or a discovery run started by the API key that created the endpoint, or by a key rotated from it.
The payload
Every event shares one envelope, flat and one level deep so tools like Clay can map each key as a column. Envelope keys are always present (null when they do not apply); the event's own fields follow, then one cf_<key> per custom field value of the lead (Custom fields). Schema version: 2026-10-08.
{
"event_id": "9a7b6c5d-4e3f-4a1b-8c2d-0e1f2a3b4c5d",
"event_type": "lead.replied",
"schema_version": "2026-10-08",
"occurred_at": "2026-10-21T09:30:00.000Z",
"workspace_id": "8d0b6a52-6f0e-4d63-9a51-2b7c3f1e9a10",
"lead_id": "0b8e2c6d-4f1a-4b7e-9d3c-6a5f4e3d2c1b",
"external_id": "crm-1042",
"ig_username": "jane.doe",
"lead_name": "Jane Doe",
"lead_status": "replied",
"lead_tags": "webinar,coach",
"campaign_id": "2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
"campaign_name": "October webinar",
"rep_ig_username": "acme.anna",
"conversation_id": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918",
"conversation_url": "https://app.dmhero.ai/inbox?c=7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918",
"campaign_step": 1,
"handed_to_setter": true,
"cf_offer": "Mentoring"
}Envelope keys: event_id, event_type, schema_version, occurred_at, workspace_id, lead_id, external_id, ig_username, lead_name, lead_status, lead_tags, campaign_id, campaign_name, rep_ig_username, conversation_id, conversation_url.
A payload never carries ig_user_id, AI output, internal notes or contact details lifted from messages. conversation_url opens the thread in DM Hero for someone signed in to the workspace; the link itself discloses nothing.
Event catalogue
Generated from the event catalog this deploy emits. Subscribe to a type by name.
| Event | Carries a lead | Own fields | When |
|---|---|---|---|
lead.replied | yes | campaign_step, handed_to_setter | A lead answered a campaign DM for the first time in that enrollment. handed_to_setter says whether the AI Setter took the thread. handed_to_setter is null when the plan does not include the AI Setter. On by default for Clay endpoints. |
lead.booked | yes | source, previous_status, booked_at, event_start_at, invitee_name, invitee_email, days_from_first_dm, dms_before_booking | A call was booked: through the Calendly goal link (source: calendly), with the inbox Booked button (source: manual), by the AI Setter in the connected Google Calendar (source: google_calendar), or on the lead's word in the DM that they booked through the link the AI Setter sent (source: claimed). The last two reach only an organization whose plan includes the AI Setter. A manual or claimed booking carries no invitee: event_start_at, invitee_name and invitee_email are null; a Google Calendar one has no invitee_name. On by default for Clay endpoints. |
booking.unattributed | no | invitee_name, invitee_email, event_start_at | A Calendly booking arrived for the workspace but matched no lead. |
lead.opted_out | yes | source, dm_sent_before | The lead said no: rejected in the send queue (queue_reject) or classified as a hard opt out by the AI (ai). On by default for Clay endpoints. |
lead.opt_out_reverted | yes | restored_status | An opt out was undone; restored_status is the status the lead went back to. On by default for Clay endpoints. |
setter.needs_human | yes | reason_kind | The AI Setter parked a thread for a person, with the reason in reason_kind. On by default for Clay endpoints. |
lead.contacted | yes | template_category | The first DM of a campaign went out to the lead. |
lead.booking_canceled | yes | source, previous_status, booked_at, event_start_at, invitee_name, invitee_email, days_from_first_dm, dms_before_booking | A booking was canceled: in Calendly (source: calendly), by undoing a Booked mark in the inbox, a claimed booking's included (source: manual), or in Google Calendar for a call the AI Setter booked there (source: google_calendar, only for an organization whose plan includes the AI Setter). On by default for Clay endpoints. |
lead.reply_classified | yes | reply_intent, reply_intent_confidence, classified_by, previous_intent | The stored intent of a reply changed (setter or operator classification). On by default for Clay endpoints. |
lead.created | yes | origin | A lead was created. origin says how. Leads created by an API import report through job.completed instead. |
lead.updated | yes | changed_fields | Fields of a lead changed; changed_fields lists them, a custom field as custom_fields.<key> (Custom fields). |
lead.status_changed | yes | previous_status, status, cause | The lead's status changed, with the previous and the new status, on every change whatever made it. cause says what did: operator (a person in the app), api (an API call), bulk (a bulk action), dm_sent (the first campaign DM went out), reply (the lead replied), opt_out (an opt out or its undo), booking (a call was booked, or a booked mark undone) or automation (another automatic step, such as an Instagram account that no longer exists). It arrives beside the specific event (lead.contacted, lead.replied, lead.opted_out, lead.booked), never instead of it. Creating a lead is not a change. |
lead.deleted | no | deleted_lead_id, deleted_external_id, deleted_ig_username | A lead was deleted. Carries no lead object (it no longer exists): deleted_lead_id, external_id and ig_username identify it. |
lead.enrolled | yes | enrollment_id, current_step, origin | A lead was enrolled into a campaign. |
enrollment.removed | yes | enrollment_id, previous_status, reason | A lead was taken out of a campaign. |
enrollment.completed | yes | enrollment_id, steps_sent | A lead received every step of a campaign. |
dm.sent | yes | campaign_step, template_category, enrollment_id | A campaign DM went out (every step, not only the first). |
campaign.status_changed | no | previous_status, status, changed_by | A campaign was started, paused, resumed, archived or completed. |
campaign.completed | no | enrolled_count, completed_count | A campaign finished its last enrollment. |
rep.attention_required | no | reason, detail_code | A rep needs a person: its Instagram token expired or was rejected, the extension paused, or the DM cap was reached. |
job.completed | no | job_id, job_kind, job_action, status, processed, succeeded, failed | An import, export or bulk job ended, with its counts. |
discovery.run.progress | no | run_id, state, mode, phase, progress_done, progress_total, candidates_ready, matched_total, tokens_reserved | A discovery run moved: at most once a minute, and on every phase change. |
discovery.run.completed | no | run_id, state, mode, candidates_ready, matched_total, paid_for, tokens_settled, skipped_sources | A discovery run reached a terminal state (done, partial, failed or cancelled). |
| Infrastructure event | Carries a lead | Own fields | When |
|---|---|---|---|
test.ping | no | Sent on demand from the settings (Send test event) or the API, to one endpoint only. Not subscribable. |
Planned, not emitted yet: lead.stage_changed, lead.no_reply. Subscribing to them is refused until they ship.
Verifying signatures
Every delivery carries this header:
x-dmhero-signature: t=1761039000,v1=5f8a...e21ctis the Unix time of the attempt, in seconds.v1is the hex HMAC SHA-256 of the string<t>.<raw body>, keyed with the endpoint's signing secret.- For 24 hours after a secret rotation the header carries a second
v1, made with the previous secret. Accept the delivery when anyv1matches, and you can swap secrets on your side at your own pace. - Reject a
tmore than 5 minutes from your clock, so a captured delivery cannot be replayed later. - Compute the HMAC over the raw bytes you received. Parsing the JSON and serializing it again changes the bytes and breaks the signature.
Node
import crypto from "node:crypto";
// rawBody: the request body exactly as received, before any JSON parsing.
export function verifyDmHeroSignature(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
if (!signatureHeader) return false;
let timestamp = null;
const signatures = [];
for (const part of signatureHeader.split(",")) {
const [key, value] = part.trim().split("=");
if (key === "t") timestamp = value;
else if (key === "v1" && value) signatures.push(value);
}
if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest();
return signatures.some((sig) => {
const given = Buffer.from(sig, "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}import express from "express";
const app = express();
// express.raw keeps the exact bytes: a re-serialized JSON body would not match the signature.
app.post("/dmhero/webhooks", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyDmHeroSignature(req.body, req.get("x-dmhero-signature"), process.env.DMHERO_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body);
// A delivery can arrive more than once: deduplicate on event.event_id.
// Answer fast (within 10 seconds) and do slow work in the background.
res.status(200).end();
});Python
import hashlib
import hmac
import time
def verify_dmhero_signature(raw_body: bytes, signature_header: str, secret: str, tolerance_seconds: int = 300) -> bool:
"""raw_body: the request body exactly as received, before any JSON parsing."""
if not signature_header:
return False
timestamp = None
signatures = []
for part in signature_header.split(","):
key, _, value = part.strip().partition("=")
if key == "t":
timestamp = value
elif key == "v1" and value:
signatures.append(value)
if not timestamp or not timestamp.isdigit() or not signatures:
return False
if abs(time.time() - int(timestamp)) > tolerance_seconds:
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, sig.lower()) for sig in signatures if sig.isascii())from flask import Flask, request, abort
import os
app = Flask(__name__)
@app.post("/dmhero/webhooks")
def dmhero_webhook():
if not verify_dmhero_signature(request.get_data(), request.headers.get("x-dmhero-signature", ""), os.environ["DMHERO_WEBHOOK_SECRET"]):
abort(401)
event = request.get_json()
# Deduplicate on event["event_id"], answer fast, work in the background.
return "", 200Delivery and retries
Answer with any 2xx within 10 seconds. Redirects are not followed. Each delivery also carries x-dmhero-event (the type), x-dmhero-event-id, x-dmhero-delivery-id and user-agent: DMHero-Webhooks/1.
| Your answer | What we do |
|---|---|
| 2xx | Delivered. |
429 (with or without Retry-After), or 503 with Retry-After | Pause the endpoint for the Retry-After time (1 minute when a 429 sends none), then continue. Not counted as a failure. |
| 408, 5xx, a timeout or a network error | Retry after 1 minute, 5 minutes, 30 minutes, 2, 6, 12 and 24 hours; after 8 attempts the delivery is marked failed. |
| 401 or 403 | Counted as an auth failure; three in a row disable the endpoint (auth_failed). |
| Any other 4xx | The delivery fails at once, without retries. |
- An endpoint that fails 25 times in a row with no success for 72 hours is disabled, and the workspace sees why in the app. Re-enable it once your receiver is fixed.
- Deliveries to one host are paced to about 8 per second per workspace.
- Delivery is at least once: deduplicate on
event_id. Order is not guaranteed: useoccurred_at.
Redelivery and testing
POST /v1/webhook-endpoints/{endpoint_id}/testsends atest.pingto that endpoint only.GET /v1/webhook-endpoints/{endpoint_id}/deliverieslists recent deliveries with their status, attempts and last response code (never the payload).POST /v1/webhook-deliveries/{delivery_id}/redeliversends a delivery again, with the bytes it was first rendered with.- The app shows the same delivery log under each endpoint.