Build a signed-in buyer account area
This guide builds the part of a storefront (the shop your buyers use) that a buyer opens after signing in: a list of their orders, one order with its invoices and delivery notes, and their addresses, with the right to edit them. At the end each screen shows only what belongs to the signed-in buyer, and you know which grant decides what a buyer sees.
There are two ways to get there. On a Theme, the account screens already exist as blocks, and you place them. On your own frontend, your server reads the data and applies the buyer filter itself. The second path is longer because the filter is your job.
The guide assumes the buyer can already sign in (Sign a buyer in from your server). Reordering from an order is a separate guide: Build reorder and quick order.
The short answer: on your own server, a call made with the API key is not narrowed to the buyer, so three rules are yours to keep (see also the warning in Sign a buyer in).
- Filter every list on your server. Take
contact.idandorganization_idfrom the session your server holds and put them in the filter. For a single record, load it and compare its owner with the session before you return it. - Always send
audience=customeron a document call. Without it, your call is treated as the operator's and internal documents are listed and linked. - Check on your server before an address write. The owner, the
addresses.managegrant and the tenant's self-service setting are not enforced on a key-only call. Hiding the form is not enough.
Prerequisites
- A signed-in buyer on your server, with the session your server keeps: the sign-in answer's
session.userId(<USER_ID>below) andsession.$id(<SESSION_ID>). The buyer's<CONTACT_ID>and<ORGANIZATION_ID>come from step 1. - An API key (
rvxk_…) for the tenant, kept on the server, whose scopes cover Orders, Customers and Documents (which scope covers which app). See API keys. - The Orders, Customers and Documents Apps installed in the tenant. Step 7 also reads the tenant's settings with
GET /v1/settings/apps/customers, which has no documented scope yet, so test that call with your key (Prerequisites).
The examples read the tenant slug and the key from the environment:
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."
The Nuxt examples read the same two values from runtime config. Set NUXT_REVENEXX_TENANT and NUXT_REVENEXX_API_KEY in your server's environment and declare the keys once:
export default defineNuxtConfig({
runtimeConfig: {
revenexxApiUrl: 'https://api.revenexx.com',
revenexxTenant: '',
revenexxApiKey: '',
},
})
Build on a Theme
The reference theme, @revenexx/cover-theme, ships the whole account area: the account shell, an account_title, and widget blocks for orders, order detail, addresses, profile and the B2B screens. The block catalog lists them all. The Theme path has four actions:
- Start from the reference theme (Build your first Theme). Its seeded content already holds one page per screen:
account(the dashboard),account-orders,account-orders-detail,account-addresses,account-profileand more. Each screen page is anaccountshell that holds anaccount_titleand one widget. The dashboard holds six dashboard widgets and no title. - Open the page in the editor and arrange the widgets. For a new screen, drop the
accountshell on the page, select it, and addaccount_titleand the widget you want inside it, for exampleaccount_orders_tableoraccount_addresses. The widgets are not meant to sit outside the shell (Shells and fields). - Check the routes. The theme binds
/account/orderstoaccount-orders,/account/orders/<order number>toaccount-orders-detailand/account/addressestoaccount-addresses. One authored detail page serves every order: theaccount_orders_detailblock reads the order number from the URL. - Look at live data in a production build. A production build of the theme reads orders and addresses from the platform.
nuxt devserves demo data unless you switch the service, so an empty or invented order list in development is not a bug in your tenant (as of 2026-11).
You add no filter for these blocks, because the theme's own server routes do it. /api/account/orders, /api/account/addresses and the address writes take the buyer's ids from the identity the server verified, never from the browser, answer another buyer's order exactly like an unknown one, and check the addresses.manage grant (on every address write, a private address included) and the tenant's self-service setting before an address write. The platform does none of this for a key-only call, so keep these checks if you change the theme. A route you override, or a route you add, is your own server code and follows the rest of this guide.
Build on your own frontend
The browser sends a cookie and nothing else that names a buyer. Your server turns the cookie into the buyer, and only the server decides which ids go into a call.
1. Resolve the buyer on every request
Before an account route reads anything, ask the platform whether the session is still alive. The same call returns the ids and the grants you need:
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":"<USER_ID>","session_id":"<SESSION_ID>"}'
A 401 means the session ended: drop the cookie and send the buyer to sign in. A 200 carries contact and permissions. Keep four values for the rest of the request:
| Value | Where it comes from | What it is for |
|---|---|---|
contact.id | contact.id | Filters the buyer's own orders and addresses. |
organization_id | permissions.organization_id | Filters the company's addresses. null for a private buyer without a company. |
permissions.permissions | permissions.permissions | The grants that decide which screens you show (see step 7 below). |
permissions.active | permissions.active | When false, your server returns no account data: the buyer is blocked or not yet approved. It says nothing about the company: requireBuyer reads the organization and refuses a blocked or trashed one (status, deleted_at). The platform does not stop a key-only call for you. |
contact: null means a login without a customer record. There is no account to show. Sign a buyer in explains the answer in full.
Take these values from the session your server holds. A contact_id in the query string or body of a browser request is the browser's word and must not reach the platform. Put the lookup in one helper that every account route calls. readBuyerSession is the one function that is yours: it returns the userId and sessionId your server stored behind the cookie, or nothing.
import type { H3Event } from 'h3'
export interface Buyer {
contactId: string
organizationId: string | null
grants: string[]
}
export async function requireBuyer(event: H3Event): Promise<Buyer> {
const config = useRuntimeConfig(event)
const session = await readBuyerSession(event)
if (!session) throw createError({ statusCode: 401 })
const me = await $fetch<any>(`${config.revenexxApiUrl}/v1/customers/auth/me`, {
method: 'POST',
headers: {
'X-Revenexx-Tenant': config.revenexxTenant,
'X-Revenexx-Api-Key': config.revenexxApiKey,
},
body: { user_id: session.userId, session_id: session.sessionId },
}).catch((err) => {
// 401: the session ended. Anything else is your configuration or the platform.
throw createError({ statusCode: err?.statusCode === 401 ? 401 : 502 })
})
if (!me.contact || !me.permissions?.active) throw createError({ statusCode: 403 })
// `active` reflects the contact only, so check the company yourself.
const organizationId = me.permissions.organization_id ?? null
if (organizationId) {
const org = await $fetch<any>(`${config.revenexxApiUrl}/v1/customers/organizations/${organizationId}`, {
headers: {
'X-Revenexx-Tenant': config.revenexxTenant,
'X-Revenexx-Api-Key': config.revenexxApiKey,
},
}).catch(() => {
throw createError({ statusCode: 502 })
})
if (org.status === 'blocked' || org.deleted_at) throw createError({ statusCode: 403 })
}
return {
contactId: me.contact.id,
organizationId,
grants: me.permissions.permissions,
}
}
A 401 from /me can also mean your API key is wrong (error: invalid api key), so log the platform's error before you drop the buyer's cookie (What a 401 and a 403 mean).
2. List the buyer's orders
The filter for "my orders" is contact_id. Every parameter of the order list is an exact match and they combine with AND. Send the sort too. The list does not promise newest first, so a page limit can hide the newest orders:
curl -G "https://api.revenexx.com/v1/orders" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
--data-urlencode "contact_id=<CONTACT_ID>" \
--data-urlencode "order=created_at.desc" \
--data-urlencode "limit=20" \
--data-urlencode "offset=0"
The answer is { "items": [...], "page": { "total": …, "hasMore": … }, "filter": {…} }. A row has the order's id, its number, status, payment_status, fulfillment_status, placed_at, currency and grand_total, but not its positions. limit defaults to 50 and stops at 200. Page with offset while page.hasMore is true.
In a Nuxt server route, the filter comes from the session and the paging comes from the browser:
export default defineEventHandler(async (event) => {
const buyer = await requireBuyer(event) // the helper from step 1
const config = useRuntimeConfig(event)
const { offset = '0' } = getQuery(event)
return await $fetch(`${config.revenexxApiUrl}/v1/orders`, {
headers: {
'X-Revenexx-Tenant': config.revenexxTenant,
'X-Revenexx-Api-Key': config.revenexxApiKey,
},
query: {
contact_id: buyer.contactId,
order: 'created_at.desc',
limit: 20,
offset: Math.max(Number(offset) || 0, 0),
},
})
})
Check the filter object of the answer against what you sent. Behaviour differs per app (as of 2026-11): the live spec still says the Orders list drops a misspelt parameter and returns the whole tenant's list, with your typo missing from filter, while the Orders source answers 400 unknown_filter; the Documents list drops it. Which one you get live is not verified, so keep the check.
A buyer who approves orders also needs their colleagues' orders, or they cannot find what they are asked to decide. For that screen send organization_id=<ORGANIZATION_ID> instead of contact_id, and only when the buyer holds orders.approve (see step 7 below). An order placed without a company never appears in a company list, so keep the buyer's own list beside it.
3. Open one order, only if it is the buyer's
An order's id and its number can both be guessed or edited in a URL, and a read by id carries no buyer. Do two things for every order route:
- Look the order up by the number from the URL, with the buyer in the same filter:
GET /v1/orders?number=<ORDER_NUMBER>&contact_id=<CONTACT_ID>&limit=1. An emptyitemsmeans this buyer has no such order. For an approver (a non-null sessionorganization_idandorders.approve), filter byorganization_id=<ORGANIZATION_ID>instead, so a colleague's order is found. Never drop both filters: a lookup by number alone finds any buyer's order. - Read the aggregate with
GET /v1/orders/<ORDER_ID>, and compare itscontact_idwith the session before you return it.
curl "https://api.revenexx.com/v1/orders/<ORDER_ID>" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY"
The aggregate holds the positions (items), the shipments, the returns and the cancellations. If its contact_id is not the buyer's, answer 404, the same as for an unknown number. A 403 would tell a stranger the order exists. For an approver reading a colleague's order, accept it only when the session has a non-null organization_id, the buyer holds orders.approve, and the order's organization_id equals the session's. Never compare two null values: a private buyer has no company, and null equals null.
4. Show the invoices and documents on an order
Invoices, delivery notes, credit notes and return receipts are not part of the order. They live in the Documents register, and a storefront reads them from there. A document is filed against an order, so the check in step 3 comes first. <ORDER_ID> below is the id of the order your verified lookup returned (items[0].id). Never call the document list without it: an entity_id that is undefined is left out of the query, and the list then returns every customer-visible document of the tenant. In code, guard it: if (!orderId) throw createError({ statusCode: 404 }).
curl -G "https://api.revenexx.com/v1/documents/documents" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
--data-urlencode "entity_type=order" \
--data-urlencode "entity_id=<ORDER_ID>" \
--data-urlencode "audience=customer"
audience=customer on a document call made for a buyer. Without it, a call with the key alone is treated as the operator's: internal documents are listed, and a link to an internal document is minted with a 200. Nothing fails, so the leak is silent.With audience=customer, a document marked internal is absent from items and from page.total. The operation text in the spec describes audience, but it is not in the spec's parameter list (as of 2026-11); the Documents page is the reference.
To let the buyer download one, read the document with the same audience and check that its entity_type is order and its entity_id is the order you verified. Send the same audience on the link call:
curl -X POST "https://api.revenexx.com/v1/documents/documents/<DOCUMENT_ID>/link?audience=customer" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY"
The answer holds a url that is valid for five minutes and cannot be revoked. Send the buyer to it at once and do not store it. A document whose file lives on the merchant's own host answers 409 with code: "external_document" and the address in details.external_url: send the buyer there instead.
5. List the buyer's addresses
An address belongs to a company, to a person, or to a person of that company (as of 2026-11 the Customers app accepts both owner fields for a contact of that company; the spec's wording says exactly one, so rely on the filters below, not on that count). The company's addresses are the ones everybody in it may ship to, and many B2B buyers have only those. Read both, in two calls, because the filters are an AND and there is no "mine or my company's":
curl -G "https://api.revenexx.com/v1/customers/addresses" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
--data-urlencode "contact_id=<CONTACT_ID>" \
--data-urlencode "limit=200"
Repeat with organization_id=<ORGANIZATION_ID> in place of contact_id, skip that call when the buyer has no company, and merge the two lists by id, the buyer's own first. Each call returns at most 200 rows, so page with offset while page.hasMore is true. When the buyer has neither id, return an empty list yourself: an unfiltered key-only list returns the tenant's whole address book.
6. Edit an address
An address is read and written by id, so the id is the one thing the browser controls. Before you change anything, load the address and check that it is the buyer's:
curl "https://api.revenexx.com/v1/customers/addresses/<ADDRESS_ID>" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY"
Use the Customers app's own rule, company first. An address that names a company is the buyer's only when the buyer has a company and it is the same one. Only an address with no company is decided by its contact. Do not compare the two ids with an OR, and never compare two null values: a private buyer's organization_id is null, and so is the address's, so a plain equality would let a private buyer reach any other private buyer's address.
export function ownsAddress(buyer: Buyer, address: { organization_id?: string | null; contact_id?: string | null }): boolean {
if (address.organization_id) {
return !!buyer.organizationId && address.organization_id === buyer.organizationId
}
return !!address.contact_id && address.contact_id === buyer.contactId
}
If ownsAddress is false, answer 404. Then send a partial update with only the fields your form shows:
curl -X PUT "https://api.revenexx.com/v1/customers/addresses/<ADDRESS_ID>" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"street":"Hafenstrasse 12","zip":"20457","city":"Hamburg","country":"DE","is_default":true}'
Build the body from a list of allowed fields, such as name, company, street, zip, city, country, phone, type and is_default. Never pass the browser's JSON through. The owner cannot change (400, field_immutable), and the fields the merchant keeps, such as external_id and the validation fields, are not the buyer's to set. By default, marking an address the default clears the owner's other default of the same type (a tenant setting can switch that off), so read the list again after a save.
The answer is the stored address. A 400 with code: "address_invalid" lists the broken fields in details.errors (field, rule, message), one sentence each, so you can show them next to the form. Each country has its own rules for postal code, town and region (Address formats). To add an address, build the body on your server and POST it to /v1/customers/addresses (Organizations and addresses lists the fields). Never take contact_id or organization_id from the browser: set contact_id to buyer.contactId and organization_id to buyer.organizationId from the session, and omit organization_id when it is null. Then run ownsAddress on the body you built, and the grant and setting checks below.
To delete, run the same checks and then call DELETE /v1/customers/addresses/<ADDRESS_ID> with the same headers. Orders already placed keep the address they were placed with.
403, or 404 for another owner) when: ownsAddress is false (above); it belongs to the company and the buyer's grants lack addresses.manage; or the tenant's self_service_master_data is none. The next step shows where each value comes from.7. Decide what each screen needs
A grant is one permission key on the buyer's role, and /me returns the list in permissions.permissions (step 1). Which grant a platform app checks, and for which calls, is in Give buyer staff the right permissions. What matters for this guide is who enforces each one when your server calls with the key alone:
| Screen or action | Grant | Who enforces it |
|---|---|---|
| The buyer's own orders and their documents | orders.read | No app. Your server decides, and shows the screen to a role that holds it. |
| The company's orders, for an approver | orders.approve | No app on a key-only call. Your server applies it. |
| Add, edit or delete a company address | addresses.manage | Your server refuses the write. The Customers app checks it only for a call it can attribute to a buyer. |
| Add, edit or delete an address the buyer holds alone | none | Your server's ownsAddress check from step 6 (every edit, and any buyer without a company). |
Beyond the grant, the tenant's setting self_service_master_data decides whether buyers may change their data: all, addresses_only (addresses can change, the company cannot) or none. Read it with GET /v1/settings/apps/customers (Settings) and refuse every address write on your server under none, as well as hiding the form. Where an ERP keeps the addresses, a buyer's edit would be overwritten by the next import.
Hide what the buyer may not do, and refuse it on your server too. The role can change between the render and the click, and /me is derived again on every call. For a buyer whose permissions.active is false, or whose company is blocked or trashed, requireBuyer already refuses, so none of these screens return data.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Every buyer sees all orders or all addresses | The list was called without contact_id or organization_id. A key alone is not narrowed. | Build the filter from the session in every account route. |
| The newest orders are missing | No order on the list, and a page limit cut them off. | Send order=created_at.desc. |
| The list is not filtered although you sent a filter | A parameter name was misspelt and dropped (Orders may answer 400 unknown_filter instead; as of 2026-11). | Compare the answer's filter object with what you sent. |
| An order list is empty for a buyer who ordered | The orders sit under another contact, or contact is null. | Check contact.id. Show a company view for approvers (step 2). |
404 on a document link or read | The id is unknown, or the document is internal and you sent audience=customer. | Show only what the list returned. |
| A buyer can open an internal document | The call lacked audience=customer, so it ran as the operator. | Send it on every document call. |
409 external_document on a link | The file lives on the merchant's host. | Send the buyer to details.external_url. |
400 address_invalid | The address breaks its country's rules. | Show details.errors beside the fields. |
400 field_immutable | The body carried organization_id or contact_id. | Allow-list the fields of the update. |
Your route answers 403 on an address write | The setting is none, or the buyer lacks addresses.manage for a company address. | That is your server's own refusal (step 6). Hide the form and say who maintains the data. |
An address write went through under setting none | Your route did not check. The platform accepts a key-only call. | Add the checks from step 6 to every address route. |
| A Theme shows demo orders | nuxt dev runs the demo services. | Look at a production build (as of 2026-11). |
Where to go next
- Next: Build reorder and quick order. It adds the "order again" button to the order screen you just built.
- Give buyer staff the right permissions: which role holds which grant, and what each one gates.
- Storefront accounts: the fields of registration, sign-in,
/meand sign-out. - Orders: the order fields, the statuses and the order list filters.
- Documents: kinds, visibility and the link call.
- Organizations and addresses: every address route and the buyer rules on them.