Build a signed-in buyer account area

Give a signed-in buyer an account area with their orders, the documents on each order and their addresses, on a Theme or on your own server, and filter every read to the buyer.

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

  1. Filter every list on your server. Take contact.id and organization_id from 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.
  2. Always send audience=customer on a document call. Without it, your call is treated as the operator's and internal documents are listed and linked.
  3. Check on your server before an address write. The owner, the addresses.manage grant 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) and session.$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:

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:

nuxt.config.ts
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-profile and more. Each screen page is an account shell that holds an account_title and 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 account shell on the page, select it, and add account_title and the widget you want inside it, for example account_orders_table or account_addresses. The widgets are not meant to sit outside the shell (Shells and fields).
  • Check the routes. The theme binds /account/orders to account-orders, /account/orders/<order number> to account-orders-detail and /account/addresses to account-addresses. One authored detail page serves every order: the account_orders_detail block 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 dev serves 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

YOUR SERVERbrowser1 · Check the sessionPOST …/customers/auth/me2 · Apply the buyer idscontact_id, organization_idrevenexx APIapi.revenexx.comcookiecontact.idAPI keycontact_id=…not usedbuyer: browseryour servernever used

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:

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

ValueWhere it comes fromWhat it is for
contact.idcontact.idFilters the buyer's own orders and addresses.
organization_idpermissions.organization_idFilters the company's addresses. null for a private buyer without a company.
permissions.permissionspermissions.permissionsThe grants that decide which screens you show (see step 7 below).
permissions.activepermissions.activeWhen 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.

server/utils/buyer.ts
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:

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

server/api/account/orders.get.ts
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:

  1. 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 empty items means this buyer has no such order. For an approver (a non-null session organization_id and orders.approve), filter by organization_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.
  2. Read the aggregate with GET /v1/orders/<ORDER_ID>, and compare its contact_id with the session before you return it.
Request
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 }).

Request
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"
Always send 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:

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

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

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

server/utils/buyer.ts
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:

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

Your server refuses an address write itself. A call with the key alone is not refused by the platform for any of these, so hiding the form leaves a crafted request open. Before every create, update and delete (for a create, on the body you built), refuse (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 actionGrantWho enforces it
The buyer's own orders and their documentsorders.readNo app. Your server decides, and shows the screen to a role that holds it.
The company's orders, for an approverorders.approveNo app on a key-only call. Your server applies it.
Add, edit or delete a company addressaddresses.manageYour 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 alonenoneYour 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

SymptomCauseFix
Every buyer sees all orders or all addressesThe 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 missingNo 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 filterA 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 orderedThe 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 readThe 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 documentThe call lacked audience=customer, so it ran as the operator.Send it on every document call.
409 external_document on a linkThe file lives on the merchant's host.Send the buyer to details.external_url.
400 address_invalidThe address breaks its country's rules.Show details.errors beside the fields.
400 field_immutableThe body carried organization_id or contact_id.Allow-list the fields of the update.
Your route answers 403 on an address writeThe 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 noneYour 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 ordersnuxt dev runs the demo services.Look at a production build (as of 2026-11).

Where to go next

Was this page helpful?