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.
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.
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:
- Count the model first.
GET /crm/rest/v2/contacts/modelreturns every contact custom field. Know how many exist before you write one. - Refuse without writing when a previously observed ceiling says there is no room. Seen it refuse at 150, model reads 150: do not try again and hope.
- Stop at the first refusal. Hammering the remaining fields against a full app produces nine more 400s and no more fields.
- Report the arithmetic. Fields present, fields refused. A count the customer can act on beats an error they cannot.
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.
// 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.
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.
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.
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:
- Never delete a partially provisioned set to tidy up. Seven of ten exist and the app is full? Leave the seven and create the missing three when there is room.
- Deletion sits only behind a checkbox a human ticked, on disconnect, for fields you created yourself, with a warning that the name does not come back.
- Never delete as a repair flow. No repair needs it, and every one that uses it makes a second problem.
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.
// 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.
- Holds tracking data, a
Ga*family, autm*family, a click id: map onto it. Never delete it. Those 241 contacts get real first touch attribution the day the mapping goes in. Deleted, that history lives in an export file and the contacts are unattributed inside the CRM for ever. - Empty, unused, not one of yours: offer to recycle it. Delete the empty one and create yours under
your own name, so
field_namematches on every account. - Holds any other real data: leave it alone entirely.
Dropdownstays. - Already one of yours: reuse it, and look for it first.
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
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.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.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.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.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.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