Give buyer staff the right permissions

Map a customer's org chart onto buyer roles, assign them to contacts, read a contact's effective grants, decide registrations, and keep the roles in step with purchase approvals.

A customer's purchaser can place orders, their assistant is not meant to place one (only a Procurement rule or an attributed Orders call stops them, see step 6), the controller just looks, and the department head is the person purchase approvals ask to sign off. At the end of this guide, each of those people holds the buyer role that matches their job, you can read what each one may do, and your back office decides the sign-ups from the customer's company. It also shows where these roles stop and purchase approvals take over: a sign-off by named approvers against budgets works only through the Procurement app, which runs purchase approvals (step 6), because the two do not use the same table. The Orders app has a single-step approve for an order it holds as pending.

This is for developers and integrators who set up a B2B customer: the back office or ERP sync that creates contacts, and the storefront that shows or hides buttons. New to the platform? Prerequisites covers Cockpit, your tenant slug, installing an app and a test tenant. You need:

  • A tenant with the Customers app installed. Customers is the app that holds organizations, contacts and their roles. A new tenant starts with no apps, and you install them in Cockpit from the App Marketplace (installing an app, Platform overview). revenexx tenants list prints your tenant slug (CLI authentication).
  • The Procurement app, for step 6 only, and only if you route approvals through it. Install it the same way as Customers.
  • An API key with these scopes: customers.roles.list and customers.roles.permissions.replace for the role table, customers.contacts.list, customers.contacts.create (only to create a contact with a role), customers.contacts.update and customers.contacts.permissions for contacts, and customers.registrations.approve and customers.registrations.reject for sign-ups. customers.* covers all of them, writes included (see which scope covers which app). Reading the Customers app's settings, as step 1 shows, has no documented scope yet; a development key with * covers it.
  • The customer's organization and contacts (see Contacts), and the customer's org chart in front of you.
  • curl and jq, which the examples use to trim the answers.

Every call goes through the API gateway:

Environment
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."

This guide is about the buyer side: people at a customer company. Keys for your own team are a different thing, and so is what your App may reach (App permissions).

Checked in October 2026. Statements here about behaviour the platform does not enforce, or that is not confirmed, can change, so re-check one before you build on it (as of 2026-10).

How a grant is decided

Each contact holds one role, many contacts can hold the same one, and a role applies inside the contact's organization. What each role grants is one table per tenant, shared by every customer in it. The permissions a role grants are looked up from the role each time they are read and never stored per contact, so a role change shows up in the next read. A grant is one permission key such as orders.create.

The Customers app only lists the grants. The app that owns the action enforces them, so a grant has an effect only where an app checks it (see the note under the table in step 1).

1. Map the org chart onto roles

The built-in roles cover the jobs a buying organization has to tell apart. Match each person to one of them before you create anything:

The person isRoleMeant to doNot meant to do
The controller, an auditorviewerSee the catalog, the organization's orders and its peopleBuy anything
An assistant who fills cartsrequesterFill carts, submit a cart for approval (step 6), see ordersPlace an order directly. Only a Procurement rule or an attributed Orders call enforces this (step 6)
The purchaserbuyerPlace orders, keep carts and addressesSign off orders, manage people
The department headapproverEverything a buyer does, plus approve purchase requests in Procurement (step 6)Manage people or the organization
The customer's own administratoradminEverything, plus the organization's people, roles and master dataNothing in this table
Today, enforce roles in your own back end. The platform acts on grants in one place only: the Orders app, when an order is placed or approved on an attributed call. How a storefront back end attributes its calls is not documented yet (as of 2026-10). The table says what each role is meant for, not what the platform stops. A request is attributed when the platform knows which signed-in buyer it is for, not just which API key sent it. A call made with just your API key, like every example in this guide, is not attributed: it is not narrowed to a buyer and not refused, so check the buyer's permissions on your own server before you act for them. Sign a buyer in shows how your server signs a buyer in. Check that an app enforces a grant before you promise a customer anything built on it, such as that a role "cannot see prices".

This is what each grant does today, as of 2026-10:

