Permissions and scope
API v3 authorization answers two questions, in this order: does the caller, a credential or an administrator, have the permission the operation requires, and is the requested resource within the scope of that permission? Without the permission, the API returns 403 permission_denied. With the permission but outside its scope, the API returns 404 not_found, the same response as for a resource that does not exist.
Permissions
Permission codes follow the pattern <plural resource>.<verb>: products.read, products.update, products.publish, users.create, webhooks.replay. Retrieve the full catalog for your version over HTTP:
GET /api/v3/credentials/permission-catalogEach entry includes the code, the resource type, a description, whether the permission can be delegated, and the release that introduced it. The catalog only grows: an existing code never changes meaning.
Three kinds of action require a dedicated permission rather than the generic write permission:
- State transitions: publishing a product requires
products.publishin addition toproducts.update. - Sensitive fields: personal user data in listings and reports requires
users.read_personal. Without it, the key is omitted from the response. It also gates setting a webhook'spayload_detailtofull; see Webhooks. - High-impact operations: revoking a certificate (
certificates.revoke), replaying a webhook (webhooks.replay), and adjusting progress (progress.adjust).
Each group page in the API reference lists the exact permissions required by each operation. In the OpenAPI document, x-cademi-permissions declares them. Entries without any_of are all required. Entries that share the same any_of value are alternatives: any one of them grants access. For example, GET /reports/rankings accepts reports.read or rankings.read. When a permission depends on the stored state of the resource, the operation also carries x-cademi-manual-checks, which is informative: for example, changing the name or content of a custom code block whose type is pushalertco requires custom_code.manage_pwa (PATCH /settings/custom-code/{custom_code_id}).
Policies, policy revisions, and selectors
A credential's access is defined by its policies. Together, they form a policy revision, and the one in effect is the active policy revision. You manage policies one at a time: add a policy (POST /credentials/{credential_id}/policies), update one (PATCH /credentials/{credential_id}/policies/{policy_id}), or remove one (DELETE /credentials/{credential_id}/policies/{policy_id}). Each change publishes a new policy revision that contains the full set of policies, and a policy keeps its policy_id across revisions.
When you update a policy, send the ETag from the retrieve operation in the If-Match header. If the credential changed in the meantime, the API returns 412 revision_mismatch and publishes nothing.
Each policy combines permissions with resource selectors:
{
"capabilities": ["products.read", "modules.read", "lessons.read"],
"resources": [
{ "type": "product", "selector": "ids", "ids": ["prd_12", "prd_34"] }
]
}| Selector | What it covers |
|---|---|
instance | The entire account, for permissions that have no resource of their own (settings, events, audit). |
all | Every resource of that type in the account. With include_future: false, it is frozen to the IDs that exist at publication time. |
descendants | Everything under a parent, set in parent (module, lesson, exam, certificate, and comment belong to product; enrollment belongs to user). With include_future: false, it is frozen the same way. |
ids | An explicit list, with at most 1,000 IDs. |
include_future defaults to false when omitted: unless you send include_future: true, an all or descendants selector is frozen to the IDs that exist when the policy is published.
The selector type must match the permission's resource type. A module permission accepts a module selector by ids, descendants of a product, all, or instance.
How scope appears in responses
- Listings: the operation requires the permission with any selector, and the page contains only what is in scope. There is no error; the collection is smaller.
- Single resources: a resource outside the scope returns
404, identical to the response for a resource that does not exist. - Routes under
/users/{user_id}/...: when the permission's selector covers a user dimension (for example, enrollments or progress), the user in the URL is checked against the scope too. A user outside the scope returns404. - References in the request body: a reference to another resource, such as
delivery_idorproduct_id, that is outside the scope returns422 validation_failedon that field, not404, so the response never confirms or denies its existence. - Administrators: a request made with an administrator's OAuth access token uses the administrator's own permissions, with the same scope rules. See Administrators (OAuth).
Delegation
Nobody grants more than they have. A credential with credentials.policies.manage can manage the policies of another credential, as long as what it grants is contained in what it has itself, and only with permissions marked as delegable in the catalog. The same limit applies when a credential creates an administrator or changes the permissions of one: it grants only the delegable permissions of its own policies. Credential management permissions (credentials.*) and administrator permissions (administrators.*) are not delegable by a credential, and a credential cannot modify its own policies.
The same limit applies to an administrator calling the API with an OAuth access token, in POST /credentials, POST /administrators, and PUT /administrators/{administrator_id}/permissions. A full-access administrator grants anything, including administrators.* and credentials.*; any other administrator grants only what their own permissions grant. Going beyond that returns 403 delegation_limit_exceeded, and nothing changes. Nobody changes their own permissions.
In the dashboard, administrators who are not root or master_admin can only grant a credential the permissions they have themselves, when they create it or change its policies.
Administrator permissions
The permissions of an administrator use the same policy format as credentials. GET /administrators/{administrator_id}/permissions returns role, full_access, and policies, where each item is a Policy, the same object as GET /credentials/{credential_id}/policies. Access limited to some products is the ids selector of product. root and master_admin return full_access: true and no policies.
PUT /administrators/{administrator_id}/permissions takes {role, policies}. Each policy is {capabilities, resources}, as in POST /credentials/{credential_id}/policies, and the new policies replace all previous ones. Send policies: [] with root or master_admin. The revision and the ETag of both operations are the revision of the administrator's permissions: send that ETag in If-Match. The ETag of GET /administrators/{administrator_id} is not accepted there and returns 412 revision_mismatch. Nobody changes their own permissions: that returns 409 state_conflict with details[].reason set to self_modification.
POST /administrators takes policies to grant exactly those permissions. Without policies, the administrator gets the default permissions of the role, limited to product_ids when you send it. Sending policies and product_ids together returns 422 validation_failed.
published_by of a Policy can be {"kind": "system", "id": null}: a revision the platform derived on its own, such as the previous permissions of an administrator converted to a policy.
Large policies
- Prefer
descendantsandallover ID lists, withinclude_future: true: the policy stays smaller, covers resources created later, and does not need to be republished. - An
idsselector with more than 1,000 IDs is rejected. If you want "everything that exists today", useallwithinclude_future: falseand let the API resolve the IDs. The same 1,000 limit applies to the resolved set. - A revision with many policies is normal; what matters is the set, not the order.
GET /credentials/current/policiesreturns the active policy revision of the calling credential, which is useful for debugging a403before you change anything. With an administrator's access token, it returns404; useGET /capabilitiesinstead.
Rejections
| Code | When |
|---|---|
permission_denied | The caller, a credential or an administrator, lacks the permission for the operation, field, or transition. |
not_found | The caller has the permission, but the resource is outside its scope (or does not exist). |
delegation_limit_exceeded | The requested policy goes beyond what the caller can delegate. |
validation_failed | A permission is not in the catalog, a selector is incompatible, an ID does not exist, or a selector exceeds 1,000 IDs. |
revision_mismatch | The If-Match value does not match the current revision of the credential or of the administrator's permissions. |
state_conflict | With details[].reason set to self_modification: an administrator tried to change their own permissions. |