Skip to main content
A custom card is the blank-slate card type. It carries no loyalty mechanics at all: no stamps, no points, no discount tiers, no stored balance. You decide what it holds by defining custom fields, attaching them to the card’s form, and writing a value for each field on every customer. Use it when your data is not a loyalty balance. A member record, a patient record, a ticket, a service history, a white-label pass you drive from your own system.

What a custom card is not

Three properties follow from having no mechanics. Plan around all three before you choose this type.
Because nothing is derived from mechanics, pass_fields is required on a custom card. It is the only thing that decides what the pass shows. Omitting it returns 400 parameter_missing naming pass_fields.
Broadcasts reach custom cards like every other card type, so POST /v1/notifications works normally. See Webhooks for the delivery events.

The seven card types

POST /v1/cards accepts five values for type: stamp, reward, discount, coupon, custom. membership and gift_card are dashboard-managed, so sending either fails schema validation as 422 validation_failed on type. POST /v1/customers enrolls on stamp, reward, discount, coupon and custom. On a membership or gift_card card it returns 422 unsupported_card_type. The dashboard names some variants as their own card. In the API each one is one of the seven types plus a setting: A reward or cashback card has levels when earn_mode is tiered.

How the pieces fit together

Custom fields live at three levels, and each level owns a different thing. There are eight field types: text, textarea, number, currency, date, boolean, select, multiselect. Only select and multiselect take options; only number takes number_format; only date takes date_granularity. Every other combination is normalised away on write.
Give the form_builder entry the same id as the definition’s key. The id is the storage key, the payload key and the template token, so keeping the two identical means one name to remember. Every example on this page does that.

End to end

Set VXN_API_KEY once and every request below runs as written.
1

Define the fields

Four definitions, four field types. Idempotency-Key is required on every POST.
Each returns 201 with the definition. A fresh definition is unattached, so both usage counters start at zero. label and each option label are single strings. If you send a per-language object such as { "en": "Note", "sk": "Poznamka" }, the definition keeps the en value (or the first language) as its one label. Per-language text for each card goes in the form entry’s translations_override.
key is optional. Omit it and the server derives one from the label, suffixing a number if that slug is taken. An explicit key that is already used by an active definition is a 409 key_already_exists instead, never a silent rename. A label that is already used by an active definition returns the same code.
2

Create the custom card

Send mechanics: null, a populated pass_fields, and one form_builder entry per field. form_builder.enabled is set to false here, which turns off the public signup page. Enrollment through this API is unaffected.
The response is 201 with "type": "custom", "mechanics": null, and the form you sent, including all four custom entries. Save the id (crd_...).
mechanics may be null or omitted, never an object. pass_fields is required and every {{custom.<id>}} token in it must name a type: "custom" entry you declared on this same request. A token that names nothing returns 422 unknown_form_field_reference on pass_fields.
3

Enroll a customer with values

Keys of the custom object are the form_builder field ids.
201, and the values come back on the customer in the type of their field: a boolean stays a boolean, a number stays a number, a select stays the option value. A value sent in a look-alike form is converted to the field type. For example, the string "42.00" on a number field is stored as the number 42, and "true" on a boolean field is stored as true.
4

Read it back

The custom object round-trips unchanged. It is also carried on the list endpoint, so you can page through every holder and read their values without a request per customer:
5

Update one field, clear another

PATCH merges. Named keys are replaced, omitted keys are untouched, and a key set to null is deleted.
Afterwards the customer reads:
member_size changed, member_note is gone, and member_vip and member_visits were never mentioned so they stand.
Required-ness is judged against the merged result, not against the patch. Updating one field never re-demands another field the customer already has.
6

See what a bad value does

A value outside the definition’s rules is 422 validation_failed with the offending key named in field_errors[]. The per-field code is invalid_value, and the message names the rule that failed.
A key the card’s form does not declare is a different error, and it is top level rather than inside field_errors[]:

Validation, by field type

Validation is driven entirely by field_type. There is nothing to configure per field beyond the type and its own options. A value that breaks one of these limits returns 422 validation_failed. Anything beyond them, such as a regex or a cross-field rule, belongs in your own layer before the write.

Showing values on the wallet pass