GrantWhat happens
orders.create, orders.requestThe Orders app checks them when an order is placed, but only on a request the platform can attribute to the buyer, meaning it knows which signed-in buyer the request is for and not just which server key sent it (Sign a buyer in, Principal resolution).
orders.approveOn an attributed request, a holder is exempt from the Orders app's require_approval_above_value setting (per market; 0 turns it off) and places directly whatever the order is worth. Without it, an order above that value is created pending and order.requested fires (Orders). On an attributed call it also lets the holder approve a pending order of their own organization, never their own order, with POST /v1/orders/<ORDER_ID>/approve; no amount ceiling applies.
catalog.read, carts.manageEnforced by no app, so withholding them hides nothing.
orders.read, addresses.manage, contacts.read, contacts.manage, organization.manageNo app refuses a call for lack of them. They are data for your own storefront or back office to act on.

The roles above carry these grants by default, which is what a tenant that never configured its role table has:

Grantviewerrequesterbuyerapproveradmin
catalog.readYesYesYesYesYes
carts.manage–YesYesYesYes
orders.create––YesYesYes
orders.request–Yes–YesYes
orders.approve–––YesYes
orders.readYesYesYesYesYes
addresses.manage––YesYesYes
contacts.readYes––YesYes
contacts.manage––––Yes
organization.manage––––Yes

Your tenant's table is configurable, so read it:

Read the role table
curl -s https://api.revenexx.com/v1/customers/roles \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  | jq '{source, roles: [.roles[] | {key, permissions}]}'

source says which table you got: tenant for a mapping your tenant configured, defaults for the built-ins. A tenant whose role table is locked (below) always reads defaults, whatever it stored before.

If the customer's jobs do not fit five roles, change what a role grants, or add a role of your own with POST /v1/customers/roles or by copying an existing one. A PUT to a role that does not exist answers 404. The whole new set goes in one call, and an empty list revokes everything. The example below sends the built-in requester grants from the table above. That changes nothing only if your tenant's requester still reads that way, so read it first with the call above; if it was customised, this overwrites it. Put your own list in its place:

This replaces the role for every contact who holds it in the tenant, across all customers, so make the edit only when every holder should change. Read the current role first with GET /v1/customers/roles and send the full intended permissions list, because anything you leave out is revoked.
Change what a role grants
curl -s -X PUT "https://api.revenexx.com/v1/customers/roles/requester/permissions" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "permissions": ["catalog.read", "carts.manage", "orders.request", "orders.read"] }'

A tenant can lock the role table with the Customers app's custom_roles_enabled setting, which is on by default. Switched off, an edit, a create or a copy answers 403 with code: role_ledger_locked (the API calls the role table the role ledger), and the built-in grants apply. Settings are changed in Cockpit, not through the API; you can read the current value with GET /v1/settings/apps/customers (Settings). Roles and permissions has the full permission vocabulary.

2. Give each contact a role

Find the contact's id by its email, which is unique in the tenant:

Find the contact
curl -s "https://api.revenexx.com/v1/customers/contacts?email=einkauf@example.com" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  | jq -r '.items[0].id'

A contact created without a role gets the tenant's default role, buyer unless you changed it. Set the role when you create the contact (POST /v1/customers/contacts, as in Contacts, with customers.contacts.create), or change it on an existing one. PUT is a partial update: only the fields you send change, so a body with just role leaves the rest of the contact as it was.

Set a contact's role
curl -s -X PUT "https://api.revenexx.com/v1/customers/contacts/<CONTACT_ID>" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "role": "approver" }'

An unknown role is refused with a 400 that lists the roles that exist. One contact holds one role: a person who both buys and approves is an approver, which includes everything a buyer does.

order_approval_limit is an amount in the market's currency (a market is a business context inside the tenant, such as a country or a B2B storefront, with its own currencies), not a grant. It always travels with order_approval_mode, which says how far the person's orders.approve reaches: none (the default), limited (up to the amount) or unlimited. The amount is set only for limited, so a null limit does not mean "no ceiling": a contact created before the mode existed with no limit now reads as none. Send both in the same PUT body:

Set an approval ceiling
curl -s -X PUT "https://api.revenexx.com/v1/customers/contacts/<CONTACT_ID>" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "order_approval_mode": "limited", "order_approval_limit": 25000 }'

An amount sent with any mode other than limited, or limited without an amount above 0, is refused with a 400. To lift the ceiling, send {"order_approval_mode": "unlimited"} alone; the stored amount is cleared with it. The pair means nothing without orders.approve, and no app reads it as a cap today (as of 2026-10), Orders and Procurement included, so keep your own ceiling: a Procurement approval rule or your own code.

