Guides
Recipes
Clay, bulk import and enroll, discovery to campaign, and CRM sync.
Four complete workflows, each built only from documented operations and events.
Clay: enrich, push, enroll, get results back
The integration DM Hero started with. Clay pushes leads into a campaign row by row, and DM Hero sends replies and bookings back into the same table.
- In DM Hero, create a key with the Clay compatible preset (
leads:read,leads:write,campaigns:read,campaigns:enroll). - In the Clay table, add an HTTP API column: method
POST, URL below, headerAuthorization: Bearer <your key>, and a JSON body mapping your columns. - Run the column. Each row upserts the lead (matched on
external_id, then on the handle) and enrolls it, in one call. - For results, add a Webhook source to the Clay table, copy its URL, and create a webhook endpoint in DM Hero with the Clay preset. Replies, bookings and opt outs arrive as rows matched by
external_id.
POST https://api.dmhero.ai/v1/campaign-leads{
"campaign_id": "2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
"ig_handle": "{{Instagram handle}}",
"first_name": "{{First name}}",
"last_name": "{{Last name}}",
"external_id": "{{Row id}}",
"custom_fields": { "offer": "{{Offer}}" }
}custom_fields takes the keys of the fields your workspace defines, so a Clay column can fill a field a template names as {{custom.offer}}. A Clay compatible key may also push a key the workspace has not defined yet: see Custom fields.
The answer says enrolled: true, or why not in skipped_reason (already_enrolled, in_other_campaign, opted_out, converted, inbound_lead, already_in_conversation, excluded_handle). Clay's shared egress addresses are never throttled because of other tenants: limits follow your key.
A cell holding the NUL character (U+0000) or a broken UTF-16 surrogate cannot be stored as sent, so the row answers 400 VALIDATION_FAILED_400 with that field named text_invalid in fields, and nothing of the row is written or enrolled. Clean the column and run the row again.
lead.contacted (one record per first DM) off by default.Bulk import and enroll
Load thousands of leads in one job, then enroll the ones you imported.
{ "leads": [...] }), taggedcurl -X POST "https://api.dmhero.ai/v1/leads/imports?on_conflict=skip&tags=oct-import" \
-H "Authorization: Bearer $DMHERO_API_KEY" \
-H "Content-Type: text/csv" \
-H "Idempotency-Key: $(uuidgen)" \
--data-binary @leads.csvjob.completed)curl https://api.dmhero.ai/v1/jobs/JOB_ID -H "Authorization: Bearer $DMHERO_API_KEY"The job ends with counts only: imported, updated, skipped, failed, and the first 100 row errors by row number. Then enroll by filter. On an active campaign a filter enroll needs a dry run first, because DMs follow and cannot be unsent:
curl -X POST https://api.dmhero.ai/v1/campaigns/CAMPAIGN_ID/enrollments \
-H "Authorization: Bearer $DMHERO_API_KEY" -H "Content-Type: application/json" \
-d '{ "filter": { "tag": "oct-import" }, "limit": 5000, "dry_run": true }'
# => { "matched_count": 1840, "confirm_token": "...", "confirm_expires_at": "..." }
curl -X POST https://api.dmhero.ai/v1/campaigns/CAMPAIGN_ID/enrollments \
-H "Authorization: Bearer $DMHERO_API_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "filter": { "tag": "oct-import" }, "limit": 5000, "confirm_token": "..." }'- A
custom_fields.<key>column fills that custom field, and the dry run'sfiltercan select on custom fields too (Custom fields). - An import takes up to 30,000 rows (15 MiB). Columns the importer does not know are refused with the column's name, never silently dropped.
- An organization runs one job at a time and holds at most three live jobs.
Discovery to campaign
Find new leads with DM Hero's discovery engine, then put the good ones into a campaign. Discovery spends your organization's discovery tokens, under the same balance and ceiling as the app. Needs discovery:read, and discovery:spend to start runs.
- Turn seed handles into accounts:
POST /v1/discovery/handles/resolvewith up to 10 handles. - Price the recipe:
POST /v1/discovery/quotewith the recipe document. The answer is the token price and what you would get. - Start it:
POST /v1/discovery/runswith{ "recipe": { "name", "doc" } }(or a savedrecipe_id) and anIdempotency-Key. The answer carries the run and the tokens reserved; a 402 says the balance is short and carries the quote. - Follow it: poll
GET /v1/discovery/runs/{run_id}every 10 seconds, or listen fordiscovery.run.progressanddiscovery.run.completed. - Read the candidates:
GET /v1/discovery/runs/{run_id}/candidates. - Promote the ones you want:
POST /v1/discovery/runs/{run_id}/promotewithcandidate_ids(up to 200) or{ "all": true }, plustagsor afolder_id. The answer lists the newlead_ids. - Enroll those leads (
POST /v1/campaigns/{campaign_id}/enrollmentswithlead_ids).
const run = await post("/discovery/runs", { recipe: { name: "Fitness coaches PL", doc: recipeDoc } }, { idempotencyKey: crypto.randomUUID() });
let state = run.run.state;
while (!["done", "partial", "failed", "cancelled"].includes(state)) {
await sleep(10_000);
state = (await get(`/discovery/runs/${run.run.id}`)).state;
}
const candidates = await get(`/discovery/runs/${run.run.id}/candidates?limit=100`);
const picked = candidates.data.filter(isGoodFit).map((c) => c.id);
const promoted = await post(`/discovery/runs/${run.run.id}/promote`, { candidate_ids: picked, tags: ["discovery"] });
await post(`/campaigns/${campaignId}/enrollments`, { lead_ids: promoted.lead_ids.slice(0, 100) });Sync to your CRM
Keep another system in step with DM Hero's leads: webhooks for changes as they happen, and a periodic incremental read that catches anything a webhook missed while your receiver was down.
- Subscribe an endpoint to
lead.created,lead.updated,lead.status_changedandlead.deleted. On each, upsert or delete the record in your CRM keyed bylead_id, or by your ownexternal_id. - Every few hours, read what changed since your last high water mark, oldest first, and upsert it.
curl "https://api.dmhero.ai/v1/leads?order=updated_at_asc&updated_after=2026-10-21T09:00:00Z&limit=200" \
-H "Authorization: Bearer $DMHERO_API_KEY"- Store the
updated_atof the last lead you processed and use it as the nextupdated_after. Follownext_cursoruntilhas_moreis false. - Write your own id into DM Hero as
external_id(on upsert orPATCH /v1/leads/{lead_id}): every webhook then carries it, so your receiver needs no lookup. - To avoid overwriting a change made in DM Hero meanwhile, send the lead's
ETagback asIf-MatchonPATCH; a 412LEAD_PRECONDITION_FAILED_412means read it again first. DM Hero also updates a lead in the background (a profile enrichment, a research pass), which changes itsETagandupdated_ateven when no field you read moved, so expect an occasional 412 and anupdated_aftermatch with nothing new. - Map your CRM's own properties to custom fields: every lead you read carries every defined field,
nullwhen empty, and a change to one arrives aslead.updatednamingcustom_fields.<key>.