Menu

Guides

Custom fields

The fields a workspace adds to its leads: defining them, reading and writing values, filtering, bulk set, export and import, webhooks and limits.

A workspace can add up to 5 fields of its own to every lead in the CRM: a segment, a city, an offer, the topic of the prospect's last post. The API treats them as first class: every lead it returns shows them, you can write them, filter on them, set them in bulk, export and re-import them, and a change to one fires a webhook. Templates read them as {{custom.<key>}}.

The definitions belong to the workspace of your API key. A key never sees or filters by another workspace's fields, even inside the same organization.

Defining fields

A field has a label, the name people see in the app, and a key, the name the API, templates and webhooks use. The key is derived from the label unless you give one, and it never changes: renaming a field changes only its label, so every template that names the key keeps working.

OperationWhat it does
GET /v1/custom-fieldsThe workspace's fields in display order, with coverage (how many leads have a value) and max.
POST /v1/custom-fieldsDefine a field: { "label": "Segment" }, optionally with key. A sixth field answers 409 CUSTOM_FIELD_LIMIT_409.
PATCH /v1/custom-fields/{key}Rename the label. The key stays.
DELETE /v1/custom-fields/{key}?confirm=trueDelete the field and strip its value from every lead of the workspace. Needs leads:delete, and confirm=true because it cannot be undone.
bash
curl -X POST https://api.dmhero.ai/v1/custom-fields \
  -H "Authorization: Bearer $DMHERO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "label": "Segment" }'
# => { "key": "segment", "label": "Segment", "created_at": "...", "coverage": 0 }
  • A key is a lowercase letter followed by up to 31 lowercase letters, digits or underscores. A label like Miasto (PL) becomes the key miasto_pl.
  • Reading the definitions needs leads:read; creating and renaming them needs leads:write.

Reading values

Every lead the API returns (GET /v1/leads, GET /v1/leads/{lead_id}, the answer of PATCH /v1/leads/{lead_id}, the sample of a bulk preview) carries custom_fields with every field the workspace defines, in display order, each one present even when the lead has no value for it:

Part of a lead
{
  "id": "0b8e2c6d-4f1a-4b7e-9d3c-6a5f4e3d2c1b",
  "ig_handle": "jane.doe",
  "custom_fields": {
    "segment": "gold",
    "city": null,
    "offer": "Mentoring"
  }
}
  • A value is a string, or null when the lead has none. A value that is only spaces reads as null too, the same way the missing filter and the send gate read it.
  • After the defined fields come any other keys stored on the lead, exactly as stored. Only a Clay compatible key can write those (below).
  • So a client can map columns from the definitions once and rely on every key being there, instead of treating an absent key and an empty value differently.
  • Two answers carry no lead object and so no custom_fields: POST /v1/leads and POST /v1/campaign-leads answer with ids and outcomes. The original Clay lookup, GET /v1/leads/lookup, keeps its answer exactly as it was, without custom fields.

Writing values

PATCH /v1/leads/{lead_id} takes custom_fields as { key: value }. Keys you do not name are left alone, and null removes a value:

bash
curl -X PATCH https://api.dmhero.ai/v1/leads/LEAD_ID \
  -H "Authorization: Bearer $DMHERO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "custom_fields": { "segment": "gold", "city": null } }'

POST /v1/leads (one lead or a batch of 100) and POST /v1/campaign-leads take the same custom_fields object and merge it per key into the lead they match: by default a value fills only a field that is empty on that lead, and merge: "overwrite" replaces values that differ. An import takes a custom_fields object per lead in JSON, or one custom_fields.<key> column per field in CSV (Export and import).

  • A value is text of at most 500 characters, trimmed before it is stored. PATCH takes strings only; POST /v1/leads also takes a number or a boolean and stores it as text.
  • All of one lead's values together are at most 4 KB.
  • With a developer key every key must be a field the workspace defines. A key it does not define answers 400 CUSTOM_FIELD_UNKNOWN_400, and fields names it; nothing is written. Define the field first.

Keys written by Clay

