Storefront accounts
Five routes under /v1/customers/auth cover the whole buyer account surface a storefront needs. A sixth, handoff, is for trusted servers only and is described below. They are a passthrough: the customers app is the system of record for the contact, and the platform identity service holds the password and the sessions.
secret — treat it as a credential, keep it server-side in an HTTP-only cookie, and never let it reach client JavaScript. /auth/me takes its ids in the body rather than in a browser-facing header for the same reason.Register
curl -X POST https://api.revenexx.com/v1/customers/auth/register \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{
"email": "einkauf@example.com",
"password": "…",
"first_name": "Anna",
"last_name": "Berger",
"locale": "de-DE",
"organization_name": "Beispiel Industrietechnik GmbH"
}'
One call writes the whole buyer: the contact this app owns, and the login behind it.
| Body field | Effect |
|---|---|
email | Becomes the unique key of the contact and its default login (see What a buyer signs in with). A second registration with it is a 409 — including while the first is still waiting for approval. |
organization_name | Founds a new company with this contact as its admin. This is what makes the registration a B2B one. |
organization_id | Joins an existing company — the invite shape. There is no invite token: the call takes the company's id and checks nothing else about who sends it, and the B2B and B2C switches do not apply to a joiner. Treat a company id as something only an invitation should hand out. Without approval the joiner lands active with the market's default role. Under approval_required the joiner is pending like any applicant, and the company is left as it is (as of 2026-10). |
locale | BCP 47, and one of the store's configured locales, or the call is a 400. |
first_name, last_name | Optional. |
email and password are the only required fields; vat_id may also ride along. Whether a VAT id is mandatory and how far it is checked are the market's organization_vat_id_required and vat_id_validation settings. A refused id is a 400 naming vat_id, and nothing is written. With vat_id_validation set to vies, only a definitive "not registered" answer from the EU registry is refused. If the registry cannot be reached, times out or reports the member state unavailable, the registration goes through, and nothing in the response says the id was not checked. Leaving out both organization fields registers a standalone buyer.
The response carries user_id, contact, registration_status and approval_required — not a session. A finished registration still has to log in.
registration_mode decides what happens
registration_mode is a per-market setting in Cockpit, and a market that sets none follows the tenant default.
| Setting | Result |
|---|---|
open (default) | A finished account: registration_status: "approved", status: "active", login works. |
approval_required | 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. |
Check approval_required in the response. When it is true, show a "we will get back to you" screen instead of signing the buyer in. A merchant then decides with approve or reject.
The registration gates are all evaluated before anything is written, and a failure after that point rolls the organization and the contact back together.
Login
curl -X POST https://api.revenexx.com/v1/customers/auth/login \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"identifier":"einkauf@example.com","password":"…"}'
Three things come back, so a storefront learns in one call both that the buyer is signed in and who they are:
| Field | Is |
|---|---|
session | The auth session: $id, userId, secret, expire, provider. |
contact | The customer record behind the login. Null when a login has no contact mirrored against it — treat that as "signed in, but not a customer of this app". |
permissions | The buyer's effective grants, derived at read time. |
Wrong credentials are a 401. A correct password on an undecided application is a 403 with code: "registration_pending", and on a rejected one a 403 with code: "registration_rejected". The rejection reason comes back under details.reason only when the market's registration_reason_disclosed setting is on.
What a buyer signs in with
identifier is one of three kinds, and the API reads the kind from the value: anything with an @ is an email address, only digits is a customer number, anything else is a username.
| Kind | Market setting | Default |
|---|---|---|
| Email address | login_identifier_email | On |
| Username | login_identifier_username | Off |
| Customer number | login_identifier_customer_number | Off |
The merchant decides which kinds the shop accepts, and a buying company can close a kind for its own people but never open one. A kind that is closed is a 403 with code: "identifier_not_offered", answered before the password is checked. Retrying the same kind won't help, so ask the buyer for another one. If every kind is switched off, the email address is accepted anyway.
- A username gives a buyer an account of their own when they have no mailbox, such as a works login. The merchant sets it on the contact. Registration doesn't take one, and the buyer can't change it through self-service.
- A customer number names a company, not a person, so it signs in as that company's primary contact. That makes it a shared account: everyone who knows the password gets the same session, and nobody can tell afterwards who placed an order. Use usernames wherever you can.
An identifier that names nobody, including a customer number whose company has no single primary contact, answers exactly like a wrong password, so the login route can't be used to find out who is a customer.
email still works as a deprecated alias of identifier, so storefronts built against the earlier contract keep working. It is read the same way (an address, a username or a customer number), and it is ignored when identifier is also sent. Recovery, magic link and one-time code are keyed by the email address only, because they send mail.
Two ids to keep straight: session.userId is the platform user, and it is the user_id every other auth route takes. session.$id is the session, and it is the session_id those routes take. Neither is the contact id — that is in contact.
Me
curl -X POST https://api.revenexx.com/v1/customers/auth/me \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"user_id":"…","session_id":"…"}'
The user, the customer record mirrored against it, and the effective grants — in one call. user_id is session.userId from the login.
session_id is optional and changes the question: pass it to ask is this session still alive? (a revoked one is then a 401); omit it to only ask who is this user?
The grants are derived on every call rather than returned from anywhere they could be cached, so a role changed a second ago is already reflected.
Logout
curl -X POST https://api.revenexx.com/v1/customers/auth/logout \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"user_id":"…","session_id":"…"}'
Ends one session: the buyer signs out on this device and stays signed in on the others, because the session id is what is revoked and not the account. The contact row is untouched — signing out is not blocking. If you want the second thing, set status: "blocked" on the contact.
Both ids come from what the login answered, and your server should drop its own cookie whatever this answers: the session is unusable afterwards either way.
Password recovery — two steps
Step one: request the mail
curl -X POST https://api.revenexx.com/v1/customers/auth/recovery \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"email":"einkauf@example.com","url":"https://shop.example.com/reset-password"}'
Both fields are required. The identity service mails a link to url with userId and secret appended as query parameters — those two are exactly what step two takes.
The secret is not in this response — it exists only inside the mailed link, which is the whole point of the two-step shape.
An address nobody holds is deliberately not distinguished from one that exists, so no account-existence check can be built on the answer. Nothing about the contact changes here.
Step two: the browser comes back
curl -X PUT https://api.revenexx.com/v1/customers/auth/recovery \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"user_id":"…","secret":"…","password":"…"}'
The userId and secret the mailed link carried — sent here as user_id and secret — plus the password the buyer just typed. All three are required.
The secret is spent on first use and expires, so a link cannot be replayed — a second attempt with the same one is a 401, not a second password change. The new password is in effect the moment this answers.
Hand a buyer over from another system
POST /v1/customers/auth/handoff signs a buyer in for a trusted app. Punchout is the caller it exists for: an ERP has already authenticated its user and hands that user over, so the buyer has no mailbox to check and no link to click. The route is server-to-server only. A browser or a storefront cannot call it.
curl -X POST https://api.revenexx.com/v1/customers/auth/handoff \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"contact_id":"…","handoff_key":"…"}'
Send handoff_key and exactly one of contact_id or email. handoff_key is an operations secret: it is configured on the customers app's deployment, and the calling app sends the same value. While the deployment has none, the route is switched off and every call answers 503.
The answer is 201 with the same token that POST /v1/customers/auth/magic-link mints, except that the secret is returned instead of mailed. It holds contact_id, user_id, secret and expire. Nothing is sent anywhere. Redeem it on your server with PUT /v1/customers/auth/magic-link, sending user_id and secret, exactly as you would a mailed one.
- The secret is a credential. It is single-use and expires. Keep it out of redirects and logs.
- Magic-link must be on. The route checks the
login_magic_linksetting, which a buyer's organization can also switch off for its own people. With it off, the route answers403(method_not_offered) and creates no token. - Nothing is created. An address or id that nobody holds is a
404. A hand-off never founds an account. - No timeline entry. The route writes no
contact_event. - Untrusted callers are refused. A missing or wrong
handoff_keyanswers403withuntrusted_caller. A buyer who may not sign in is a403too:registration_pending,registration_rejected,contact_blockedororganization_blocked. - Name the buyer once. Sending both
contact_idandemail, or neither, is a400. A contact with no platform user to sign in as is a409withnot_mirrored.
Where this fits
| You want | Use |
|---|---|
| Buyer accounts on a storefront | These five routes |
| Sign in a buyer another system already authenticated | POST /v1/customers/auth/handoff |
| A Cockpit operator or an API integration signing in | Authentication |
| A storefront built on the platform's own stack | Experience Studio authentication |
Where to go next
- Sign a buyer in from your server — the whole session in order, including address confirmation, a second factor and sign-in without a password.
- Contacts — the record a registration creates, and the approve/reject routes.
- Roles and permissions — what the
permissionsblock contains. - Carts — claiming a guest cart once a buyer signs in.