Sign a buyer in from your server

Register a buyer, sign them in, keep the session on your server, check it is still alive and sign them out, plus what each 401 and 403 along the way is telling you.

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 as orders.list for 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:

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

Request
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 only true when 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:

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

Request
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

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

Response
{
  "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:

ValueWhy you keep it
session.$idThe session. It is the session_id for the session check and for sign-out.
session.userIdThe buyer's login. It is the user_id every other sign-in route takes. It is not the contact id.
session.secretThe session credential.
session.expireWhen the session ends on its own. Don't let your cookie outlive it.
contact.idThe 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:

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

Request
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 contact on 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 403 with code: "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:

Request
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 carries user, contact and permissions.
  • 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: false means 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 on permissions alone.
  • permissions lists what the role allows, for example orders.create, or only orders.request. Roles and permissions explains the roles.
The API key is not the buyer. It reaches whatever its scopes cover, for every buyer in the tenant. A call made with it is not narrowed to this buyer, so your server has to narrow it with the ids from the session. For example, 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

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

StatusWhereMeaningWhat to do
401Any callerror 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.
401Sign-inThe 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_idThe session expired or was revoked.Drop the cookie and sign the buyer in again.
401Sign-outThe session was already gone, or it does not belong to this user.Drop the cookie. The buyer is signed out either way.
401Second factor, link or codeThe code or secret is wrong, already used or expired.Offer to send a new one.
403Any callapi key not valid for this tenant: the key belongs to another tenant.Check X-Revenexx-Tenant against the key.
403Any callapi 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.
403Sign-incode 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.
403Sign-incode is method_not_offered: the store does not offer this way of signing in.Offer a method the store does offer.
403Sign-incode 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.
403RegistrationThe 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.

Some answers reveal whether an address is registered. The one message for a wrong password or an unknown address hides that at sign-in. But a 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

Was this page helpful?