A key with the Clay compatible preset keeps the rule it always had: it may write any well formed key, up to 20 keys on one lead, defined or not, so a Clay table never breaks because a column was added before the field. Such a value is stored, shows in custom_fields after the defined fields, and reaches webhooks as cf_<key>. Until the workspace defines a field with that key it is not a field: it cannot be filtered on, it is not an export column, and the app does not show it. Defining a field with the same key (POST /v1/custom-fields with key) turns the stored values into that field's values.

Filtering leads by custom fields

GET /v1/leads filters on custom field values with query keys that start with cf., one key per condition:

Query keyMatches leads whose value
cf.<key>=<text>equals the text, both trimmed, ignoring case. The whole value: cf.segment=gold does not match gold plus.
cf.<key>.contains=<text>contains the text, ignoring case. Every character is literal, % and _ included. The text is not trimmed.
cf.<key>.present=trueis not blank.
cf.<key>.missing=trueis absent or blank. A lead that never had the key matches too.
bash
curl "https://api.dmhero.ai/v1/leads?cf.segment=gold&cf.city.contains=gdy&cf.offer.missing=true&limit=100" \
  -H "Authorization: Bearer $DMHERO_API_KEY"
  • Conditions combine with AND, with each other and with every other filter (tag, status, campaign_id and the rest). At most 5 conditions, one per query key: the same key twice is refused.
  • A text is 1 to 500 characters. URL-encode it like any query value: cf.city=New%20York.
  • .present and .missing take only true.
  • Every key must be a field your workspace defines. The cursor is bound to the conditions like every other filter: send the same ones with cursor.
  • Case is ignored for every alphabet, Polish letters included: cf.city=łódź matches Łódź.

In the OpenAPI document the family appears as one query parameter named cf.{key}, marked with x-dmhero-query-prefix: "cf.".

In a JSON filter

Operations that take a lead filter in a JSON body, a bulk action, an export and an enrollment by filter (POST /v1/campaigns/{campaign_id}/enrollments), take the same conditions as an array under filter.custom_fields:

json
{
  "filter": {
    "tag": "webinar",
    "custom_fields": [
      { "key": "segment", "op": "equals", "value": "gold" },
      { "key": "city", "op": "contains", "value": "gdy" },
      { "key": "offer", "op": "missing" }
    ]
  }
}

op is equals, contains, present or missing, with the meanings of the table above. value is required, 1 to 500 characters, for equals and contains, and ignored for the other two. At most 5 conditions.

When a filter is refused

json
{
  "error": "custom_field_unknown",
  "message": "The filter names custom fields this workspace has not defined: segment. GET /v1/custom-fields lists the defined ones.",
  "code": "CUSTOM_FIELD_UNKNOWN_400",
  "request_id": "req_4fK9wQz2LmT7xB1sR8vN3cYd",
  "doc_url": "https://developers.dmhero.ai/errors#CUSTOM_FIELD_UNKNOWN_400",
  "fields": { "cf.segment": "key_unknown" }
}
AnswerWhen
400 CUSTOM_FIELD_UNKNOWN_400A condition names a key your workspace has not defined, including one defined only in another workspace. fields names each one: cf.segment in a query, filter.custom_fields.1 in a body.
400 VALIDATION_FAILED_400More than 5 conditions, an empty text, a text over 500 characters, .present or .missing with a value other than true, the same key twice, or a bare cf=.

A refused filter never runs: you get the 400, not an empty page or an empty job.

Setting a field on many leads

The bulk action set_custom_fields writes the same values to every lead a target selects, through the usual preview and confirm. params.values is { key: value }; null clears that field, and keys you do not name are left alone.

Preview, then confirm
curl -X POST https://api.dmhero.ai/v1/leads/bulk-actions \
  -H "Authorization: Bearer $DMHERO_API_KEY" -H "Content-Type: application/json" \
  -d '{ "action": "set_custom_fields", "target": { "filter": { "custom_fields": [{ "key": "segment", "op": "missing" }] } }, "params": { "values": { "segment": "silver", "city": null } } }'
