What a custom card is not
Three properties follow from having no mechanics. Plan around all three before you choose this type.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.
End to end
SetVXN_API_KEY once and every request below runs as written.
1
Define the fields
Four definitions, four field types. Each returns
Idempotency-Key is required on every POST.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 The response is
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.201 with "type": "custom", "mechanics": null, and the form you sent, including all four custom entries. Save the id (crd_...).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
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.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 A key the card’s form does not declare is a different error, and it is top level rather than inside
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.field_errors[]:Validation, by field type
Validation is driven entirely byfield_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.
- Every token must be declared. A
{{custom.<id>}}naming a field the card’sform_builderdoes not carry is rejected at write time with422 unknown_form_field_referenceonpass_fields. This runs on bothPOST /v1/cardsandPATCH /v1/cards/{id}. - A
PATCHresolves against whichever form is authoritative. If your patch resendsform_builder, the new field ids are the only legal ones, because a resent form replaces the old one wholesale. If your patch changes onlypass_fields, the tokens are checked against the card’s stored fields. selectandmultiselectrender the label, not the stored value. A customer whosemember_sizeislargeseesLargeon an English pass andVelkaon a Slovak one. Settranslations_overrideon the form entry to change that per card.- 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 tocustom. 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: trueserves the public signup page for this card.enabled: falsedisables that page. Customers are enrolled through this API or the dashboard instead.
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 throughPOST /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 and429 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.