Sign a buyer in from your server
This guide takes one buyer through a complete session from your own server. The buyer registers, signs in and signs out again. In between, your server keeps the session, reads who the buyer is and checks that the session still works. At the end you know what to store, where to keep it, and what each failure means.
A buyer is a person at a customer company who signs in to a storefront or a customer portal. Staff who work in Cockpit sign in differently; see the end of this page. The guide is for developers building that storefront or portal on their own stack. If you build on a Theme, it already ships registration, sign-in, sign-out, the signed-in buyer and password recovery as server routes and blocks. It does not ship address confirmation, a second factor or sign-in without a password; you add those as described below. See Experience Studio authentication.
Prerequisites
- A tenant slug, the store you are building for. Prerequisites shows where to find it.
- An API key (
rvxk_…) created in Cockpit for that tenant. Its scopes must cover the customer sign-in routes,customers.auth.*, and whatever your server reads for the buyer, such asorders.listfor the order history in step 3 (see which scope covers which app). See API keys. - A server. Every call below carries the API key, and the sign-in answer carries a session secret, so none of it may run in a browser. The browser talks to your server, and your server talks to the platform. The BFF pattern explains the split.
Every call sends the tenant slug in X-Revenexx-Tenant and the key in X-Revenexx-Api-Key (see API usage). The examples read both from the environment:
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."
Storefront accounts documents the fields of registration, sign-in, /me, sign-out and password recovery. The routes for address confirmation, the second factor and sign-in without a password are documented in the API Explorer. This page is about the order you call them in and what you do with the answers.
1. Register the buyer
curl -X POST https://api.revenexx.com/v1/customers/auth/register \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"einkauf@example.com","password":"<password>","first_name":"Anna","last_name":"Berger","verification_url":"https://shop.example.com/confirm"}'
A 201 means the account was written, but it does not mean the buyer is signed in. Registration returns no session. Read two fields before you decide what to show:
approval_required: true: the tenant reviews registrations first. The account exists, but it cannot sign in until a merchant approves it. Show a "we will get back to you" screen and don't try to sign the buyer in.verification_sent: whether an address confirmation mail went out. It is onlytruewhen the tenant asks for address confirmation at registration. See the next section.
Keep the user_id from the answer. You need it to send a confirmation mail later, and you get it again at every sign-in.
Confirm the buyer's address
Some tenants require a confirmed address before a buyer can sign in. Until it is confirmed, sign-in answers 403 email_not_verified. The API calls this step verification: the verification_sent field, the /verification routes and the email_not_verified code all refer to it.
The mail goes out during registration only if you pass verification_url, as the example above does. Without it the registration still succeeds, verification_sent is false, and you have to send the mail yourself. The same applies to a registration that needs approval: no mail goes out with it, so once the merchant approves, send the mail if the tenant requires a confirmed address.
To send it, or send it again:
curl -X POST https://api.revenexx.com/v1/customers/auth/verification \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"user_id":"<user_id>","url":"https://shop.example.com/confirm"}'
The link points at your URL, with userId, secret and expire appended. When the buyer lands there, your server sends the first two back:
curl -X PUT https://api.revenexx.com/v1/customers/auth/verification \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"user_id":"<userId from the link>","secret":"<secret from the link>"}'
A 200 means the address is confirmed. The link's secret works once, so a second attempt is a 401.
2. Sign the buyer in
curl -X POST https://api.revenexx.com/v1/customers/auth/login \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"identifier":"einkauf@example.com","password":"<password>"}'
identifier is the buyer's email address by default. If the merchant has switched them on, it can also be a username or the company's customer number (What a buyer signs in with). The login route does not tell you in advance which kinds a shop accepts, so ask the merchant, label the field to match, and handle identifier_not_offered below. Older storefronts send email, which still works as a deprecated alias.
Success is a 201, not a 200, because the call creates a session. A client that only treats 200 as success never sees one. The answer has three parts:
{
"session": {
"$id": "…",
"userId": "…",
"secret": "…",
"expire": "<ISO-8601 timestamp, for example 30 days from sign-in>",
"provider": "…"
},
"contact": { "id": "…", "email": "einkauf@example.com", "…": "…" },
"permissions": { "contact_id": "…", "organization_id": "…", "active": true, "permissions": ["orders.read", "orders.create"] }
}
Keep these on your server. Give the browser only a cookie that points at them, and set it httpOnly and Secure yourself:
| Value | Why you keep it |
|---|---|
session.$id | The session. It is the session_id for the session check and for sign-out. |
session.userId | The buyer's login. It is the user_id every other sign-in route takes. It is not the contact id. |
session.secret | The session credential. |
session.expire | When the session ends on its own. Don't let your cookie outlive it. |
contact.id | The customer record, which is what orders, carts and addresses refer to. |
session.secret is a credential. Once the sign-in is complete, whoever holds it is signed in as the buyer. It must never reach client JavaScript, a URL or a log line.Two answers need a branch before you treat the buyer as signed in: contact: null, and mfa_required: true in the next section.
contact: null means the login is valid, but no customer record belongs to it. Treat that as "signed in, but not a customer of this shop" and don't render an account.
If a second factor is required
mfa_required: true means the tenant requires a second factor, and the password was only half of it. The answer still contains a session, but it is not signed in yet. Don't treat the buyer as signed in, and use its secret only to confirm the code. A code has already been mailed, and challenge_id names it. Ask the buyer for the code, then confirm it:
curl -X PUT https://api.revenexx.com/v1/customers/auth/mfa/challenge \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"session_secret":"<session.secret>","challenge_id":"<challenge_id>","code":"<what the buyer typed>"}'
A 200 completes the sign-in. A 401 means the code is wrong or has expired. To mail a fresh code, call POST /v1/customers/auth/mfa/challenge with {"user_id":"<session.userId>"}. The $id it answers with is the new challenge_id.
Sign in without a password
A tenant can also offer sign-in by mailed link or by mailed code. Both are two calls: one mails the link or code, and the second redeems it.
curl -X POST https://api.revenexx.com/v1/customers/auth/magic-link \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"einkauf@example.com","url":"https://shop.example.com/signin"}'
The link points at your url, with userId and secret appended. Your server redeems it with PUT on the same route and a body of {"user_id":"…","secret":"…"}. For a mailed code, use POST /v1/customers/auth/otp with just the email, then PUT /v1/customers/auth/otp with the userId from the first answer as user_id and the code the buyer typed as secret. Each secret works once.
Both redeems answer 201 with the same session, contact and permissions as a password sign-in, so step 2's table and the contact: null branch apply unchanged. Two differences matter:
- Sending to an address nobody holds creates a login. That makes this a registration path as well as a sign-in one, so check
contacton the answer. - Which methods a store offers is a tenant setting, and a buyer's company can switch one off for its own people. A method that is switched off answers
403withcode: "method_not_offered".
3. Read who is signed in, and check the session
As of 2026-09 there is no refresh call. A session lives until session.expire or until it is revoked, and after that the buyer signs in again. So before anything that acts for the buyer, such as an account page, a checkout or an order, your server asks whether the session still works. The same call tells it who the buyer is:
curl -X POST https://api.revenexx.com/v1/customers/auth/me \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"user_id":"<session.userId>","session_id":"<session.$id>"}'
200: the session works. The answer carriesuser,contactandpermissions.401: the session expired or was revoked, for example because the buyer signed out on this device. Drop your cookie and send the buyer to sign in.
It is a POST because the ids travel in the body, which only your server sends. The session_id is what makes it a session check. Leave it out and /me only answers who is this user, and never returns that 401. That is fine for a lookup, but it doesn't confirm a sign-in.
The grants are worked out again on every call, so a role a merchant changed a moment ago is already reflected. The copy you got at sign-in is not updated. Use permissions to decide what to render:
active: falsemeans the buyer must not act: the merchant blocked them, or their registration is pending or was rejected. Your server checks it and stops there. The buyer still holds a role, and what that role lists while they are inactive is a tenant setting, so don't rely onpermissionsalone.permissionslists what the role allows, for exampleorders.create, or onlyorders.request. Roles and permissions explains the roles.
GET /v1/orders?contact_id=<contact.id> returns that buyer's order history. Take the id from the session your server holds, never from a parameter the browser sent. Otherwise any signed-in buyer can read any other buyer's orders by changing one value.4. Sign the buyer out
curl -X POST https://api.revenexx.com/v1/customers/auth/logout \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"user_id":"<session.userId>","session_id":"<session.$id>"}'
This ends one session. The buyer is signed out on this device and stays signed in everywhere else. The customer record is untouched, so signing out does not block anyone.
Drop your cookie whatever the answer. A 200 (logged_out: true) means the session is ended. A 401 means the session was already gone or does not belong to this user. The one exception is a 401 whose error is invalid api key: that is your configuration, and the session may still be alive. A 404 means the account itself no longer exists.
Troubleshooting: what a 401 and a 403 mean in this flow
A 401 says a credential is wrong: your API key, the buyer's password, the session or a one-time code. A 403 says the identity is fine, but the action is not allowed for it.
| Status | Where | Meaning | What to do |
|---|---|---|---|
401 | Any call | error is invalid api key (the key you sent is unknown) or not authenticated (no key arrived). | Fix your server's configuration. The buyer did nothing wrong. |
401 | Sign-in | The identifier or password is wrong, or the identifier names nobody. | Show one message for a wrong password and an unknown identifier alike. |
401 | /me with session_id | The session expired or was revoked. | Drop the cookie and sign the buyer in again. |
401 | Sign-out | The session was already gone, or it does not belong to this user. | Drop the cookie. The buyer is signed out either way. |
401 | Second factor, link or code | The code or secret is wrong, already used or expired. | Offer to send a new one. |
403 | Any call | api key not valid for this tenant: the key belongs to another tenant. | Check X-Revenexx-Tenant against the key. |
403 | Any call | api key scope does not permit this capability: your key's scopes don't cover this route. | Use a key with that scope. See API keys. |
403 | Sign-in | code is registration_pending or registration_rejected (the password was right, but the registration is not approved), or email_not_verified (the address is not confirmed yet). | Show a message for that case. Retrying does not help. A rejection carries reason only when the tenant chooses to disclose it. |
403 | Sign-in | code is method_not_offered: the store does not offer this way of signing in. | Offer a method the store does offer. |
403 | Sign-in | code is identifier_not_offered: the shop or the buyer's company does not accept this kind of identifier (an address, a username or a customer number). | Ask for another kind. Retrying the same one does not help. |
403 | Registration | The store does not accept this kind of registration. It has switched off either company registrations or private ones. | Hide the option this store does not offer. |
Three answers are easy to mistake for one of the above. A 400 on sign-in means a field was missing and nothing was checked, so don't tell the buyer their password is wrong. A 409 on registration means a duplicate. Usually the address is already registered, including by an application still waiting for approval. With code: duplicate_record, another field the tenant checks for duplicates already has this value. A 429 means the tenant's rate limit was reached. Back off and retry.
403 with a registration code says the password was right, and a 409 on registration can say the address exists. You can't hide those. On top of the platform's own rate limit, limit how often your own forms can be submitted.Buyers and staff sign in differently
Everything on this page is the buyer surface. Buyer accounts and sessions are served by the routes under /v1/customers/auth, and your server keeps calling with its API key while a buyer is signed in.
Staff and operators, the people who work in Cockpit, sign in through ID, the platform's identity service for staff, instead, and their calls carry an ID token as Authorization: Bearer. A buyer session doesn't authenticate a staff member, an ID token doesn't sign a buyer in, and a Cockpit sign-in grants nothing in a storefront.
Where to go next
- Storefront accounts: the fields of registration, sign-in,
/meand sign-out, plus two-step password recovery. - Roles and permissions: what a buyer's role grants, and how a merchant changes it.
- Give buyer staff the right permissions: assign roles to a customer's people and decide the registrations this flow creates.
- Carts: hand a guest's cart to the buyer the moment they sign in.
- API keys: scope the key your server uses to what it calls.