# => { "id": "JOB_ID", "status": "awaiting_confirmation", "preview": { "matched_count": 1840, ... }, "confirm_token": "..." }

curl -X POST https://api.dmhero.ai/v1/leads/bulk-actions/JOB_ID/confirm \
  -H "Authorization: Bearer $DMHERO_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "confirm_token": "...", "expected_count": 1840 }'
  • It overwrites values, so it always needs the confirm step: auto_confirm: true is refused.
  • Like update_status, it needs the leads:delete scope as well as leads:write, at preview and at confirm. A key without it gets 403 API_SCOPE_MISSING_403.
  • Every key must be a field the workspace defines (400 CUSTOM_FIELD_UNKNOWN_400 at preview, before anything is frozen).
  • Each value follows the writing rules. A lead whose values would pass 4 KB, or 20 keys, is left as it was and counted in the result's failed and in its own count, custom_fields_too_large: a value is never cut to fit. The result carries counts only, never which leads.
  • A lead whose values would not change is counted in skipped and not written, so it fires no webhook.
  • If one of the fields is deleted after the preview, the job fails with CUSTOM_FIELD_NOT_FOUND; leads it already wrote keep their values. Preview again.

Export and import

An export (POST /v1/leads/exports) appends one column per defined field to the file, in display order, headed custom_fields.<key>. That is exactly the header an import reads, so an exported file goes back through POST /v1/leads/imports unchanged, custom fields included.

Export the gold segment
curl -X POST https://api.dmhero.ai/v1/leads/exports \
  -H "Authorization: Bearer $DMHERO_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "filter": { "custom_fields": [{ "key": "segment", "op": "equals", "value": "gold" }] } }'
A CSV import with two custom fields
ig_handle,name,custom_fields.segment,custom_fields.city
jane.doe,Jane Doe,gold,Gdynia
john.roe,John Roe,silver,
  • An import refuses a custom_fields.<key> column whose key the workspace has not defined. Define the field first.
  • Only defined fields become columns. A key that only a Clay compatible key wrote (above) is not exported until a field with that key exists.
  • The CSV export in the app keeps its own column layout; this page describes the API's export.

Webhooks

A change to a lead's custom field values fires lead.updated, whoever made it: a person in the app, an API update or upsert, an import or a bulk action. changed_fields names each changed field as custom_fields.<key>, comma separated and sorted with the lead's other changed fields:

Part of the payload
{
  "event_type": "lead.updated",
  "lead_id": "0b8e2c6d-4f1a-4b7e-9d3c-6a5f4e3d2c1b",
  "changed_fields": "custom_fields.city,custom_fields.segment,notes",
  "cf_segment": "gold"
}
  • One request that changes ordinary fields and custom fields together fires one event, which lists both.
  • A write that leaves every value as it was fires nothing.
  • A mass write (set_custom_fields, an import that merges into existing leads, deleting a field that several leads hold) fires one event per lead it changed, as a bulk event: only endpoints with include_bulk: true receive it.
  • Every event that carries a lead also carries its stored values as flat cf_<key> keys, as it always has (The payload). A field the lead has no value for has no cf_ key, which is why cf_city is absent above after its value was removed.

MCP

  • search_leads takes the same four conditions, so an agent can ask for "gold segment leads in Gdynia" directly.
  • bulk_update_leads can run set_custom_fields. It overwrites, so it goes through the two step confirmation.
  • manage_crm_setup lists the fields a workspace defines. An agent should read them before it filters or writes, since an undefined key is refused.

Limits

LimitValue
Fields per workspace5
KeyA lowercase letter, then up to 31 lowercase letters, digits or underscores. Never changes.
One value500 characters, text only
All values of one lead4 KB
Keys stored on one lead20, of which only a Clay compatible key can write undefined ones
Conditions in one filter5
Text of one condition1 to 500 characters
Warning: Deleting a field strips its value from every lead and cannot be undone. A campaign step whose template still names {{custom.<key>}} then stops sending to its leads, because the value it needs is missing.
Custom fields | DM Hero Developers