Documentación

La documentación del producto está disponible solo en inglés.

Custom fields in the API

Custom fields let your firm store its own data on contacts, matters, and leads. The API reads and writes them through one optional customFields object.

All paths sit under the base address https://api.esqase.com, and every request needs an API key. See Authentication and scopes if you have not made your first call yet.

Before you begin

A custom field is a field your firm adds to Esqase itself, such as a referral source on a contact or an opposing counsel on a matter. Someone at your firm defines each one in the dashboard: its name, its type, and any options it offers. See Custom fields.

Three things to know before you write any:

  • The API can set values, not definitions. You cannot create, rename, or delete a custom field through the API. Add fields in the dashboard, then fill them in from your integration.
  • Only contacts, matters, and leads have custom fields. Practice areas and matter types do not.
  • Every field belongs to exactly one category, CONTACT, MATTER, or LEAD. A contact field cannot be set on a matter, and the API rejects the attempt rather than guessing.

Discovering your fields

GET /v1/custom-fields lists the custom field definitions in your firm. This is how you learn a field's name, what kind of value it takes, and which option values it accepts.

The endpoint is authorized by the scopes you already have: a key may call it if it holds any of contacts:read, matters:read, or leads:read, and the results are limited to the categories that key can actually read. A key with only contacts:read sees contact fields and nothing else.

Query parameters:

  • category (optional): CONTACT, MATTER, or LEAD. Omit it to get every category the key may read. Any other value is rejected with 400, and asking for a category the key cannot read is rejected with 403.
  • limit: how many records to return. Minimum 1, maximum 100, default 20.
  • offset: how many records to skip before the page starts. Minimum 0, default 0.
curl "https://api.esqase.com/v1/custom-fields?category=CONTACT&limit=20" \
  -H "Authorization: Bearer $ESQASE_API_KEY"
{
  "data": [
    {
      "id": "b2c3d4e5-9000-4a1b-9c2d-1234567890ab",
      "name": "referral_source",
      "title": "Referral source",
      "label": "Where did they hear about us?",
      "description": null,
      "category": "CONTACT",
      "element": "DROPDOWN",
      "itemType": null,
      "isRequired": false,
      "isIndividual": true,
      "supported": true,
      "options": [
        { "label": "Website", "value": "website" },
        { "label": "Referral", "value": "referral" }
      ],
      "placeholder": null,
      "helperText": null,
      "tooltip": null,
      "sort": 0,
      "createdAt": "2026-07-01T09:00:00.000Z",
      "updatedAt": "2026-07-01T09:00:00.000Z"
    },
    {
      "id": "b2c3d4e5-9001-4a1b-9c2d-1234567890ab",
      "name": "retainer_amount",
      "title": "Retainer amount",
      "label": null,
      "description": null,
      "category": "CONTACT",
      "element": "CURRENCY",
      "itemType": null,
      "isRequired": false,
      "isIndividual": true,
      "supported": false,
      "options": null,
      "placeholder": null,
      "helperText": null,
      "tooltip": null,
      "sort": 1,
      "createdAt": "2026-07-01T09:02:00.000Z",
      "updatedAt": "2026-07-01T09:02:00.000Z"
    }
  ],
  "pagination": { "limit": 20, "offset": 0, "total": 2 }
}

The fields on each definition:

  • id: the field's id. You can use it as a key when you write values.
  • name: the short machine name, lowercase letters, numbers, and underscores. This is the usual key when you write values.
  • title, label, description, placeholder, helperText, tooltip: the wording your firm sees in the dashboard. Any of them may be null.
  • category: CONTACT, MATTER, or LEAD.
  • element: what kind of value the field holds, such as TEXT, DROPDOWN, or DATE. The table further down says exactly what each one accepts.
  • itemType: only meaningful for a LIST field, and informational. List entries are always sent as text.
  • isRequired: whether your firm marked the field required in the dashboard. The API does not enforce it, so check it yourself if you want to.
  • isIndividual: false when the field came from a field set rather than being added on its own. Either kind can be written.
  • supported: the field to check first. When it is false, this API cannot write the field and any attempt returns 400.
  • options: the option list for a DROPDOWN, RADIO, or CHECKLIST field, each entry with a label and a value. You always send the value, never the label. null when the field has no options.
  • sort: the field's display order in the dashboard.
  • createdAt, updatedAt: timestamps.

