Storefront accounts

The five account routes a shop front-end calls — register, login, me, logout and the two-step password recovery — why they belong to a trusted server rather than a browser, and the server-to-server hand-off.

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.

These are for a trusted server, not a browser. The login response carries a session 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

Request
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 fieldEffect
emailBecomes 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_nameFounds a new company with this contact as its admin. This is what makes the registration a B2B one.
organization_idJoins 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).
localeBCP 47, and one of the store's configured locales, or the call is a 400.
first_name, last_nameOptional.

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.

SettingResult
open (default)A finished account: registration_status: "approved", status: "active", login works.
approval_requiredAn 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

Request
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:

FieldIs
sessionThe auth session: $id, userId, secret, expire, provider.
contactThe 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".
permissionsThe 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.

KindMarket settingDefault
Email addresslogin_identifier_emailOn
Usernamelogin_identifier_usernameOff
Customer numberlogin_identifier_customer_numberOff

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

Request
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

Request
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

Request
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

Request
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.

Request
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_link setting, which a buyer's organization can also switch off for its own people. With it off, the route answers 403 (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_key answers 403 with untrusted_caller. A buyer who may not sign in is a 403 too: registration_pending, registration_rejected, contact_blocked or organization_blocked.
  • Name the buyer once. Sending both contact_id and email, or neither, is a 400. A contact with no platform user to sign in as is a 409 with not_mirrored.

Where this fits

You wantUse
Buyer accounts on a storefrontThese five routes
Sign in a buyer another system already authenticatedPOST /v1/customers/auth/handoff
A Cockpit operator or an API integration signing inAuthentication
A storefront built on the platform's own stackExperience 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 permissions block contains.
  • Carts — claiming a guest cart once a buyer signs in.
Was this page helpful?