Vital Guides

KEAP CUSTOM FIELDS: THE CEILING, THE TAB YOU CANNOT MOVE, THE NAME YOU CANNOT GET BACK

Adding one field to a mature Keap app runs into three walls the documentation does not mention, in this order: a ceiling that is not 100, a tab you only get to choose once, and a delete that takes the name with it. Here is how to make room without burning anything.

Updated September 2, 2026· Measured on three Keap apps, August 2026· Keap, Custom fields· Intermediate

THE FIELD THAT WOULD NOT CREATE

You are adding a custom field to a Keap app that has been running for a decade. Maybe you are an integration, maybe a form tool, maybe a consultant setting up tracking with the client watching. The first few go in. Then one comes back with a 400 saying the app is out of custom fields.

You check the documentation. It says 100. You count the app's fields and there are 150. So the number is wrong, and nothing tells you by how much. The app you are standing in is the only source, and it only speaks when refused.

That is wall one. A Keap consultant, onboarding on a live call, hit the ceiling on his own app in August 2026, with seven of ten tracking fields created and three refused. Walls two and three are behind it, and you hit them while trying to fix wall one. The tab a field sits on is decided at creation and cannot be changed by API afterwards. And deleting a field to make room burns its name for good.

Everything below was measured on real apps: a live customer app, a full sandbox with five tabs and eighteen existing fields, and a stripped sandbox used to confirm. Where we give a number, it is the number that came back.

THE CEILING IS NOT 100, AND NOTHING WILL TELL YOU WHAT IT IS

On 16 August 2026 a live customer app refused a contact custom field create at 150. Not 100, the documented figure, and not with a helpful body. This is the whole of what came back.

The refusalresponse measured
POST /crm/rest/v1/contacts/model/customFields
{ "label": "Vt Last Source", "field_type": "Text" }

HTTP 400
{ "message": "Can not create due to exceeded limit of custom fields" }

Three things matter more than the number. The cap varies by edition, so 150 is what that app had, not what yours has. Keap exposes no "how many may I create" endpoint, so the only way to learn your ceiling is to hit it. And the refusal came partway through a set, leaving the app holding seven of the ten fields the integration needed. Seven of ten looks like a working install and silently drops the rest.

So the provisioning rules we now follow, all learned from that account:

Never assume a number. The account's own refusal is the only reliable source. A tool that hardcodes 100 will refuse to write on apps that have room, and a tool that hardcodes 150 will hammer apps that do not.

A GROUP IS A HEADER, AND THE TAB IS CHOSEN ONCE

Keap models contact custom fields on three levels, not two. A tab is the thing across the top of the contact record. A group, which the interface calls a header, sits inside a tab. A field sits inside a group. A field carries a group_id and no tab_id at all; its tab is reached only through its group.

The create request describes group_id as "an optional tab group to place the field under in the interface", which reads as if it wants a tab id. It wants a header id; the tab follows. Keap's default "Custom Fields" tab is not even a row: it is absent from the tab list, has no id to target, and is simply where a field lands when group_id is omitted.

So placing a field on your own tab is three calls, and the first two are v2 only. Here is the chain as it ran on the full sandbox on 19 August 2026, with the ids that came back.

Tab, header, fieldmeasured
// 1. the tab
POST /crm/rest/v2/contacts/model/customFields/tabs
{ "name": "MyVitalAssistant" }
HTTP 201
{ "id": "32", "name": "MyVitalAssistant", "record_type": "CONTACT", ... }

// 2. the header, inside that tab
POST /crm/rest/v2/contacts/model/customFields/groups
{ "name": "Tracking Details", "tab_id": "32" }
HTTP 201
{ "id": "38", "name": "Tracking Details", "tab_id": "32", ... }

// 3. the field, inside that header
POST /crm/rest/v1/contacts/model/customFields
{ "label": "Vt Anon Id", "field_type": "Text", "group_id": 38 }
HTTP 201
{ "id": ..., "label": "Vt Anon Id", "field_name": "VtAnonId", "field_type": "Text" }
// no group_id anywhere in that body. Keep reading.

Two details that cost an hour each. The v1 create wants field_type: "Text"; the v2 create wants "TEXT", and every v2 type token is upper case. Send the wrong spelling to the wrong version and every create 400s. And match tab names case insensitively, trimmed, before creating one: a customer who already made a tab of the same name by hand should get their fields on it, not on a second tab in different capitals.

Read before you create, at every level. Both lists are unpaginated and small, so the cost is two GETs on the rare path where a field is being created and zero everywhere else.

THE CREATE RESPONSE LIES BY OMISSION

Look again at step three. The field was created with group_id: 38, the response is a 201, and the body never mentions a group. On v1, a create that placed the field perfectly returns a body that looks like it ignored the parameter. The v2 create echoes group_id and group_name; v1 does not.

So the create response is not evidence either way. The model read back is the proof, and it must be a v2 read, because the v1 model carries no group data at all. Same app, same key, same minute: v1 returned 53 fields with zero occurrences of group_id, v2 returned the same 53 with 53.

