Menu

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.

  1. In DM Hero, create a key with the Clay compatible preset (leads:read, leads:write, campaigns:read, campaigns:enroll).
  2. In the Clay table, add an HTTP API column: method POST, URL below, header Authorization: Bearer <your key>, and a JSON body mapping your columns.
  3. Run the column. Each row upserts the lead (matched on external_id, then on the handle) and enrolls it, in one call.
  4. 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.
HTTP API column URL
POST https://api.dmhero.ai/v1/campaign-leads
Body
{
  "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.

Note: A Clay table webhook accepts at most 50,000 records over its whole life, which is why the Clay preset leaves 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.

1. Import a CSV (or JSON with { "leads": [...] }), tagged
curl -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.csv
2. Poll the job (or listen for job.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:

3. Dry run, then enroll
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's filter can 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.

  1. Turn seed handles into accounts: POST /v1/discovery/handles/resolve with up to 10 handles.
  2. Price the recipe: POST /v1/discovery/quote with the recipe document. The answer is the token price and what you would get.
  3. Start it: POST /v1/discovery/runs with { "recipe": { "name", "doc" } } (or a saved recipe_id) and an Idempotency-Key. The answer carries the run and the tokens reserved; a 402 says the balance is short and carries the quote.
  4. Follow it: poll GET /v1/discovery/runs/{run_id} every 10 seconds, or listen for discovery.run.progress and discovery.run.completed.
  5. Read the candidates: GET /v1/discovery/runs/{run_id}/candidates.
  6. Promote the ones you want: POST /v1/discovery/runs/{run_id}/promote with candidate_ids (up to 200) or { "all": true }, plus tags or a folder_id. The answer lists the new lead_ids.
  7. Enroll those leads (POST /v1/campaigns/{campaign_id}/enrollments with lead_ids).
ts
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) });
Note: A quote counts against a budget of 1,000 a day per organization, so quote once per recipe, not in a loop.

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.

  1. Subscribe an endpoint to lead.created, lead.updated, lead.status_changed and lead.deleted. On each, upsert or delete the record in your CRM keyed by lead_id, or by your own external_id.
  2. Every few hours, read what changed since your last high water mark, oldest first, and upsert it.
bash
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_at of the last lead you processed and use it as the next updated_after. Follow next_cursor until has_more is false.
  • Write your own id into DM Hero as external_id (on upsert or PATCH /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 ETag back as If-Match on PATCH; a 412 LEAD_PRECONDITION_FAILED_412 means read it again first. DM Hero also updates a lead in the background (a profile enrichment, a research pass), which changes its ETag and updated_at even when no field you read moved, so expect an occasional 412 and an updated_after match with nothing new.
  • Map your CRM's own properties to custom fields: every lead you read carries every defined field, null when empty, and a change to one arrives as lead.updated naming custom_fields.<key>.
Recipes | DM Hero Developers