Archived and deleted definitions are never listed.

Sending custom fields

Add an optional customFields object to the body of any create or update on contacts, matters, or leads. It is a plain JSON object: each key names one custom field, and each value is that field's value.

{
  "firstName": "Jane",
  "lastName": "Doe",
  "customFields": {
    "referral_source": "website",
    "is_vip": true,
    "practice_tags": ["family", "estate"],
    "old_notes": null
  }
}

Keys are the field's name or its id. Anything shaped like a UUID is treated as an id; everything else is treated as a name. The two can never be confused, because field names never contain hyphens. Use the id when two of your fields happen to share a name.

Updates merge, they never wipe. This is the rule to remember:

  • A key you omit is left exactly as it is.
  • A key set to a value is written.
  • A key set to null clears that field.
  • Leaving customFields out of the body entirely means no custom field work happens at all.

There is no way to clear every custom field at once. Clear them one key at a time.

Empty means cleared. An empty string, false, an empty array, and an empty object are all treated the same as null, matching what the dashboard stores when you clear a field by hand.

Limits. At most 50 keys per request. Each key must be a non-empty string of at most 64 characters. If two keys resolve to the same field, one by name and one by id, the request is rejected.

Example: create a contact with custom fields.

curl -X POST https://api.esqase.com/v1/contacts \
  -H "Authorization: Bearer $ESQASE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "PERSON",
    "firstName": "Jane",
    "lastName": "Doe",
    "emails": ["jane@example.com"],
    "customFields": {
      "referral_source": "website",
      "is_vip": true,
      "intake_date": "2026-07-01"
    }
  }'

Example: update a matter, changing one field and clearing another.

curl -X PATCH https://api.esqase.com/v1/matters/c3d4e5f6-0001-4a1b-9c2d-1234567890ab \
  -H "Authorization: Bearer $ESQASE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "customFields": {
      "opposing_counsel": "Ramirez & Co.",
      "court_docket": null
    }
  }'

Every other custom field on that matter is untouched.

Example: create a lead with a custom field. Lead custom fields are written on the lead, not on the contact the lead creates.

curl -X POST https://api.esqase.com/v1/leads \
  -H "Authorization: Bearer $ESQASE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "PERSON",
    "firstName": "Jane",
    "lastName": "Doe",
    "email": "jane@example.com",
    "customFields": {
      "intake_channel": "phone",
      "urgency": 4
    }
  }'

What each field type accepts

Check a field's element on GET /v1/custom-fields, then send the value in the matching form.

ElementSendExample valueNotes
TEXTa string"Acme Holdings"Up to 10,000 characters.
MULTILINEa string"line one\nline two"Up to 10,000 characters.
RICH_TEXT_AREAa string"## Scope\n\nSome **bold** text"Markdown, not HTML. Up to 10,000 characters.
NUMBERa number or a numeric string42 or "42"Always reads back as a string.
DATEa string"2026-08-18"YYYY-MM-DD, and it must be a real calendar date.
DATE_TIMEa string"2026-08-18T06:30:00.000Z"ISO 8601, stored normalized to UTC. A value with no time zone offset is read as UTC.
TIMEa string"14:30"HH:mm, 24-hour, no seconds.
DROPDOWNa string"tier_gold"Must be one of the field's option values.
RADIOa string"yes"Must be one of the field's option values.
CHECKBOXa booleantruefalse clears the field. The strings "true" and "false" are rejected.
SWITCHa booleantrueSame rules as CHECKBOX.
CHECKLISTan array of strings["tier_gold", "tier_silver"]Every entry must be an option value. Up to 100 entries, duplicates removed.
LISTan array of strings["first line", "second line"]Up to 100 entries, each up to 500 characters. Entries are always text, even on a number list.
EMAILa string"ada@example.com"Must look like an email address.
PHONEa string"+13105551234"Must be in E.164 form, a plus sign then digits.
STAR_RATINGa number4A whole number from 1 to 5. Send null to clear.