The read backmeasured
GET /crm/rest/v2/contacts/model
HTTP 200
{
  "custom_fields": [
    ...
    {
      "id": ...,
      "label": "Vt Anon Id",
      "field_name": "VtAnonId",
      "field_type": "TEXT",          // v2 spells the type in upper case
      "group_id": "38",               // there it is
      "group_name": "Tracking Details"
    }
  ]
}

Run as one chain on the full sandbox, our provisioning code created nine fields, found one already there, and the read back showed 11 of 11 in group 38 under tab 32, nothing on Keap's default tab. Until that read back, placement rested on a curl probe and unit tests against a fake.

This API returns 200 and drops values without comment elsewhere, and an optional placement hint is exactly the kind of parameter it swallows. Never verify placement from the create response. Read the model, find the field, check its group_id.

CONTACT FIELDS CANNOT BE MOVED, AND ONLY CONTACT FIELDS

Here is wall two properly. A field landed on the wrong tab, or on the default one because you sent no group_id. You would expect to move it. For companies, deals, orders and every other record type you can: PATCH /crm/rest/v2/{resource}/model/customFields/{id} with a group_id does it.

For contacts, there is no verb. We ran this first on the stripped sandbox and then again on the full one, against field 83, which sat in a real group on a populated app, reading the field back after each call to prove it had not moved.

Every verb, two appsmeasured
PATCH /crm/rest/v2/contacts/model/customFields/83
{ "group_id": "38" }                                     HTTP 405

PATCH /crm/rest/v2/contacts/model/customFields/83?update_mask=group_id
{ "group_id": "38" }                                     HTTP 405

PUT   /crm/rest/v2/contacts/model/customFields/83        HTTP 405
POST  /crm/rest/v2/contacts/model/customFields/83        HTTP 405

PATCH /crm/rest/v1/contacts/model/customFields/83        HTTP 404
PUT   /crm/rest/v1/contacts/model/customFields/83        HTTP 404
POST  /crm/rest/v1/contacts/model/customFields/83        HTTP 404

GET /crm/rest/v2/contacts/model                           // field 83: group_id "27", every time

// and the reason, from the server itself
OPTIONS /crm/rest/v2/contacts/model/customFields/320     allow: DELETE,OPTIONS
OPTIONS /crm/rest/v2/orders/model/customFields/320       allow: PATCH,DELETE,OPTIONS

A 405 is method not allowed on the resource, which is body independent: no payload shape changes it. Keap's published spec agrees, declaring a delete operation and nothing else for contact custom fields while every other record type gets a PATCH carrying group_id. Keap's API manager confirmed it to us on 10 August 2026. Contacts are the one exception, and contacts are where tracking fields live.

There is one partial escape. A header can be moved between tabs with PATCH .../groups/{id}?update_mask=tab_id, and its fields go with it. That only helps if your fields are alone in the header. Reparenting a shared one drags the customer's own fields onto your tab, which is worse than the problem you were fixing.

So placement is decided once, at creation. A person can drag a misplaced contact field in Keap's interface, and it keeps its id and name when they do. Code cannot. So get the tab and header right the first time, and fail soft: a 403 on the tab routes from a narrowly scoped token should cost you placement, never the field. A field on the wrong tab is untidy. A customer with no tracking fields because a cosmetic call failed is a broken product.

DELETE ANSWERS 204 AND BURNS THE NAME

Wall three. Faced with a full app and a field on the wrong tab, the obvious move is delete and recreate. The delete works.

The only verb contacts getmeasured
DELETE /crm/rest/v2/contacts/model/customFields/{id}
HTTP 204

GET /crm/rest/v2/contacts/model
// the field is gone from the model, and its value is gone from every contact

Two things leave with it. The first is expected: the value on every contact, permanently. The second is not. Keap will not give the original field_name back on a recreate. Create a field labelled Vt First Source, delete it, create it again with the same label, and the new one gets a different field_name.

That name is what everything else refers to. The hidden input on every web form is inf_custom_VtFirstSource. Merge codes are typed from it. Integrations key on it. Support quotes it. Delete the field and all of them point at a name that cannot be brought back, which is a stronger kind of one way than losing the data.

The consequences we build to:

MAKE ROOM BY MEASURING, NEVER BY SAMPLING

So the app is full, nothing can be moved, and deleting the wrong thing is permanent. Room has to come from existing fields, and the question is which ones are actually empty. The consultant's app is the example, because it is what a mature Keap looks like: 4,599 contacts, 150 fields, years of tools that each left something behind.

The most deletable looking field in that app was called Dropdown. Populated on all 4,599 contacts. Seven purposeful looking fields, GaSource, GaMedium, GaCampaign, GaTerm, GaContent and two more, from an older Google Analytics install, each held data on 241 contacts. A 200 contact sample undercounted those by 2.6 times. Decide from the sample, or from the names, and you keep the junk and delete the history.

So count populated values across the whole contact table before recommending anything. "No contact has this" must be a fact, not an estimate. The walk is a v1 job, since v1 pages by offset, and it has two traps of its own.