3. Check what a contact can do

Read the effective grants
curl -s "https://api.revenexx.com/v1/customers/contacts/<CONTACT_ID>/permissions" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY"
Response
{
  "active": true,
  "contact_id": "…",
  "organization_id": "…",
  "role": "approver",
  "order_approval_mode": "unlimited",
  "order_approval_limit": null,
  "permissions": ["catalog.read", "carts.manage", "orders.create", "orders.request", "orders.approve", "orders.read", "addresses.manage", "contacts.read"]
}

Read active before permissions. It is false while the contact is blocked or its registration is not approved. Such a contact still holds a role, and what permissions lists meanwhile is the Customers app's blocked_contact_behavior setting, set per market in Cockpit:

  • keep (the default) returns the role's grants unchanged, so the list alone does not show that the contact is blocked: you gate on active yourself.
  • catalog_only returns only catalog.read.
  • deny_all returns an empty list.

This call reads the setting for the market in the request's X-Revenexx-Market header, and the tenant value when you send none, so a market's answer can differ from the tenant's. A storefront back end gets the same block inline from the sign-in and /me answers, so it does not need this call to decide what to render.

4. Know what a missing grant looks like

Buyers reach the API through your storefront back end, never with a Bearer token of their own (Sign a buyer in). The Bearer token on API usage is for staff who sign in through ID, not for buyers (Buyers and staff sign in differently). The app that owns an action refuses it when the signed-in buyer lacks the grant. When the request is attributed to the buyer, placing an order for one who holds neither orders.create nor orders.request answers 403. A request made with only your API key is not attributed and is not refused, which is why step 3's check belongs on your server too. None of this guide's calls is attributed, so you cannot reproduce the refusal with them; this is what it looks like:

A refused order
{ "code": "permission_denied", "message": "this account may not place or request orders" }

The answer also carries an error field like every gateway error (API usage); branch on code. A buyer who holds only orders.request is not refused: the order is accepted as pending instead of placed. It then waits as pending until someone approves it or cancels it. Your back office approves it with POST /v1/orders/<ORDER_ID>/approve and its API key, and so can a colleague in the same organization who holds orders.approve. Rejecting is a cancel, POST /v1/orders/<ORDER_ID>/cancel, which only your back office or the buyer who placed it can make: a buyer's call does not reach a colleague's order. A buyer's call also lists only that buyer's own orders, so an approver screen gets the waiting orders from your back office (GET /v1/orders?status=pending&organization_id=<ORGANIZATION_ID>) or from the order.requested event. So a requester who orders through Orders alone parks every order until one of them acts.

Approve a pending order
curl -s -X POST "https://api.revenexx.com/v1/orders/<ORDER_ID>/approve" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY"

<ORDER_ID> is the order's id, not its order number. A buyer's attributed call answers 403 with self_approval on the buyer's own order and permission_denied without orders.approve; an order of another organization answers 404, as an unknown id does; and an order that is already placed, cancelled or completed answers 422 with order_not_pending.

If you do not want a single-step approval in Orders, pick one of these two options:

  • Route the approval through Procurement (step 6). Your checkout code decides this: it calls POST /v1/procurement/submit instead of placing the order through Orders. Procurement's rules then decide whether the cart becomes an order or a request for approval, and a request becomes an order once it is approved.
  • Without Procurement, remove orders.request from requester. Read requester first, then send the step 1 PUT with its list minus orders.request; on the defaults that is ["catalog.read", "carts.manage", "orders.read"]. It applies to every customer in the tenant. requester is the only built-in role that holds orders.request without orders.create. An attributed order from a requester is then refused with the 403 above instead of parked.

Two consequences for the storefront. Hide a button when the grant is missing, so the buyer does not meet a 403 in normal use. And still handle the 403, because the role may have changed since the page rendered.

5. Decide new registrations

When the Customers app's registration_mode setting is approval_required (set per market in Cockpit, falling back to the tenant value), a self-registration is an application: the contact is pending, its login is disabled, and a company the application founded is blocked. Your back office decides it, and nothing happens until it does. List what is waiting:

List pending applications
curl -s "https://api.revenexx.com/v1/customers/contacts?registration_status=pending" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY"
Response, trimmed
{
  "items": [
    {
      "id": "…",
      "email": "einkauf@neu-gmbh.example",
      "first_name": "Anna",
      "last_name": "Berger",
      "organization_id": "…",
      "role": "admin",
      "is_primary": true,
      "registration_status": "pending"
    }
  ]
}