Three of these deserve a second look:

  • Number fields read back as text. You may send 42 or "42", but a read always returns "42". Parse it on your side if you need arithmetic.
  • Star ratings read back as numbers. A read returns 4, not "4". There is no zero rating; clear the field with null instead.
  • Checkboxes have no stored false. Sending false clears the field, so a cleared checkbox and a checkbox that was never set look the same. This matches the dashboard.

Field types the API cannot set

Four kinds of custom field are not writable through the API, because their value is a compound object whose parts would need their own rules:

ElementWhy it is not supported
CURRENCYHolds a currency code and an amount, each validated separately.
ADDRESSHolds six parts, and the meaning of the state part depends on the country.
DATE_RANGEHolds a start and an end, either of which may be missing.
NAMEHolds six name parts, two of them drawn from fixed lists, and it already has a dedicated meaning on intake.

Set these in the dashboard instead. A definition with any of them reports "supported": false on GET /v1/custom-fields, and writing one returns 400:

{
  "error": {
    "code": "invalid_custom_field",
    "message": "Custom field 'retainer_amount' uses the CURRENCY element, which this API does not support. Set it in the Esqase dashboard."
  }
}

Nothing is silently dropped. If a request touches an unsupported field, the whole request fails and no record is created or changed.

Reading custom fields back

Single-record reads and create and update responses carry two extra properties on contacts, matters, and leads:

  • customFields: an object keyed the same way you write them, with each field's current value. On contacts, create and update responses echo back the values you sent; matters and leads reload the saved record. Retrieve a contact by id if you need to confirm what was stored.
  • unsupportedCustomFields: the names of fields set on this record that the API cannot write, so you always know the record holds more than you can see.
{
  "data": {
    "id": "a1b2c3d4-0001-4a1b-9c2d-1234567890ab",
    "type": "PERSON",
    "status": "ACTIVE",
    "name": "Jane Doe",
    "emails": ["jane@example.com"],
    "phones": [],
    "customFields": {
      "referral_source": "website",
      "is_vip": true,
      "intake_date": "2026-07-01",
      "case_value": "25000"
    },
    "unsupportedCustomFields": ["retainer_amount"]
  }
}

Two things to plan for:

  • List responses never include custom fields. GET /v1/contacts, /v1/matters, and /v1/leads leave both properties out entirely rather than sending an empty object. Fetch a record by id when you need its values.
  • When two fields share a name, and this record holds a value for more than one of them, every colliding value is keyed by the field's id instead, so nothing is lost. If only one of the same-named fields has a value on the record, the read returns the plain name, and writing that name back returns 409. Use the field's id from GET /v1/custom-fields whenever GET /v1/custom-fields shows two fields sharing a name.

Round-tripping a response

The customFields object you read is safe to send back on an update, with two exceptions:

  • The field was archived or deleted after the value was stored. The old value still shows up in the read, but writing it back returns 400 with Custom field 'x' does not exist for this firm, because archived and deleted definitions are not listed. Drop that key, or restore the field in the dashboard.
  • A dropdown, radio, or checklist option was renamed or removed after the value was stored. The old value still reads back, but writing it back returns 400 with Custom field 'x' must be one of: .... Send one of the current option values instead.

Errors

Every custom field problem uses one code, invalid_custom_field, with a message naming the key so you can act on it. The one exception is an ambiguous name, which is a conflict.

StatusCodeMessage you will see
400invalid_custom_fieldcustomFields must be a JSON object keyed by custom field name or id, for example {"referral_source": "Website"}
400invalid_custom_fieldcustomFields accepts at most 50 entries per request
400invalid_custom_fieldcustomFields keys must be a custom field name or id
400invalid_custom_fieldCustom field 'x' does not exist for this firm
400invalid_custom_fieldCustom field 'x' is a MATTER field and cannot be set on a contact
400invalid_custom_fieldCustom field 'x' uses the CURRENCY element, which this API does not support. Set it in the Esqase dashboard.
400invalid_custom_fieldCustom field 'x' is set twice in customFields
400invalid_custom_fieldCustom field 'x' expects a whole number from 1 to 5 (and the equivalent for each other type)
400invalid_custom_fieldCustom field 'x' must be one of: website, referral
400invalid_custom_fieldCustom field 'x' has no options configured. Add options in the Esqase dashboard first.
409conflictMore than one custom field is named 'x'. Use the custom field id instead.

