Upsert a customer
Enrolls a new customer on a specific card template, or upserts an existing one.
Dedupe & upsert
The unique key is scoped to the card_id. Resolution order:
external_id(if provided)email(case-insensitive)phone(E.164 normalized)
If any match is found, the request UPDATES the existing customer. Otherwise a NEW customer is created.
Wallet delivery
Optional deliver object sends the wallet pass link via email or SMS on creation (e.g., { "channel": "email" }).
Without deliver, the response carries claim_url and you deliver it yourself.
Idempotency
Send an Idempotency-Key header to make retries safe. See Idempotency.
Required fields from the card form
The card form_builder.fields[] decides what this endpoint demands. A field marked required: true must be present and non-empty, and a PATCH may not clear it to empty. This holds whether or not the public signup page is enabled: form_builder.enabled governs that page, never this endpoint.
Enforcement covers the five system fields this request carries: email, first_name, last_name, phone, date_of_birth. Custom fields are enforced through the custom object.
The other two system keys are the exception. consent and preferred_language are collected by the public signup page ONLY and are never required here: this request declares no consent property, and language always resolves to the card default_language, so neither can ever be absent. Marking either required on a card changes nothing about this endpoint, and an API-created customer records no marketing consent from a form consent checkbox. Send marketing_opt_in if you want to record consent deliberately.
Common failures
422 invalid_formatonphone- supply E.164 (+14155552671)404 resource_missing(paramcard_id) - the card does not exist, is archived, or does not belong to your organization422 unknown_custom_field- a key incustomthat the card’s form does not define409 external_id_conflict- another customer on this card already has thatexternal_id403 plan_limit_reached- the plan’s customer limit is already in use. Only a new customer counts against it: matching an existing customer is never blocked422 unsupported_card_type-card_idpoints to agift_cardormembershipcard; those types are dashboard-managed and don’t take enrollments through this endpoint
Authorizations
Your secret API key as a Bearer token: Authorization: Bearer vxn_sk_.... Keys are created and scoped in the dashboard - see Authentication.
Headers
Replay-safe key for this POST. Caching window is 24h: an identical body with the same key returns the original response without side effects; a different body with the same key returns 409 idempotency_key_in_use. UUID v4 is recommended; any 8-255 char string from the allowed alphabet works.
8 - 255^[A-Za-z0-9_-]{8,255}$One of en, sk, cs, de. Defaults to en.
en, sk, cs, de Body
- Option 1
- Option 2
- Option 3
POST /v1/customers REQUIRES card_id. There is no /v1/cards/{id}/issue verb. Must send at least one of external_id, email, phone.
Validation: card_id is required AND at least one of (email, phone, external_id) must be present for customer deduplication.
REQUIRED. The card template (crd_) the customer is enrolled in. Immutable after creation. Returns 404 if the card doesn't exist or is archived.
gift_card and membership cards do not take new customers here. They are managed in the dashboard: enrolling on either returns 422 unsupported_card_type. Stamp, reward, discount, coupon and custom cards are fully supported; reading is unaffected for every card type.
Email address. At least one of email, phone or external_id is required. Used for wallet pass email delivery and dedupe.
255"customer@example.com"
Your stable reference for this customer on this card (CRM ID, internal account number, etc.). Used for dedupe on re-POST. Highest priority dedupe field.
Contact phone. Accepted in any common written form - +421908123456, +421 908 123 456, +421-908-123-456, 00421908123456, or a bare Slovak local number like 0908123456. The platform canonicalises it to E.164 on write, and every read returns strict E.164 (^\\+[1-9]\\d{1,14}$) or null. A value that cannot be resolved without guessing a country code is rejected with 422 invalid_format rather than stored and silently reported as null.
8 - 16^\+?[0-9][0-9\s().\-]{6,24}$"+14155552671"
Customer's given name. Strongly recommended - the wallet pass renders this in the primary slot. When omitted, the pass shows blank where the name should be. You can update it later.
1 - 50"Tomáš"
Customer's family name. Shown on the wallet pass alongside first_name when configured.
50Date of birth in ISO 8601 format (YYYY-MM-DD). Used for birthday campaigns and age-gated rewards.
Preferred display language for the wallet pass and notifications. One of sk, cs, en, de. Defaults to the card's default_language.
sk, cs, en, de The location where the customer signed up (QR scan location, in-store signup, and so on). Set on creation and cannot be changed afterwards. Required only if your card uses location-scoped settings.
A record of the customer's marketing consent, stored and returned for your own use and audits. Defaults to false when omitted. VEXiON does not apply it when sending notifications: a notification sent through the API is not filtered by this flag or by the customer's marketing switch in the dashboard. To respect opt-outs on API sends, send only to customers with marketing_opt_in true, using explicit customer_ids.
Schema-validated custom field values. Keys must match the card's form_builder fields (type='custom' fields). Values validate against the referenced custom_field's type. Returns 422 if a required custom field is missing or a value fails validation.
Free-form key-value map for partner-internal IDs and integration markers. NOT rendered in pass_fields templates. Shape IS enforced (422 validation_failed): max 50 keys, key must match ^[a-zA-Z0-9_]{1,40}$, values must be strings of at most 500 chars. The platform never interprets the contents.
OPTIONAL per-customer override of the card's default initial state (welcome bonus stamps, starting points, etc.). When omitted, the customer is enrolled with the card's configured defaults. Server validates each field matches the card's type and returns 422 with param='initial_state.' if mismatched.
Honoured on creation only. When the call matches an existing customer (200), initial_state is ignored and no balance changes. A starting balance is not a transaction: it creates no ledger entry, sends no transaction.created event and does not count toward level spend. field_errors[].code is invalid_for_card_type (the field does not fit the card), out_of_range (above the card size, or initial_state.points above the starting bonus limit of the card: points 100,000, cashback about EUR 50 of credit in your currency, never above 1,000,000,000) or prepaid_starts_empty.
Triggers immediate email/SMS pass delivery on successful creation.
Accepts either form:
true- deliver over the best channel the customer's own contact details allow: email when anemailwas supplied, otherwise SMS when aphonewas supplied. Email wins when both are present, because an SMS spends money you did not explicitly ask to spend.- an object - explicit control:
channelpicks the transport,localeoverrides the message language.
When omitted or false, the customer is created but the pass is NOT auto-delivered (call POST /v1/customers/{id}/deliver separately, or share the claim_url manually).
deliver: true on a customer with neither an email nor a phone is rejected with 422 contact_missing_for_channel and the customer is NOT created, because there is no channel to pick. That case is reachable: external_id alone is a valid identifying field. The object form is not subject to that guard.
Honoured on creation only. When this call matches an existing customer and returns 200 instead of 201, deliver is ignored: use POST /v1/customers/{id}/deliver to (re)send to an existing customer.
Delivery is best-effort: the customer is still created (201) if the send itself fails, and the outcome is recorded on last_delivery.
Response
Existing customer matched on dedupe key.
Customer identifier - an opaque, server-issued token prefixed cus_. Encodes the internal record through a keyed cipher (AES-128 + an integrity tag, base32-encoded) - not decodable or guessable client-side; the prefix lets clients route on resource type without parsing the ID.
^cus_[A-Za-z0-9]+$"cus_g2y5z2ylfkrybsthe2qnweualy"
Always 'customer'. Type discriminator for consumers that handle multiple resource families.
"customer"Card template (crd_) this customer is enrolled in. Immutable.
Your stable reference for this customer (CRM ID, internal account number, etc.). Highest-priority dedupe field on POST.
Customer's email address. Used for wallet pass delivery, account recovery, and dedupe.
255"customer@example.com"
Customer's phone number in E.164 format. Used for SMS pass delivery and dedupe.
8 - 16^\+[1-9]\d{1,14}$"+14155552671"
Given name. Shown on the wallet pass when configured.
Family name. Shown on the wallet pass when configured.
Customer's date of birth in ISO 8601 format (YYYY-MM-DD). Used for birthday campaigns and age-gated rewards.
Preferred display language for the wallet pass and notifications. Writable values are sk, cs, en, de; reads may additionally return hu, hr, or ro for legacy customers enrolled before the language set was narrowed.
sk, cs, en, de, hu, hr, ro A record of the customer's marketing consent, stored and returned for your own use and audits. VEXiON does not apply it when sending notifications: a notification sent through the API is not filtered by this flag or by the customer's marketing switch in the dashboard. To respect opt-outs on API sends, send only to customers with marketing_opt_in true, using explicit customer_ids.
Unix timestamp when the customer requested GDPR right-to-be-forgotten. When set, all PII fields are anonymized but the skeleton record is preserved for analytics integrity. Distinct from deleted_at (soft-delete from admin or DELETE /v1/customers/{id}).
null
Echo of the parent card's type for routing convenience. Mirrors card.type.
stamp, reward, discount, coupon, membership, gift_card, custom Type-specific mechanics data (stamps collected, points balance, tier, gift-card balance, etc.). Shape varies per card_type.
Location whose signup QR/link enrolled this customer (?loc= on the signup URL). Null for customers created via API or dashboard - only public signup-form enrollments carry it.
Wallet pass install state on the customer's device. installed = pass on at least one device, not_installed = signed up but pass not yet added, deleted = customer removed the pass, disputed = wallet flagged the pass. A customer with no stored status reads not_installed; the value is never null.
installed, not_installed, deleted, disputed, null Wallet platform holding the pass: apple_wallet or google_wallet. Null when no wallet pass is installed. The platform tracks Apple and Google Wallet only - any other install channel reads as null.
apple_wallet, google_wallet, null Unix seconds when the pass was FIRST added to a wallet (Apple/Google install callback). Null until installed. Distinct from created_at (when the customer record was created at signup) - the gap between the two is your activation funnel.
1779494400
Direct download URL for the .pkpass file. Sent to iPhone customers via SMS or email.
Save-to-Google-Wallet URL for this customer's card, or null if not yet generated. Generated lazily: null on a freshly created customer, and populated on the first GET /v1/customers/{id} for that customer (not on create, and not on the list endpoint) - persisted after the first successful generation, so later reads return the same URL without regenerating it. A transient generation failure leaves this null; retry by reading the customer again.
Public claim URL the customer visits to add the pass to their wallet. Auto-detects device and serves the right format. Treat the URL as opaque - never construct or parse it; its internal format may change.
The value encoded in the customer's wallet pass QR code or barcode, and the number printed on physical cards. A merchant-assigned custom number when serial_number_locked is true; otherwise a platform-assigned 24-character hex value, or a referral link ending in it when the card has referrals enabled. NOT the customer id: to find the customer behind a scanned code, call GET /v1/customers?serial_number=<scanned value>.
True when the serial number was custom-set by staff (do not regenerate). False when auto-generated.
Custom field VALUES for this customer, keyed by the definition's key. Validated against the card's form and the cf_ definitions - unknown keys are rejected (422 unknown_custom_field).
Value shapes by field type: text/textarea → string; number → number (integer when the definition says so); currency → integer minor units; date → YYYY / YYYY-MM / YYYY-MM-DD per granularity; boolean → true/false; select → one option value; multiselect → array of option values.
Renderable everywhere via {{custom.<key>}}: pass slots, notification title/body - typed values format in the customer's locale.
Partner-internal {key: value} map (max 50 keys, key ^[a-zA-Z0-9_]{1,40}$, string values ≤500 chars). NEVER rendered to the customer - for CRM ids, import batches, integration markers. Customer-facing typed data belongs in custom (validated against custom field definitions, renderable on passes/notifications).
Update semantics (JSON Merge Patch): PATCH merges per key - send only the keys you change; "key": null deletes that key; "metadata": null clears the whole map; omitting metadata leaves it untouched.
Most recent email/SMS delivery attempt for this customer's wallet pass. Null if never delivered via API. Contains channel, destination (redacted email/phone), status, sent_at timestamp, and error message if delivery failed.
Referral program statistics for this customer. Null when the card template has no referral program configured. All counts are non-negative integers.
Unix timestamp when the customer record was created (signup time).
Unix timestamp when any field on the customer was last modified.
Unix seconds of the customer's most recent confirmed transaction - ANY channel (POS, scanner, API, online). Stored denormalized for fast filtering; equals the newest ledger entry (see GET /v1/transactions), except that gift card lock, employee refill and merchant issue rows do not move it.
1782604800
Unix seconds until which the customer keeps the current discount or reward tier (level retention, mechanics.tier_demotion). It is the end of the Bratislava calendar day: a qualifying transaction on or before that day keeps the level, and the first daily run after it moves the customer down one tier. Null when level retention is off on the card, the customer is on the lowest tier, the card cannot use level retention (employee discount, Booqme or Bookio card, flat reward, fewer than 2 tiers), or the card entry is deleted or disputed. Reminders go out 14 and 3 days before this date.
1807833599
Location of the most recent confirmed transaction - ANY channel. Stored denormalized for fast filtering; equals the newest ledger entry (see GET /v1/transactions), except that gift card lock, employee refill and merchant issue rows do not move it.
"loc_g2f2cbt7cbuz3hambazorvws2q"
Unix seconds when the customer was soft-deleted (DELETE /v1/customers/{id} or dashboard). Null while active. Soft-deleted customers keep their FULL transaction ledger - history is never destroyed. Distinct from gdpr_redacted_at (right-to-be-forgotten anonymization).