The row describes the person and the role they arrived with, not the company. Read the company with GET /v1/customers/organizations/<ORGANIZATION_ID> for its name, vat_id and status. An applicant who founded a new company arrives as its admin with is_primary: true, and that company is blocked until you approve.

Check the role first, because the applicant arrives with one you did not choose. An applicant who founds a company is an admin of it. Anyone else gets the market's registration_default_role setting, otherwise the role flagged as default on the Roles page in Cockpit (buyer unless you changed it). Change it with step 2, then decide:

Approve or reject
# approve: the password the applicant chose works at once
curl -s -X POST "https://api.revenexx.com/v1/customers/contacts/<CONTACT_ID>/registration/approve" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" -d '{ "decided_by": "sales@merchant.example" }'

# reject: a reason is mandatory
curl -s -X POST "https://api.revenexx.com/v1/customers/contacts/<CONTACT_ID>/registration/reject" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" -d '{ "reason": "No verifiable VAT ID supplied.", "decided_by": "sales@merchant.example" }'

decided_by is optional free text naming who decided, such as an email address. It is stored on the contact, and left out it stays empty. A rejected applicant can still be approved later, and a repeat of either decision is a no-op, so a retry is safe. Contacts has the states and the effect on the company.

Each step fires an event your systems can follow: contact.registered when an application arrives (it fires for pending ones too), then contact.registration_approved or contact.registration_rejected. These are the event names. The topic you subscribe to puts the vendor and the app in front, such as revenexx.customers.contact.registered; copy the exact strings from the topic list in Cockpit's webhook endpoint form (Event types). Webhooks and Choose an event surface cover how to consume them.

6. Keep purchase approvals in step

Purchase approvals do not read the table you edited in step 1. The Procurement app, which runs purchase approvals, honours four roles and maps them to its own fixed permissions:

RolePlaces an orderApproves
requesterNoNo
buyerYesNo
approver, adminYesYes

"No" means the role lacks that permission. Whether that matters is up to a Procurement rule, below.

A viewer grants nothing there, and so does any other role outside that table. Re-mapping a role in step 1 changes nothing in Procurement: a buyer you gave orders.approve still cannot approve there. A person you made an approver here can also approve in Procurement; in Orders their orders.approve exempts them from the approval threshold and lets them approve a colleague's pending order (step 4). Use the built-in role names for anyone who takes part in an approval.

In Procurement a role and a rule do different jobs, and both apply:

  • The rule decides whether a cart needs approval, and who is asked. A role routes nothing by itself: a requester holds no place-order, but their cart goes to approval only if a rule says so, for example contactMissingPermission with place-order. If no rule fires, the cart becomes an order whatever its buyer's role.
  • The role decides who may approve at all. Only approver and admin can. If the rule also names a person or a role as the approver, the person deciding has to be that person or hold that role.
  • order_approval_mode and order_approval_limit are read nowhere in Procurement. Its limits come from the rules (constantLimit) and from cost-center budgets and personal limits (a per-person spend limit kept in Cost Centers, not the order_approval_limit on the contact).

Set up purchase approvals has the rules.

Troubleshooting

SymptomCauseFix
A role change is refused with 400The role is not in the tenant's role table; the answer lists the ones that areRead the role table (step 1) and use one of its keys
An attributed order request gets 403 permission_deniedThe role lacks orders.create and orders.requestHide the button from the grants (step 3) and assign the right role (step 2)
A viewer placed an orderThe request carried only your API key, so no buyer was attached to itCheck the buyer's grants on your server before you call (step 3)
A contact reads active: falseBlocked, or the registration is pending or rejected (those cannot sign in at all)Decide the registration (step 5) or unblock the contact
Someone with orders.approve cannot approve in ProcurementProcurement does not read the step 1 table; only approver and admin approve thereGive them the approver role (step 6)
Editing a role answers 403 role_ledger_lockedThe tenant's custom_roles_enabled setting is off, so the role table is locked and reads source: defaultsWork with the built-in grants (step 1), or have an operator switch the setting on in Cockpit

Where to go next

Create one test contact per role and read each one's effective grants before you hand the customer anything. Then read:

Was this page helpful?