Contacts
A contact is a person, and the unit that logs in: one login identity, one email, one role held inside its organization.
Two people at the same company are two contacts sharing an organization_id. A shared purchasing mailbox is one contact, not several. A contact with no organization is a standalone buyer, not an error.
curl "https://api.revenexx.com/v1/customers/contacts?limit=50" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..."
Listing and paging
Every column is an exact-match query filter (email, organization_id, registration_status, external_id, …). The list pages with limit (default 50, maximum 200) and offset, and sorts by one column with order=<column>.asc or .desc. The answer carries items and a page object with total (every row the filter matches), returned, offset and hasMore; page on until page.hasMore is false.
There is no updated_since filter and no range filter on any column. updated_at and source_synced_at match a timestamp exactly. To read what changed, sort with order=updated_at.desc and page until you reach a row you have already seen, or follow the contact.* events instead (as of 2026-10).
The list also filters on these columns, each an exact match (timestamps match exactly, too):
| Filter | What it matches |
|---|---|
shop_access | Whether the person can buy in the shop: can_order, invited, pending, blocked or none. |
access_blocked_reason | Why the shop access was revoked, in the tenant's default language. |
invited_at | When the last shop invitation was sent. |
lifecycle_stage | Where the person stands in the sales and marketing funnel: one of the tenant's contact lifecycle stages (GET /v1/customers/contact-lifecycle-stages). |
lifecycle_changed_at | When lifecycle_stage last changed. |
owner_id | The code of the sales rep who owns the person. |
lead_source | How the person first came: form, campaign, import, trade_fair, newsletter, referral or manual. |
lead_source_ref | What exactly they came through, as free text, such as a campaign id or a fair code. |
lead_score | A whole number from 0, higher is warmer. Null until something scores the person. |
consent_marketing | Whether the person may be sent marketing email. |
consent_marketing_synced_at | When the consent sync last changed consent_marketing. Null while the sync never touched the row. |
The row
| Field | Meaning |
|---|---|
email | Login identity and the unique key of a person within the tenant. Changing it changes the login with it. |
organization_id | The company. Null is legitimate — a standalone buyer. Deleting the organization moves its contacts to the trash with it; restoring the organization brings them back. |
role | The person's role inside their organization, and the only thing permissions derive from. One of the tenant's own roles. |
status | invited (created, has not accepted), active (works), blocked (cannot log in). A create through the API defaults to invited; a self-registration in an open store lands active. |
registration_status | pending, approved, rejected. Only the approve/reject routes move it — it is ignored on an ordinary update. |
is_primary | The main contact of its organization. At most one per company is the intent; the tenant's primary_contact_required setting decides whether the last one may be demoted or deleted. |
job_title | Free text on purpose. It is a title, not a grant — overloading it with authority silently un-grants everyone the day the ledger is enforced. |
order_approval_mode | How far the orders.approve permission reaches for this person: none (no order, the default), limited (up to order_approval_limit) or unlimited. The grant comes from the role, the reach from here. Stored, but no app enforces it as a cap today (as of 2026-10): the Orders approve route ignores it, so a contact whose role holds orders.approve can approve whatever its mode is, none included. |
order_approval_limit | Amount ceiling in the market's currency. Set exactly when order_approval_mode is limited, and then above 0; null for every other mode, so read the mode, not the null, to know whether there is a ceiling. A contact written before the mode existed reads none, with its old amount still set if it had one, until a write sends either field. The effective grants already read such an amount as limited. An amount, never a grant. A pair that does not fit is refused with 400 (limited_mode_requires_limit, limit_requires_limited_mode, limit_not_positive, invalid_mode), as on organizations. |
locale | BCP 47, one of the store's configured locales. Null falls back to the store default. |
first_name, last_name, phone | Optional — an ERP import often has only a mailbox. phone is free text; send E.164. |
external_user_id | The platform user this contact is mirrored as — the account that holds the password and the sessions. Written by the platform, ignored on any write you send. |
external_id | The person's id in the system the record came from — an ERP contact number or a CRM id. Writable on create and update, optional, and unique per tenant while set, so a repeated import finds the row it wrote last time. A contact created in the shop has none. Filter with GET /v1/customers/contacts?external_id=. Not the same as external_user_id: that one points at the platform account, this one points out of the platform. |
external_refs | Every other system that knows this person, keyed by system name, such as {"crm": "CT-90210"}. Not a query filter: look the row up by external_id and read this off the answer. |
source_synced_at | When the source system last confirmed the row. An edit in Cockpit does not touch it. Null for a row no source owns. |
A buyer may edit their own contact (PUT /v1/customers/contacts/{id}), except the fields the merchant keeps, their own role among them: changing one gets 403 (field_merchant_owned). On a colleague's contact, a buyer may change only the role, and needs the contacts.manage grant for it. The checks run in this order: a colleague outside the buyer's company, or a buyer without the grant, gets 403 (not_own_record); then any field besides role gets 403 (field_merchant_owned); then a role wider than the buyer's own, or a colleague who holds more than the buyer, gets 403 (role_exceeds_own). Only an edit that passes those checks meets self_service_master_data: set to addresses_only or none, it gets 403 (self_service_closed), on the buyer's own contact and on a colleague's alike. A call acts for a buyer when the storefront makes it on a signed-in buyer's behalf; a call that does not (an operator, an import, an ERP) is not refused for this reason.
A contact's own postal addresses are created on POST /v1/customers/addresses, described in Addresses on the Organizations page.
Creating one
curl -X POST https://api.revenexx.com/v1/customers/contacts \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{
"email": "einkauf@example.com",
"organization_id": "…",
"role": "buyer",
"first_name": "Anna",
"last_name": "Berger"
}'
email is the only field a create cannot omit. The create makes the person and their login together, so a contact created through this API can always sign in. registration_status may only be set to pending or approved here — a rejection has to carry a reason, and that is the reject route's job. A buyer acting for themselves gets 403 (self_service_closed) when they add a colleague under self_service_master_data set to addresses_only or none; a buyer without the contacts.manage grant gets not_self_service, and one naming another company's organization_id gets not_own_record, first, whatever the setting says. Inviting a colleague who already exists (POST /v1/customers/contacts/{contact_id}/invite) is a login, not master data, and is not refused for this reason.
Two rows of one tenant may not share an email.
A contact that cannot sign in
A contact written straight into the record by a migration or an ERP feed has no login: it reads as a customer everywhere, but the person cannot sign in or recover a password. POST /v1/customers/contacts/{contact_id}/identity creates the missing login and links it.
curl -X POST https://api.revenexx.com/v1/customers/contacts/{contact_id}/identity \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"created_by": "erp-import-repair"}'
- Safe to re-run. A contact that already has a login is answered with it,
createdisfalse, and nothing changes. One call per imported contact heals a whole import. - No password, no message. The call sends nothing. Invite the person (
POST /v1/customers/contacts/{contact_id}/invite; its optionalurlsets where the message points and must be on one of the shop's link origins, else422 link_origin_not_allowed; left out, the shop's first link origin is used), or point them at password recovery (POST /v1/customers/auth/recovery) to set their own password. - It keeps refusals in place. A blocked contact, or one whose registration is pending or rejected, gets its login created disabled.
409means a login already exists for that address but is linked to no contact, typically left over from a create that failed halfway. The call does not guess whether to link that login or remove it: that is your decision.
The activity timeline
contact_events is one entry on a customer's timeline: an activity somebody logged, or a registration decision the app recorded itself. Append-only — there is no update and no delete, which is what makes it usable as evidence. The one exception is erasing the person, which removes their timeline with them.
curl -X POST "https://api.revenexx.com/v1/customers/contacts/{contact_id}/events" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{
"kind": "call",
"subject": "Discussed Q3 framework agreement",
"occurred_at": "2026-08-07T09:15:00Z",
"actor": "sales@merchant.example",
"payload": { "note": "Wants a second delivery address for the Leipzig site." }
}'
| Field | Meaning |
|---|---|
kind | One of the tenant's own activity types, seeded with note, call, email, meeting, visit, task. |
subject | The one line a person scans. Required for an activity. |
occurred_at | When it actually happened, which is not when it was written down. Defaults to now. |
actor | Who logged it — free text as the client supplied it. Null for a row the app wrote itself. |
payload | Machine-readable body. activity.<kind> carries { note }. |
name | Set by the app: activity.<kind>, or registration.submitted / .approved / .rejected. |
organization_id | Derived from the contact, never taken from the body — so an activity cannot be filed under a company the person does not belong to. |
kind: "system" is reserved. Those rows are the app's own registration decision trail and no caller may file one.Read the feed with the column the question needs:
curl "https://api.revenexx.com/v1/customers/contact_events?organization_id=…&order=occurred_at.desc&limit=50" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..."
Filter by contact_id for one person, organization_id for a whole company, kind for one type. Newest first is order=occurred_at.desc. The list also filters on assignee (the name the caller gave, exact match), and on due_at and done_at (exact timestamps only). A follow-up is a timeline entry with a due date; done_at stays null while it is open.
Read one entry by its id with GET /v1/customers/contact_events/{id}. It answers the entry as it was recorded; entries are never rewritten, apart from the done_at stamp a follow-up gets when it is closed. An unknown id is a 404.
Where documents go (contact points)
A contact point says where one kind of document goes for one company: the invoice to accounts payable, the order confirmation to the buyer who placed it, the shipping notice to goods-in, the dunning letter to whoever settles it. One point per company and kind is in use, flagged is_primary. The kinds come from recipient types, at GET /v1/customers/contact-point-kinds. Note the spelling: the kinds route is contact-point-kinds with hyphens, the contact points route is contact_points with an underscore.
| Route | What it does |
|---|---|
GET /v1/customers/contact_points | Lists the points. Filter by organization_id, kind or is_primary. ?organization_id=…&kind=invoice&is_primary=true answers the one point a company's invoice goes to. |
POST /v1/customers/contact_points | Adds a point. organization_id and kind are required, and it needs an email or a phone. The first point of a kind is in use on its own. A later one takes over only when sent as is_primary, which moves the flag off the previous holder. A buyer acting for themselves gets 403 (self_service_closed) under self_service_master_data set to addresses_only or none; a call that does not act for a buyer is not refused for this reason. |
GET /v1/customers/contact_points/{id} | Reads one point. |
PUT /v1/customers/contact_points/{id} | A partial update. The owning company cannot change, and an empty body is refused. To move the flag, send is_primary on the point that should take over; is_primary: false on the point in use is a 400. Changing kind on the point in use does not hand its flag on: the old kind has no point in use until you flag another one, and a new kind that already has one answers 409. A buyer acting for themselves gets 403 (self_service_closed) under self_service_master_data set to addresses_only or none; a call that does not act for a buyer is not refused for this reason. |
DELETE /v1/customers/contact_points/{id} | Removes the point. If it was the one in use, the next point of that kind takes over. A buyer acting for themselves gets 403 (self_service_closed) under self_service_master_data set to addresses_only or none; a call that does not act for a buyer is not refused for this reason. |
When a buyer creates, changes or deletes a point that is not theirs, the call is refused with 403 (not_own_record). On an attributed call, changing a point of their own company also needs the organization.manage grant (Give buyer staff the right permissions); without it, or while the buyer's contact is still invited and has never signed in, the call is refused with 403 (not_self_service). An unknown id is a 404.
Those two checks come first on all three write routes (POST, PUT and DELETE). self_service_closed is the answer only for a buyer who owns the point and holds the grant, so a buyer without organization.manage gets not_self_service whatever the setting says.
Registration approval
The registration_mode setting decides what a self-registration is. It is a per-market setting in Cockpit, and a market that sets none follows the tenant default, so two markets of one tenant can differ. When it is approval_required, POST /v1/customers/auth/register creates an application: registration_status: "pending", status: "invited", the login exists with the applicant's own password but is disabled, and a newly founded organization is parked as blocked.
Two routes decide it.
Approve
curl -X POST "https://api.revenexx.com/v1/customers/contacts/{contact_id}/registration/approve" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"decided_by":"sales@merchant.example"}'
Enables the login first — the password the applicant chose at submit time works immediately, no new credential is issued — then sets registration_status: "approved" and status: "active", and un-blocks the organization the registration itself founded. Writes a registration.approved timeline row and publishes contact.registration_approved.
The app itself sends the buyer no mail on approve or reject (as of 2026-10). A decision writes the timeline row, which publishes contact_event.created, and the contact.registration_* event. Telling the buyer is the job of whatever listens to those: a mail template in your tenant, or your own system through a webhook. Whether your tenant has such a template is not something the app decides. The registration_notification_recipients setting does not reach the buyer either: it lists your staff addresses, attached to the timeline row so the approval queue reaches the people who decide.
Reachable from pending or rejected: approving a rejection reinstates it. Approving an already-approved registration is a no-op, so a retry is safe.
Reject
curl -X POST "https://api.revenexx.com/v1/customers/contacts/{contact_id}/registration/reject" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"reason":"No verifiable VAT ID supplied.","decided_by":"sales@merchant.example"}'
Only reachable from pending. Sets registration_status: "rejected" and status: "blocked", and keeps the login in place but disabled — the email must not fall free for a silent second identity, and the merchant keeps the record. Delete the contact to send it to the trash with its login switched off; a second delete, emptying the trash or erasing the person removes both for good.
reason is mandatory. It is stored on the contact and carried in the event payload. Whether the applicant is ever told it is the tenant's registration_reason_disclosed setting, because that is a legal decision and not a template one.
Rejecting an already-rejected registration is a no-op. An approved registration cannot be rejected (409): blocking a live customer is a block, not a registration decision.
What the rejected buyer sees. A sign-in with the correct password answers 403 with code: "registration_rejected". An undecided application answers 403 with code: "registration_pending", and a wrong password stays 401. The reason is in the refusal's details.reason only when registration_reason_disclosed is on, and then the timeline row marks it disclosed. With it off (the default) the reason stays on the contact and the sign-in carries none. The contact.registration_rejected event always carries reason and a reason_disclosed flag, so a mail built from it has to honour the flag.
Account actions
These calls change a person's shop access, funnel stage or owner. Each row names what its timeline entry carries. The fields they change are the ones in the field table above: shop_access and access_blocked_reason follow from the calls below. shop_access is derived on every write and is never taken from a body. A revoked person reads shop_access: blocked with the reason in access_blocked_reason. A restored one reads can_order, unless the application is still undecided, which reads pending. The record, the role and the history stay. Only status, the login and access_blocked_reason change, and only for revoke and restore.
| Operation | What it does | Body |
|---|---|---|
POST /v1/customers/contacts/{contact_id}/revoke-access | Switches the login off, sets status to blocked with the reason, and publishes contact.blocked, as an edit to blocked does. Sets access_blocked_reason and adds a timeline entry. 409 already_blocked when access is already revoked. A contact in the trash answers 404. | reason string, required, one of left_company, misuse, customer_request, other. note string or null, optional. actor string or null, optional: who revoked it. |
POST /v1/customers/contacts/{contact_id}/restore-access | Sets status back to active, clears access_blocked_reason and switches the login on again. Its timeline entry carries no reason. A person who is an application nobody has decided on keeps the login off until the approval. 409 not_blocked when access is not revoked. | actor string or null, optional: who gave it back. |
POST /v1/customers/contacts/{contact_id}/reinvite | Sends the same shop invitation again, for a person who has not accepted the first one (shop_access: invited). invited_at moves to the new send. It writes no timeline entry. Without url, the shop's first link origin is used. With no link origin configured, a call without url answers 400. A url on an origin that the shop's link origins do not name answers 422 link_origin_not_allowed. 409 not_invited for any other access. | url string (uri), optional: where the invitation points. |
POST /v1/customers/contacts/{contact_id}/handover | Names the sales rep who owns the person (owner_id) and files a task for that rep on the timeline, the "hand over to sales" step. The stage does not change: accepting the lead is the rep's own step. Where the sales-reps app is installed, an unknown rep code answers 400 unknown_owner. Answers 201 with the contact and the task. | owner_id string, required: the rep's code. subject string or null, optional: the task's line. note string or null, optional. due_at date-time or null, optional. actor string or null, optional. |
POST /v1/customers/contacts/{contact_id}/lifecycle | Moves the person to another funnel stage (lifecycle_stage) and publishes contact.lifecycle_changed once. It writes no timeline entry; the reason and actor ride on the event only. Repeating the stage the person already holds changes nothing and answers changed: false. A person with no lead source takes lead_source and lead_source_ref; one that has a source keeps it. A stage set inactive answers 409 value_inactive. | stage string, required: a code from GET /v1/customers/contact-lifecycle-stages. reason string or null, optional. actor string or null, optional. lead_source string, optional, one of form, campaign, import, trade_fair, newsletter, referral, manual. lead_source_ref string or null, optional. |
POST /v1/customers/contact_events/{id}/done | Marks a follow-up done: a task, or any entry with a due date. done_at is set and the entry leaves the open-task lists. It stays on the timeline as it was filed, and a second call answers it as it is. 409 not_a_follow_up for an entry that is not one. | None. |
Contact lifecycle stages. The stage codes come from GET /v1/customers/contact-lifecycle-stages. GET /v1/customers/contact-lifecycle-stages/{id} reads one value. PUT changes its titles, help text, badge tone, position and default, and can retire it with active: false; the code cannot change. PUT with active: false retires a stage even while records carry it: they keep it, and pickers stop offering it. DELETE is the step that needs the stage unused: it answers 409 value_in_use while any record carries the code, and deleting the last stage answers a plain 409 with no code. The value lists page covers the full set of value endpoints.
Marketing consent. consent_marketing mirrors the consent app: true while the person holds at least one confirmed email subscription there. POST /v1/customers/consent-mirror/run reads the email subscriptions that changed in the last three hours and sets each person's flag to the consent app's current answer. The hourly schedule runs the same code, and so does each subscription event. It takes no body.
Trash and restore
DELETE /v1/customers/contacts/{id} moves a contact to the trash. The person's login is switched off and their sessions end, but the login is kept, so a restore needs no new invitation. It is refused with 409 (trash_blocked) while the person is the approver on a pending approval, and with 409 (primary_contact_required) while the person is the only primary contact of a company that has other people and the tenant requires a primary contact. A second DELETE on the same id deletes the contact for good, with its platform user. That is refused with 409 (delete_blocked) while another app still holds the person, such as an open order or quote, and the same primary-contact rule applies while the company is live. Deleting for good is an administrator's: a buyer's call gets 403 (purge_not_allowed).
A contact in the trash comes back with POST /v1/customers/contacts/{id}/restore, or is deleted for good by POST /v1/customers/contacts/trash/empty. Emptying the trash runs the same checks on each contact as a second DELETE, but a contact still blocked stays in the trash and is listed with its reason in the response, instead of the whole call answering 409. Restore needs the same right as the delete.
- Restore switches the login back on, unless the record says no: a blocked contact, or one whose registration is undecided, stays off.
- Refused with
409,parent_in_trash, while its organization is in the trash. The error'sdetails.restorenames the organization's restore route. Restoring the organization brings its contacts back with it. 409,not_in_trash, when the contact is not in the trash.404when no contact has that id.- Emptying the trash deletes the contacts in the trash for good, oldest first, at most 50 per call. Each one is checked on its own, as a second DELETE would be. A contact that something still blocks stays in the trash and is listed in
blockedwith the reason. Call again whilemoreistrue. Emptying the trash is an administrator's right: a buyer's call is refused with403,purge_not_allowed.
Reading and filtering the trash. GET /v1/customers/contacts takes trashed: exclude (the default) leaves trashed contacts out, only returns the trash alone and include returns the live and the trashed contacts together. Each contact carries the fields below, and each one is also an exact-match filter on the list call. A call that acts for a buyer always behaves as exclude, whatever trashed says. deleted_at and purge_at match only an exact timestamp; there is no range filter, so to see what was trashed in a period, sort with order and page.
| Field | What it holds |
|---|---|
deleted_at | When the contact went to the trash; null while it is live. |
deleted_by | Who moved it there: an e-mail, or system. A name for the trash view, not an identity to check. |
deleted_by_id | The platform subject of that person, the stable id a privacy request pseudonymises. Null for the system. |
purge_at | When the daily purge deletes it for good: deleted_at plus the tenant's trash retention (90 days unless the tenant says otherwise). |
trash_batch | The trash action it went with. An organization and the contacts it took along share one, and a restore brings back exactly the records that carry it. |
purge_blocked_reason | Why the purge could not delete it for good, in a sentence; null when nothing held it. Cleared by a restore. |
The platform sets these fields when a contact is deleted and clears them when it is restored. They are information to read, so do not send them in a write.
The first delete fires contact.trashed and a restore contact.restored. contact.deleted fires only once the record is deleted for good. See Deletes and the trash.
Erase personal data
POST /v1/customers/contacts/{id}/erase answers a person's erasure request under Art. 17 GDPR. It skips the trash: the contact is deleted for good at once, in the trash or not, together with its platform user, its addresses and its timeline.
- The same checks as a final delete apply. Something still holding the person answers
409,delete_blocked, anddetails.blockerscounts it. An open order keeps its buyer until it is closed. Being the only primary contact of an organization that requires one is also refused with409. - Documents already issued keep their own snapshot in the apps that hold them.
history_pseudonymisedin the answer says whether the person was also pseudonymised in the record history. It isfalse, because this call does not do that yet (as of 2026-10).- Merchants only. A buyer's call is refused with
403,purge_not_allowed: an erasure request is carried out by the merchant.
Where to go next
- Organizations — the company a contact belongs to.
- Roles and permissions — what
rolegrants. - Storefront accounts — where a registration comes from.
- Value lists — the activity types
kinddraws on.