Menu

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.completed and discovery.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.

json
{
  "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.

EventCarries a leadOwn fieldsWhen
lead.repliedyescampaign_step, handed_to_setterA 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.bookedyessource, previous_status, booked_at, event_start_at, invitee_name, invitee_email, days_from_first_dm, dms_before_bookingA 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.unattributednoinvitee_name, invitee_email, event_start_atA Calendly booking arrived for the workspace but matched no lead.
lead.opted_outyessource, dm_sent_beforeThe 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_revertedyesrestored_statusAn opt out was undone; restored_status is the status the lead went back to. On by default for Clay endpoints.
setter.needs_humanyesreason_kindThe AI Setter parked a thread for a person, with the reason in reason_kind. On by default for Clay endpoints.
lead.contactedyestemplate_categoryThe first DM of a campaign went out to the lead.
lead.booking_canceledyessource, previous_status, booked_at, event_start_at, invitee_name, invitee_email, days_from_first_dm, dms_before_bookingA 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_classifiedyesreply_intent, reply_intent_confidence, classified_by, previous_intentThe stored intent of a reply changed (setter or operator classification). On by default for Clay endpoints.
lead.createdyesoriginA lead was created. origin says how. Leads created by an API import report through job.completed instead.
lead.updatedyeschanged_fieldsFields of a lead changed; changed_fields lists them, a custom field as custom_fields.<key> (Custom fields).
lead.status_changedyesprevious_status, status, causeThe 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.deletednodeleted_lead_id, deleted_external_id, deleted_ig_usernameA lead was deleted. Carries no lead object (it no longer exists): deleted_lead_id, external_id and ig_username identify it.
lead.enrolledyesenrollment_id, current_step, originA lead was enrolled into a campaign.
enrollment.removedyesenrollment_id, previous_status, reasonA lead was taken out of a campaign.
enrollment.completedyesenrollment_id, steps_sentA lead received every step of a campaign.
dm.sentyescampaign_step, template_category, enrollment_idA campaign DM went out (every step, not only the first).
campaign.status_changednoprevious_status, status, changed_byA campaign was started, paused, resumed, archived or completed.
campaign.completednoenrolled_count, completed_countA campaign finished its last enrollment.
rep.attention_requirednoreason, detail_codeA rep needs a person: its Instagram token expired or was rejected, the extension paused, or the DM cap was reached.
job.completednojob_id, job_kind, job_action, status, processed, succeeded, failedAn import, export or bulk job ended, with its counts.
discovery.run.progressnorun_id, state, mode, phase, progress_done, progress_total, candidates_ready, matched_total, tokens_reservedA discovery run moved: at most once a minute, and on every phase change.
discovery.run.completednorun_id, state, mode, candidates_ready, matched_total, paid_for, tokens_settled, skipped_sourcesA discovery run reached a terminal state (done, partial, failed or cancelled).
Infrastructure eventCarries a leadOwn fieldsWhen
test.pingnoSent 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:

http
x-dmhero-signature: t=1761039000,v1=5f8a...e21c
  • t is the Unix time of the attempt, in seconds.
  • v1 is 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 any v1 matches, and you can swap secrets on your side at your own pace.
  • Reject a t more 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

js
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);
  });
}
With Express
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

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())
With Flask
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 "", 200

Delivery 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 answerWhat we do
2xxDelivered.
429 (with or without Retry-After), or 503 with Retry-AfterPause 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 errorRetry after 1 minute, 5 minutes, 30 minutes, 2, 6, 12 and 24 hours; after 8 attempts the delivery is marked failed.
401 or 403Counted as an auth failure; three in a row disable the endpoint (auth_failed).
Any other 4xxThe 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: use occurred_at.

Redelivery and testing

  • POST /v1/webhook-endpoints/{endpoint_id}/test sends a test.ping to that endpoint only.
  • GET /v1/webhook-endpoints/{endpoint_id}/deliveries lists recent deliveries with their status, attempts and last response code (never the payload).
  • POST /v1/webhook-deliveries/{delivery_id}/redeliver sends a delivery again, with the bytes it was first rendered with.
  • The app shows the same delivery log under each endpoint.
Note: Webhooks report facts; they do not ask you to act. To act on a thread, see How sending works.
Webhooks | DM Hero Developers