Developers

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-catalog

Each 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.publish in addition to products.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's payload_detail to full; 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"] }
  ]
}
SelectorWhat it covers
instanceThe entire account, for permissions that have no resource of their own (settings, events, audit).
allEvery resource of that type in the account. With include_future: false, it is frozen to the IDs that exist at publication time.
descendantsEverything 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.
idsAn 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 returns 404.
  • References in the request body: a reference to another resource, such as delivery_id or product_id, that is outside the scope returns 422 validation_failed on that field, not 404, 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 descendants and all over ID lists, with include_future: true: the policy stays smaller, covers resources created later, and does not need to be republished.
  • An ids selector with more than 1,000 IDs is rejected. If you want "everything that exists today", use all with include_future: false and 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/policies returns the active policy revision of the calling credential, which is useful for debugging a 403 before you change anything. With an administrator's access token, it returns 404; use GET /capabilities instead.

Rejections

CodeWhen
permission_deniedThe caller, a credential or an administrator, lacks the permission for the operation, field, or transition.
not_foundThe caller has the permission, but the resource is outside its scope (or does not exist).
delegation_limit_exceededThe requested policy goes beyond what the caller can delegate.
validation_failedA permission is not in the catalog, a selector is incompatible, an ID does not exist, or a selector exceeds 1,000 IDs.
revision_mismatchThe If-Match value does not match the current revision of the credential or of the administrator's permissions.
state_conflictWith details[].reason set to self_modification: an administrator tried to change their own permissions.

On this page