Roles and permissions

How buyer-side grants work on revenexx — the per-tenant role catalogue, replacing a role's permission set, reading a contact's effective grants, and the principal resolution the gateway calls.

This page is about buyer-side permissions: what a person at a customer company may do in a storefront. It is not about what a Cockpit operator may see (that is a view's permissions array — see Permissions and scope) and not about what your app is allowed to reach (that is manifest.permissions).

The model in three sentences

A role is held by a contact and applies inside that contact's organization — there is no global customer role. Permissions are derived from the role at read time and never stored per contact, so a role change takes effect immediately and cannot leave a stale grant behind. The role-to-permission mapping is per tenant and configurable.

Read the catalogue

Request
curl https://api.revenexx.com/v1/customers/roles \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..."

One read answers every role a contact of this tenant can hold, the permissions each grants, and the built-in permission vocabulary those grants are drawn from.

Built-in roles, least to most privileged:

KeyTitle
viewerViewer
requesterRequester
buyerBuyer
approverApprover
adminAdministrator

The response's source says which answer you got: tenant means the configured mapping answered, defaults means this tenant has no roles yet (or custom_roles_enabled locks the ledger) and the built-ins answered.

POST /v1/customers/roles/defaults seeds the built-in roles.

GET /v1/customers/permissions/ledger is what the role editor offers. It lists the customers app's own permission keys with their words per language, their area, the owning app (always customers) and whether the platform enforces each yet (as of 2026-10), and the built-in set of every built-in role, which is what resetting a role to its default restores. It takes no body. Keys that other apps declare can still be granted (below), but they do not appear in this list.

Change what a role grants

Request
curl -X PUT "https://api.revenexx.com/v1/customers/roles/buyer/permissions" \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..." \
  -H "Content-Type: application/json" \
  -d '{"permissions":["orders.read","orders.create","contacts.read","addresses.manage"]}'

The whole new set in one call. That is the shape a role editor actually produces, and the one that cannot leave a half-applied grant behind if a second call fails. Duplicates and blanks are ignored; an empty array revokes everything.

It seeds the built-in roles first when the tenant has none, so editing works without calling /defaults.

POST /v1/customers/roles creates a role of the tenant's own with a title and its permissions; the key is made from the title when you send none, and it is fixed from then on. An existing key answers 409 duplicate_role_key. To make a role that differs from an existing one by a grant or two, copy it first and then change the copy:

Request
curl -X POST "https://api.revenexx.com/v1/customers/roles/buyer/copy" \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..." \
  -H "Content-Type: application/json" \
  -d '{"title": "Buyer, plant 2"}'

title is required. key is made from it when you leave it out, and description defaults to the source's. The copy is the tenant's own role, never a built-in and never the default, and the source may be a built-in the tenant never wrote down.

Permission keys are free text on purpose: they belong to whichever app declared them, and a grant for an app that is not installed simply has nothing to act on. That is what makes the ledger extensible — your own app declares its grants in provides_permissions and a tenant can hand them to a role without a release of the customers app.

Edit a role

OperationWhat it does
PUT /v1/customers/roles/{key}Edits a role's words, its rank, its default flag and whether it is still offered. The key and the grants do not change here: grants go through PUT /v1/customers/roles/{key}/permissions.

A role marked inactive is no longer offered to a contact. Every contact that already holds it keeps it and its grants. The body is a partial update: send only what changes. The fields are title, labels, description, descriptions, tone, position (the rank, an integer), is_default, and active (a boolean). A different key answers 400, and a successful edit answers 200. A built-in role and the default role must stay on offer: retiring either answers 409 (built_in_role_must_stay_active or default_must_stay_active), and so does making an inactive role the default (inactive_cannot_be_default). Making a role the default takes the flag off the previous default. A tenant that locks its roles to the built-ins gets 403 (role_ledger_locked).

Read a contact's effective grants

Request
curl "https://api.revenexx.com/v1/customers/contacts/{contact_id}/permissions" \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..."

Computed from contacts.role on every call — nothing is persisted, so this always reflects the role the contact holds right now.

FieldMeaning
permissionsWhat this role may do.
roleThe role held, and the only input to permissions.
organization_idThe organization the role applies inside. Null for a standalone buyer.
activefalse while the contact is blocked or its registration is pending/rejected — it holds the role but must not act on it.
order_approval_modeHow far orders.approve reaches: none, limited (up to order_approval_limit) or unlimited. Read this, not the null limit, to know whether there is a ceiling.
order_approval_limitAmount ceiling in the market's currency, set only when order_approval_mode is limited. Only meaningful together with orders.approve.

Check active before you check permissions. A blocked contact still has a role.

The same block comes back inline on POST /v1/customers/auth/login and /auth/me, so a storefront back end does not need a second call to decide what to render.

Principal resolution

POST /v1/customers/principal/resolve is the capability the gateway calls to turn a caller's principal assertion into the permission set it forwards to every other app.

Request
curl -X POST https://api.revenexx.com/v1/customers/principal/resolve \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..." \
  -H "Content-Type: application/json" \
  -d '{"contact_id":"…"}'

The body names exactly one reference: contact_id for a contact a storefront back end asserted, or user_id for a platform login, matched against the contact's external_user_id. The answer has the same shape either way: the grants described above (permissions, role, organization_id, active, order_approval_mode, order_approval_limit), plus contact_id (the person the grants belong to) and two fields that only name the person: display_name (first and last name, or null) and email (or null). The gateway puts the name and email into the record history. Use them to label a person, never to decide what they may do.

A reference that matches no contact answers 404; on the user_id route that is the normal answer for a platform user this tenant has no contact for.

This is the hot path of every attributed storefront request: one contact read plus the tenant's role map. You normally do not call it — the gateway does, and your app receives the resolved permissions. It is documented because it is what makes buyer identity available to every commerce app rather than only to the customers app.

A blocked or pending contact always resolves with active: false. What its permissions then say is the tenant's blocked_contact_behavior setting: keep (the default — the role's grants), catalog_only, or deny_all.

Two amounts that are not grants

contacts.order_approval_limit and organizations.credit_limit are amounts, and each comes with a mode that says whether there is a ceiling at all: order_approval_mode (none, limited, unlimited) and credit_limit_mode (unset, limited, unlimited). The amount is set only for limited, so a null amount never means "no ceiling" on its own.

Neither pair confers a grant. An approval mode means nothing without orders.approve from the role. No app enforces either pair as a cap today (as of 2026-10), so check both in your own rules.

Where to go next

Was this page helpful?