A custom card has no mechanics-derived template variables, so {{balance}}, {{card.points}} and the rest do not exist here. What you have is the universal customer variables plus your own fields. Use {{custom.<id>}}, where <id> is the id you gave the form_builder entry. Not the custom_field_id, and not the definition’s key unless you kept them the same.
Four rules govern these tokens.
  1. Every token must be declared. A {{custom.<id>}} naming a field the card’s form_builder does not carry is rejected at write time with 422 unknown_form_field_reference on pass_fields. This runs on both POST /v1/cards and PATCH /v1/cards/{id}.
  2. A PATCH resolves against whichever form is authoritative. If your patch resends form_builder, the new field ids are the only legal ones, because a resent form replaces the old one wholesale. If your patch changes only pass_fields, the tokens are checked against the card’s stored fields.
  3. select and multiselect render the label, not the stored value. A customer whose member_size is large sees Large on an English pass and Velka on a Slovak one. Set translations_override on the form entry to change that per card.
  4. An unresolved value renders empty, never as the literal token. Use the default: filter to control what appears instead: {{custom.member_note | default: "None"}}.

Lifecycle

usage_count and cards_using[] on a definition track which cards reference it. Both move when a card write changes the form: creating a card with the field, patching a form to add or drop it, archiving a card, unarchiving one, and deleting one. A field listed twice on the same form counts once. Those two counters are what the guard rails read. To free a definition, detach it first. PATCH /v1/cards/{card_id} with a form_builder.fields[] array that omits the entry, then delete the definition. Deleting the card itself also frees its definitions. DELETE /v1/cards/{card_id} archives the template, which releases every field the form referenced, and enrolled customers keep their passes. Unarchiving the card takes the references back.

Custom fields on other card types

Nothing on this page is exclusive to custom. A stamp, reward, discount or coupon card attaches definitions the same way and stores values on customers the same way. The only difference is that those types also have mechanics, a balance, and a writable transactions endpoint alongside the custom data.

The public signup form

form_builder.enabled controls one thing: whether the public signup URL is served.
  • enabled: true serves the public signup page for this card.
  • enabled: false disables that page. Customers are enrolled through this API or the dashboard instead.
The flag does not change validation, storage, template rendering, or anything else about custom fields. A card with enabled: false keeps every field you defined on it, and POST /v1/customers accepts values for all of them. Per-field required is a different setting and does apply to the API. A form_builder entry with "required": true must be present and non-empty on POST /v1/customers, and must not be cleared to empty by a PATCH, whether or not the public form is enabled.

Two system fields are the exception

required is enforced on API enrollment for the five system fields the customer request carries. consent and preferred_language are collected by the public signup page and nowhere else. The customer request declares no consent property at all, and language always resolves to the card’s default_language, so neither can ever be absent from a request. Marking either one required on a card changes nothing about POST /v1/customers. One consequence to plan around: an API-created customer records no marketing consent from a form consent checkbox. If you collect consent in your own flow and want it on the record, send marketing_opt_in explicitly. One more thing to know about that flag: marketing_opt_in is a stored record, not a switch. No send reads it today. A broadcast sent through POST /v1/notifications with an API key is not filtered by the flag, and it is not filtered by the customer’s Marketing messages switch in the dashboard either. A broadcast sent from the dashboard, or from an AI assistant through the VEXiON MCP server, skips customers who switched marketing off. If you must honour opt-outs on a POST /v1/notifications send with an API key, keep your own list and send to explicit customer_ids.
Custom fields are unaffected by any of this. A type: "custom" entry marked required: true is enforced on the API exactly as it is on the public form.

Webhooks

Subscribe through POST /v1/webhook_endpoints. Nine events cover this surface, and all of them use the standard envelope with the resource in data.object. See Webhooks for signatures, retries and the full catalog.
Attaching a field to a card moves that definition’s usage_count and cards_using[], but it fires card.updated, not custom_field.updated. Treat those two counters as a snapshot and re-read the definition with GET /v1/custom_fields/{id} before acting on them in a long-running job.

Rate limits

Every bucket below is the standard one for its endpoint. Nothing on this page has a limit of its own. See Rate Limits for headers and 429 handling. The lookup bucket is the only org-scoped one here. Every key in your organization shares that budget, so a second key does not double it. A lookup also counts against the default 60 per minute, per API key.

Scopes

Errors on this path

Where next

Errors

Every code, with the envelope it arrives in.

Webhooks

Signatures, retries, the 22-event catalog.

Rate Limits

Buckets, headers, and handling 429.

Idempotency

Why every POST carries the key.