Count every contactillustrative
// v1 contacts carry NO custom_fields key unless you ask. No error, the key is simply absent.
// v1 limit silently caps at 1000: limit=1500 returns 1000 rows and no warning.
const counts = {};
let offset = 0;
while (true) {
  const page = await keap("GET",
    `/crm/rest/v1/contacts?limit=1000&offset=${offset}&optional_properties=custom_fields`);
  for (const c of page.contacts) {
    for (const f of c.custom_fields ?? []) {
      if (f.content !== null && f.content !== "") counts[f.id] = (counts[f.id] ?? 0) + 1;
    }
  }
  if (page.contacts.length < 1000) break;   // a short page is the end; a full one never is
  offset += 1000;
}
// counts[fieldId] is now a fact about the whole table, not a guess about a sample

When the fields are asked for they come back dense: unset ones are present with content: null, so a field id in the array proves nothing and only the value counts.

THE RECYCLE RULE: NAME BY WHAT A FIELD HOLDS, NEVER BY WHAT IT IS CALLED

With real counts in hand, the decision for each field is made by what it contains, and never by what its name suggests it is for.

The uniform name is not tidiness. The form builder step is manual, support quotes inf_custom_VtFirstSource, merge codes are typed by hand. A divergent name on one customer silently breaks all three for that customer alone, the hardest kind of bug to see from outside.

Two rules about who holds the knife. We never delete a customer's own field. The tool recommends, with counts beside each recommendation, and the customer executes in their own Keap. And before any deletion, export the full record, tags and custom fields included, plus a manifest naming every field going. Their data, their call, logged and exportable first.

Recognise tracking families by vocabulary, not by prefix. "Ga" followed by a known Google Analytics term (source, medium, campaign, term, content) is precise. "Ga" alone also matches Gate Code and Garage Size. And name the family per field: an fbclid sitting beside a Google install is Meta, not Google.

MAPPING ONTO A FIELD THAT ALREADY EXISTS, SAFELY

Mapping onto an existing field is how room gets made without deleting anything, and it carries its own rules, each found by trying to skip it.

Search your own fields first, not merely list them first. An alphabetical candidate list hands vt_first_source to GaSource on an account that also holds a Vt First Source, because G sorts before V. That is exactly the account this mapping exists for.

The target must be able to hold the value, and an unknown type is denied. Free text only. A dropdown, radio or list box accepts only its own options, so an arbitrary utm_source does not error, it fails to land. Dates are sharper: tracking fields are created as text deliberately, because Keap renders a date into its own format and the copy stops matching the cookie it came from. Default deny is the posture. A wrong refusal is reported in a day; a wrong allow drops attribution silently for months.

One of their fields cannot stand for two of yours. First touch and last touch pointed at one field means the second write lands on the first, and first touch stops being first touch invisibly. First touch values are write once, never overwritten on a later visit; last touch updates every visit. A shared target breaks the first rule with the second.

An incomplete map is never installed. The same rule provisioning follows: a form handed four fields of ten looks like it is working and drops the rest.

And when a set has required and optional fields, attempt the optional ones last, after every required one has landed, and treat a refusal there as silence rather than an error. Our own set is ten required and three optional: the converting touch's date, keyword and creative. A field that changes no total must never take room a required one needed, and an account without it keeps the behaviour it had until the field appears. That is how MyVitalAssistant handles a full app, and it is the honest answer: ship what fits, say what did not, never make the hole worse.

COMMON QUESTIONS

How many custom fields can a Keap contact have?
The documentation says 100. A live customer app refused the next field at 150 with a 400 and Can not create due to exceeded limit of custom fields. The ceiling varies by edition, no endpoint reports it, and the only reliable source is being refused. Count the model, stop at the first refusal, never assume a figure.
Can I move a custom field to another tab by API?
Not a contact field. PATCH, PUT and POST all answer 405 on v2, with or without update_mask, and 404 on v1, proven on two apps with a read back after every call. Companies, deals and every other record type take a PATCH with group_id. Keap's API manager confirmed contacts are the exception. Only a person dragging it in Keap can move one.
If I delete a field can I recreate it with the same name?
No. DELETE answers 204 and the field leaves the model, but Keap will not hand the original field_name back on a recreate. Every inf_custom_ hidden field, merge code and mapping that referred to it is now pointing at a name that cannot be brought back.
Should I delete unused fields to make room?
Only genuinely empty ones, and only after counting every contact. On a 4,599 contact app the field called Dropdown was populated on all 4,599 and the Google Analytics fields held 241; a 200 contact sample undercounted those by 2.6 times. Never delete a field holding tracking data; map onto it. Export first, and the customer does the deleting.
Why does the create response not show my group_id?
Because the v1 create does not echo it, even when it honoured it. We sent group_id: 38, got a 201 with no group in the body, and found the field in group 38 on the v2 model read. The v2 create does echo it. Either way the create response is not the proof; the model read back is.
A FULL APP IS A MATURE APP

Years of tools each leaving a field behind is what a Keap that has paid for itself looks like. We have been making room in those apps since 2010, and we count before we recommend. If your next integration just came back with a 400, that is a conversation worth having before anything gets deleted.

Book a Free Consult
Keap revenue and reporting, from the API up
04Keap custom fields: the ceiling, the tab you cannot move, the name you cannot get back· you are here