Give buyer staff the right permissions
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 listprints 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.listandcustomers.roles.permissions.replacefor the role table,customers.contacts.list,customers.contacts.create(only to create a contact with a role),customers.contacts.updateandcustomers.contacts.permissionsfor contacts, andcustomers.registrations.approveandcustomers.registrations.rejectfor 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.
curlandjq, which the examples use to trim the answers.
Every call goes through the API gateway:
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).
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 is | Role | Meant to do | Not meant to do |
|---|---|---|---|
| The controller, an auditor | viewer | See the catalog, the organization's orders and its people | Buy anything |
| An assistant who fills carts | requester | Fill carts, submit a cart for approval (step 6), see orders | Place an order directly. Only a Procurement rule or an attributed Orders call enforces this (step 6) |
| The purchaser | buyer | Place orders, keep carts and addresses | Sign off orders, manage people |
| The department head | approver | Everything a buyer does, plus approve purchase requests in Procurement (step 6) | Manage people or the organization |
| The customer's own administrator | admin | Everything, plus the organization's people, roles and master data | Nothing in this table |
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:
| Grant | What happens |
|---|---|
orders.create, orders.request | The 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.approve | On 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.manage | Enforced by no app, so withholding them hides nothing. |
orders.read, addresses.manage, contacts.read, contacts.manage, organization.manage | No 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:
| Grant | viewer | requester | buyer | approver | admin |
|---|---|---|---|---|---|
catalog.read | Yes | Yes | Yes | Yes | Yes |
carts.manage | – | Yes | Yes | Yes | Yes |
orders.create | – | – | Yes | Yes | Yes |
orders.request | – | Yes | – | Yes | Yes |
orders.approve | – | – | – | Yes | Yes |
orders.read | Yes | Yes | Yes | Yes | Yes |
addresses.manage | – | – | Yes | Yes | Yes |
contacts.read | Yes | – | – | Yes | Yes |
contacts.manage | – | – | – | – | Yes |
organization.manage | – | – | – | – | Yes |
Your tenant's table is configurable, so read it:
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:
GET /v1/customers/roles and send the full intended permissions list, because anything you leave out is revoked.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:
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.
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:
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
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"
{
"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 onactiveyourself.catalog_onlyreturns onlycatalog.read.deny_allreturns 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:
{ "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.
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/submitinstead 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.requestfromrequester. Readrequesterfirst, then send the step 1PUTwith its list minusorders.request; on the defaults that is["catalog.read", "carts.manage", "orders.read"]. It applies to every customer in the tenant.requesteris the only built-in role that holdsorders.requestwithoutorders.create. An attributed order from a requester is then refused with the403above 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:
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"
{
"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: 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:
| Role | Places an order | Approves |
|---|---|---|
requester | No | No |
buyer | Yes | No |
approver, admin | Yes | Yes |
"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
requesterholds noplace-order, but their cart goes to approval only if a rule says so, for examplecontactMissingPermissionwithplace-order. If no rule fires, the cart becomes an order whatever its buyer's role. - The role decides who may approve at all. Only
approverandadmincan. 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_modeandorder_approval_limitare 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 theorder_approval_limiton the contact).
Set up purchase approvals has the rules.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
A role change is refused with 400 | The role is not in the tenant's role table; the answer lists the ones that are | Read the role table (step 1) and use one of its keys |
An attributed order request gets 403 permission_denied | The role lacks orders.create and orders.request | Hide the button from the grants (step 3) and assign the right role (step 2) |
A viewer placed an order | The request carried only your API key, so no buyer was attached to it | Check the buyer's grants on your server before you call (step 3) |
A contact reads active: false | Blocked, 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 Procurement | Procurement does not read the step 1 table; only approver and admin approve there | Give them the approver role (step 6) |
Editing a role answers 403 role_ledger_locked | The tenant's custom_roles_enabled setting is off, so the role table is locked and reads source: defaults | Work 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:
- Roles and permissions: the full permission vocabulary and how a role is resolved.
- Set up purchase approvals: cost centers, budgets and the approval rules.
- Sign a buyer in: registration, sign-in and the session from the storefront side.
- Contacts and Organizations: the records these roles hang on.