Custom fields are checked before anything is written, so a rejected customFields object never leaves a half-created record behind. A POST that fails this way creates no contact, matter, or lead at all.

Full examples in JavaScript and Python

Both examples read the key from an environment variable, look up the firm's contact fields, then create a contact using them.

JavaScript (fetch)

const BASE = "https://api.esqase.com/v1";
const headers = {
  Authorization: `Bearer ${process.env.ESQASE_API_KEY}`,
  "Content-Type": "application/json",
};

// 1. Discover the contact fields you may write.
const fieldsResponse = await fetch(`${BASE}/custom-fields?category=CONTACT&limit=100`, {
  headers,
});
const { data: fields } = await fieldsResponse.json();
const writable = fields.filter((field) => field.supported);
for (const field of writable) {
  console.log(field.name, field.element, field.options?.map((o) => o.value));
}

// 2. Create a contact with a few of them filled in.
const createResponse = await fetch(`${BASE}/contacts`, {
  method: "POST",
  headers,
  body: JSON.stringify({
    type: "PERSON",
    firstName: "Jane",
    lastName: "Doe",
    customFields: {
      referral_source: "website",
      is_vip: true,
      intake_date: "2026-07-01",
    },
  }),
});
const { data: contact } = await createResponse.json();

// 3. Change one field and clear another, leaving the rest alone.
await fetch(`${BASE}/contacts/${contact.id}`, {
  method: "PATCH",
  headers,
  body: JSON.stringify({
    customFields: { referral_source: "referral", is_vip: null },
  }),
});

Python (requests)

import os
import requests

BASE = "https://api.esqase.com/v1"
headers = {
    "Authorization": f"Bearer {os.environ['ESQASE_API_KEY']}",
    "Content-Type": "application/json",
}

# 1. Discover the contact fields you may write.
fields = requests.get(
    f"{BASE}/custom-fields",
    headers=headers,
    params={"category": "CONTACT", "limit": 100},
).json()["data"]
writable = [field for field in fields if field["supported"]]
for field in writable:
    options = [option["value"] for option in (field["options"] or [])]
    print(field["name"], field["element"], options)

# 2. Create a contact with a few of them filled in.
contact = requests.post(
    f"{BASE}/contacts",
    headers=headers,
    json={
        "type": "PERSON",
        "firstName": "Jane",
        "lastName": "Doe",
        "customFields": {
            "referral_source": "website",
            "is_vip": True,
            "intake_date": "2026-07-01",
        },
    },
).json()["data"]

# 3. Change one field and clear another, leaving the rest alone.
requests.patch(
    f"{BASE}/contacts/{contact['id']}",
    headers=headers,
    json={"customFields": {"referral_source": "referral", "is_vip": None}},
)

Common questions

  • Do I need a new scope for custom fields? No. Values ride on the record's own scopes, so contacts:write covers a contact's custom fields. The discovery endpoint needs any one of contacts:read, matters:read, or leads:read.
  • Does the API enforce required custom fields? No. A field marked required in the dashboard is still optional over the API, so that turning the toggle on never breaks an integration that is already running. Read isRequired from GET /v1/custom-fields and enforce it yourself if you want to.
  • How do I clear a field? Send it as null. An empty string, false, and an empty array do the same thing.
  • What happens to fields I do not mention? Nothing. Updates merge, so anything you leave out keeps its value.
  • Why can I not see custom fields in my list call? List responses do not include custom fields at all. Fetch the record by id to see them.
  • Can I create a custom field through the API? No. Definitions are managed in the dashboard. See Custom fields.