API Permissions
API permissions decide which roles may read and change data through the API. The server checks them on GraphQL, REST, MCP and agent-tool calls. They are separate from UI permissions, which decide what a screen renders: a role that can see a button still needs the API permission for the call behind it.
The check runs where the environment turns it on (Turning It On).
YAML Structure
A module grants API permissions in its permissions: block, with the same keys as UI permissions:
permissions:
- name: API/Contact/Store/Create
displayName:
en-US: "API: Create Stores"
description:
en-US: Create stores through the API
roles:
- Administrator
- Store Manager
| Attribute | Description |
|---|---|
name | The API name, in the format below. cxtms does not check names, so a misspelling passes validation. |
roles | The roles that receive the name. With no roles, nobody holds it. |
displayName, description | Localized text for people reading the permission. |
Names
API/<Entity>[/<Subtype>]/<Action>, compared case-insensitively.
| Part | Meaning |
|---|---|
Entity | The singular entity name, without the operation's verb. createContact and importContacts both use Contact. |
Subtype | The record's type, on writes to the entities in the next table. Reads have no subtype. |
Action | Create, Update, Delete, Read, List, Get or Execute. |
| Entity | Subtype | Example |
|---|---|---|
Contact | ContactType | API/Contact/PoolPoint/Delete |
Order | OrderType | API/Order/DeliveryOrder/Update |
Attachment | AttachmentType | API/Attachment/Picture/Create |
Charge | ChargeType | API/Charge/Expense/Create |
AccountingTransaction | AccountingTransactionType | API/AccountingTransaction/Invoice/Update |
Rate | RateType | API/Rate/ClientRate/Create |
DispatchRoute, DispatchRouteTemplate | RouteType | API/DispatchRoute/Pickup/Create |
Calendar | CalendarType, free text, once a module declares one for the action | API/Calendar/Create while none is declared |
Other entities have no subtype: API/Commodity/Update.
Declaring a Name Grants It
Every name a request needs is enforced whether or not a module declares it. A user passes when one of their roles holds the name or a wildcard that covers it. A name no role holds refuses everyone except system administrators.
A declaration gives the name to the roles it lists. It does not switch a check on:
- Each name is granted on its own.
API/Contact/Store/Createdoes not grant StoreUpdateorDelete. - A misspelled declaration grants nothing.
API/Contact/Stroe/Createis a valid string, so nothing reports the mistake. The role is refused when it makes the call. - Wildcards grant every name they cover. For
API/Contact/Store/Createthe server looks for that name,API/Contact/Store/*,API/Contact/*andAPI/*.API/Contact/*stops at that entity: it does not coverAPI/ContactAddress/List. - Wildcards exist only under
API/. ASystem/...wildcard grants nothing, andAPI/*grants no UI name. - Declaring a subtype raises the bar for requests of unknown type. Once
API/Contact/Customer/Createis declared, an import needs it too (see the next section). Wildcards do not count as declaring a subtype. - A soft-deleted module's permissions grant nothing.
Organization administrators keep full access through the API/* wildcard that the core app declares for the Administrator role. There is no code bypass for that role, so every other role needs the API names declared for it.
Which Name a Request Needs
| Request | Name it needs |
|---|---|
| Create with a type | The subtype name only: API/Contact/Store/Create. API/Contact/Create does not satisfy it. |
| Update or delete by id | The stored type: deleteContact on a Pool Point needs API/Contact/PoolPoint/Delete. |
Delete, soft delete or restore (an update that sets isDeleted) | Delete. |
| Change the type from A to B | Update and Delete on A, Create and Update on B. |
| Create with a missing or unrecognized type | Create on every declared subtype. |
| Free-text type no module declares for the action | The action on every declared subtype, so a user cannot step around the declarations by inventing a type. |
| Import | Create on every subtype declared for Create, and Update on every subtype declared for Update, because each row may carry its own type and rows match existing records of any type. With no subtype declared, API/Contact/Create and API/Contact/Update. |
Child record, such as deleteContactAddress, commodity tags or order documents | Update on the parent, with the parent's subtype: an address on a Store needs API/Contact/Store/Update. Moving a child to a new parent checks both parents. |
| Entity with no subtype | The entity-level name: API/Commodity/Update. |
So a role that holds API/Contact/Store/Create and API/Contact/Store/Update is still refused on importContacts in an organization that also declares API/Contact/PoolPoint/Create.
Reads
| Read | Name it needs |
|---|---|
Any GraphQL field, root or nested, that returns Order, Commodity, InventoryItem or Attachment | API/<Entity>/Read. Selecting attachments on a commodity needs API/Attachment/Read as well as API/Commodity/Read. |
| A root query of most other entities, returning many records | API/<Entity>/List: API/Contact/List. |
| A root query of most other entities, returning one record | API/<Entity>/Get: API/OrderStatus/Get. |
- Nested fields of
ListandGetentities are not checked, so an order still shows its status to a role withoutAPI/OrderStatus/Get. - Mutation payloads are not read-checked.
API/<Entity>/*grantsListandGettogether.- A refused field resolves to
nullwith anAUTH_NOT_AUTHORIZEDerror. A refused non-null field nulls its parent, as GraphQL requires. - REST read endpoints that send a gated query are checked the same way and answer 403.
- Reads of a user's own records stay open:
currentUser, the user's ownorganizationUser, and theorganizationandorganizationsfields used by the profile and organization switcher. So doappRoutes,appComponentsandappComponent, which the app renders from.
Workflows
Tasks run trusted. Only the outermost request is checked. A workflow's tasks, Flow transitions, after-save handlers and the commands a handler sends are not checked against the caller, so a Query/GraphQL step or Contact/Create@1 is never refused for the user who started the workflow.
What gates a workflow is who may start it:
| Workflow | A user's direct run needs |
|---|---|
Declares permission | That permission. It is checked whether or not API permissions are enabled. |
Declares no permission | API/Workflow/Execute. |
runAs: SYSTEM with no permission | Refused for every user, because it would hand them system privileges. Saving one that users can start fails validation with STRUCT-011. |
A direct run is one where a user picks the workflow: GraphQL executeWorkflow, MCP tools, agent tools and the workflow trigger endpoint. API/Workflow/Execute does not apply to entity triggers, child workflows, Public API calls, webhooks and background jobs.
UI and API Names
| UI names | API names | |
|---|---|---|
| Format | Free-form: System/Contacts/Update, System/Contact/Store/Create | API/<Entity>[/<Subtype>]/<Action>, always singular |
| Checked by | The web and mobile clients, on components, routes and hasPermission | The server, on API calls |
| Matching | Exact name, case-sensitive | Case-insensitive, and wildcards grant |
A UI name does not grant an API name, so a role needs both to use a button and the mutation behind it. Declare both with the same roles:
permissions:
- name: System/Contact/Store/Create
displayName:
en-US: Create Stores
roles:
- Administrator
- Store Manager
- name: API/Contact/Store/Create
displayName:
en-US: "API: Create Stores"
roles:
- Administrator
- Store Manager
currentUser.permissions returns every declared name the user's grants cover, including API names covered only by a wildcard, and leaves out permissions of soft-deleted modules.
A few server-side checks use System/ names by their exact name, such as System/UserGroups/Update and System/TimeTracking/Manage. API/* does not pass them.
Record Ownership
The record a request addresses must belong to an organization the caller can access, and must match the request's organization when it names one. Otherwise the request fails with Not authorized for this record., which does not name the record's organization. This check runs whether or not API permissions are enabled.
What Is Not Checked
- System administrators: members of the System Administration organization. An organization's
Administratorrole is checked like any other, and passes throughAPI/*. - Requests with no user, such as background jobs.
- Anything running inside a checked request or a workflow (see Workflows).
Coverage
The table lists what the server checks today. An operation it does not list is not gated by an API permission yet. A write is checked on the command it sends, so GraphQL, MCP and agent tools behave the same, and so does a REST endpoint that sends a listed command.
List and Get apply to root queries; Read applies to every field that returns the entity.
| Area | Writes (Create, Update, Delete) | Reads |
|---|---|---|
| Contacts | Contact by ContactType. Addresses and payment methods count as Update on the contact. | Contact, ContactAddress, ContactPaymentMethod: List, Get |
| Orders | Order by OrderType. OrderCommodity, OrderDocument and OrderEntity count as Update on the order. | Order: Read. OrderCommodity, OrderDocument, OrderEntity: List, Get |
| Commodities and inventory | Commodity, InventoryItem. Commodity tags count as Update on the commodity. | Commodity, InventoryItem: Read |
| Tracking | TrackingEvent, CommodityTrackingNumber | TrackingEvent, CommodityTrackingNumber: List, Get |
| Attachments | Attachment by AttachmentType | Attachment: Read |
| Accounting | Charge, AccountingTransaction and Rate by type. Payment, AccountingItem, AccountingAccount, Discount, SalesTax, PaymentTerm. Linking charges counts as Update on the transaction. | The same entities: List, Get |
| Moves and routes | Job, OrderMove, Route, RouteLeg. DispatchRoute and DispatchRouteTemplate by RouteType. Legs, stops and route links count as Update on the parent, and assignJob as Update on the job. | The same entities, plus OrderMoveLeg, DispatchRouteStop, RouteRouteLeg: List, Get |
| Calendars | Calendar. Events, availability, business hours and links count as Update on the calendar. | Calendar: List, Get |
| Equipment | Equipment | Equipment: List, Get |
| Users and access | UserEmployee, OrganizationUser, UserGroup (its roles and members count as Update). Organization: Update. Secret: Update, Delete. | User, UserEmployee, OrganizationUser, OrganizationConnection, UserGroup, UserGroupRole, ApplicationUserRole, AppPermission, Role: List, Get |
| Configuration | Workflow, AppModule, AppManifest, OrganizationConfig, Division | The same entities, plus DocumentTemplate, EntityField, CustomField, AuditLog, Search: List, Get |
| Reference data | OrderStatus, CommodityStatus, ContactStatus, JobStatus, EquipmentStatus, DispatchRouteStatus, DispatchRouteStopStatus, OrderMoveStatus, OrderMoveLegStatus, CommodityType, PackageType, PackageCategory, EquipmentType, ContainerType, ContainerDescription, City, Country, State, Port, PostalCode, Currency, ModeOfTransportation, Vessel, Terminal, WarehouseLocation, WarehouseZone, Lane, EventDefinition, CustomCode, Tag | The same entities: List, Get |
| Operations | Transmission, KnowledgeAsset. ActionEvent: Update, Delete. Notification: Create. | Transmission, KnowledgeAsset, ActionEvent, TimePunch, TelemetryRecord, TelemetrySource, TelemetrySubjectState, EmployeeClockState: List, Get |
| Workflow runs | API/Workflow/Execute (see Workflows) | None |
Turning It On
ApiPermissions:Enabled is false by default. While it is off, the permission check runs no queries and logs nothing. Record ownership and workflow.permission do not depend on it.
"ApiPermissions": { "Enabled": true, "Mode": "Enforce" }
ApiPermissions:Mode is Enforce (the default) or Audit:
- Enforce refuses a missing permission.
- Audit logs a missing permission as a Warning,
ApiPermissionAuditRefusal {OrganizationId} {UserId} {Permission} {Source}, and lets the request through.
To introduce permissions in an environment, set Enabled=true with Mode=Audit, fix the declarations and roles the audit log shows, then switch to Enforce.
Errors
| Check | GraphQL | REST |
|---|---|---|
| Missing permission on a write | AUTH_NOT_AUTHORIZED, Missing permission <name>., extension permission | 403 |
| Missing permission on a read | The field is null, with AUTH_NOT_AUTHORIZED | 403 |
| Record ownership | FORBIDDEN, Not authorized for this record. | 401 |
workflow.permission | FORBIDDEN, You do not have permission to run '<workflow>'. | 401 (Public API: 403) |
A refused write names one missing permission at a time, so a role missing several sees them in turn.
Best Practices
- When a module adds a screen or action, declare the API name of every query, mutation and workflow it calls, with the same roles as the UI name that shows it.
- Use exact names for anything below full access.
API/Commodity/*would also handDeleteto a role that only scans. - Keep
Administratorinroleslike any other role. It has no code bypass, so the entry keeps working if theAPI/*wildcard is ever narrowed. - To restrict a call, list only the roles that may make it, and check that no other role holds a covering wildcard.
- Give user-callable workflows their own
permissionrather than relying onAPI/Workflow/Execute, which opens every workflow without one.
Related Topics
- Permissions: UI permission names and how components use them
- Modules: the
permissions:block in an app module - Workflow YAML: the
permissionandrunAskeys - Security: how permissions, roles and user groups relate