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.
| Operation | What it does |
|---|---|
GET /v1/custom-fields | The workspace's fields in display order, with coverage (how many leads have a value) and max. |
POST /v1/custom-fields | Define 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=true | Delete the field and strip its value from every lead of the workspace. Needs leads:delete, and confirm=true because it cannot be undone. |
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 keymiasto_pl. - Reading the definitions needs
leads:read; creating and renaming them needsleads: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:
{
"id": "0b8e2c6d-4f1a-4b7e-9d3c-6a5f4e3d2c1b",
"ig_handle": "jane.doe",
"custom_fields": {
"segment": "gold",
"city": null,
"offer": "Mentoring"
}
}- A value is a string, or
nullwhen the lead has none. A value that is only spaces reads asnulltoo, the same way themissingfilter 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/leadsandPOST /v1/campaign-leadsanswer 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:
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.
PATCHtakes strings only;POST /v1/leadsalso 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, andfieldsnames 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 key | Matches 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=true | is not blank. |
cf.<key>.missing=true | is absent or blank. A lead that never had the key matches too. |
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_idand 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. .presentand.missingtake onlytrue.- 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:
{
"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
{
"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" }
}| Answer | When |
|---|---|
400 CUSTOM_FIELD_UNKNOWN_400 | A 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_400 | More 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.
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: trueis refused. - Like
update_status, it needs theleads:deletescope as well asleads:write, at preview and at confirm. A key without it gets 403API_SCOPE_MISSING_403. - Every key must be a field the workspace defines (400
CUSTOM_FIELD_UNKNOWN_400at 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
failedand 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
skippedand 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.
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" }] } }'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:
{
"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 withinclude_bulk: truereceive 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 nocf_key, which is whycf_cityis absent above after its value was removed.
MCP
search_leadstakes the same four conditions, so an agent can ask for "gold segment leads in Gdynia" directly.bulk_update_leadscan runset_custom_fields. It overwrites, so it goes through the two step confirmation.manage_crm_setuplists the fields a workspace defines. An agent should read them before it filters or writes, since an undefined key is refused.
Limits
| Limit | Value |
|---|---|
| Fields per workspace | 5 |
| Key | A lowercase letter, then up to 31 lowercase letters, digits or underscores. Never changes. |
| One value | 500 characters, text only |
| All values of one lead | 4 KB |
| Keys stored on one lead | 20, of which only a Clay compatible key can write undefined ones |
| Conditions in one filter | 5 |
| Text of one condition | 1 to 500 characters |
{{custom.<key>}} then stops sending to its leads, because the value it needs is missing.