Manage API keys and permissions
This guide creates an API key for a client application, grants and reviews the key's permissions, checks the key, changes the key's permissions and expiration, and replaces the key. Administrators need this guide to issue and rotate keys, and integrators need this guide to confirm that a key holds the permissions an integration requires. Access control describes API keys, permissions and classification allow-lists.
Administration key
The requests in this guide run with an administration key, set in FOUNDATION4_API_KEY and FOUNDATION4_API_SECRET as described in Authenticate. The administration key is the master key, or a key with the following permissions:
- Key creation. Write permission on the
api-keysobject type. - Permission grants. Every permission that the administration key grants, with a classification allow-list that covers the granted classifications, as described in Granting limits.
- Key deletion. Write permission on the
api-keysobject type.
The master key serves only for administration. The master key does not belong in client application configuration, and the master key must remain active and unexpired, because the API server and the workers authenticate with the master key at startup. The replacement procedure in Key rotation applies to the keys of client applications; Secrets and keys describes how the master key is stored.
Permission plan
A key for a client application holds permissions on individual objects, not on object types. Permissions by operation lists the permissions of each operation. This guide creates a key for an indexing service that keeps the support-kb pipeline from First search current:
- Pipeline. Permission 7 on the pipeline, so that the service can add, expire and delete documents.
- Text splitter. Permission 1 on the pipeline's default text splitter, which adding a document requires.
- Classification allow-list.
publicandinternal, the classifications of the documents that the service submits.
Key creation
Create the key with POST /api-keys, without permissions on object types. The expiration time sets the date by which the key is replaced.
curl -X POST "$FOUNDATION4_URL/api-keys" \
-H "x-api-key: $FOUNDATION4_API_KEY" \
-H "x-api-key-secret: $FOUNDATION4_API_SECRET" \
-H "Content-Type: application/json" \
-d '{
"name": "support-indexer",
"description": "Indexing service for the support knowledge base",
"expiration": "2027-09-30T00:00:00Z"
}'
Expected result: status 201 and the new key. The secret field holds a generated 48-character string, shown here as a placeholder:
{
"id": "59971347-4b50-4edd-b6f6-12184d2af420",
"created_at": "2026-09-30T10:00:00.615180Z",
"updated_at": "2026-09-30T10:00:00.615180Z",
"name": "support-indexer",
"description": "Indexing service for the support knowledge base",
"secret": "change-me",
"active": true,
"expiration": "2027-09-30T00:00:00Z"
}
Foundation4 returns the secret only in this response. Store the secret in the secret store of the client application, and set the variables for the remaining steps:
export INDEXER_KEY_ID=<id from the response>
export INDEXER_KEY_SECRET=<secret from the response>
Permission grant
Read the identifier of the pipeline's default text splitter with GET /pipelines/{id}:
curl "$FOUNDATION4_URL/pipelines/$PIPELINE_ID" \
-H "x-api-key: $FOUNDATION4_API_KEY" \
-H "x-api-key-secret: $FOUNDATION4_API_SECRET"
Expected result: status 200 and the pipeline. The following response is shortened to the id, name and default_text_splitter_id fields:
{
"id": "c4a7e2d1-6b3f-4f8e-a2d9-7e1b5c3f9a06",
"name": "support-kb",
"default_text_splitter_id": "019252e9-b4a0-7713-9a69-d701b4f4a2d1"
}
export TEXT_SPLITTER_ID=<default_text_splitter_id from the response>
Grant both permissions and the allow-list in one request to POST /api-keys/{id}/permissions. Foundation4 applies all entries of the request in one transaction.
curl -X POST "$FOUNDATION4_URL/api-keys/$INDEXER_KEY_ID/permissions" \
-H "x-api-key: $FOUNDATION4_API_KEY" \
-H "x-api-key-secret: $FOUNDATION4_API_SECRET" \
-H "Content-Type: application/json" \
-d '{
"permissions": [
{"object_type": "pipelines", "object_id": "'"$PIPELINE_ID"'", "permission": 7},
{"object_type": "text-splitters", "object_id": "'"$TEXT_SPLITTER_ID"'", "permission": 1}
],
"classifications": ["public", "internal"]
}'
Expected result: status 200 and an empty body. Documents that name another text splitter in text_splitter_id require execute permission on that text splitter as well.
Permission review
List every permission entry of the key with GET /api-keys/{id}/permissions:
curl "$FOUNDATION4_URL/api-keys/$INDEXER_KEY_ID/permissions" \
-H "x-api-key: $FOUNDATION4_API_KEY" \
-H "x-api-key-secret: $FOUNDATION4_API_SECRET"
Expected result: status 200 and a JSON array with one entry for each object. The entries are in no fixed order, and a later change to the key's permissions can change their order. The allow-list appears on the pipeline entry, in any order, and classifications is null on other object types:
[
{
"object_type": "pipelines",
"object_id": "c4a7e2d1-6b3f-4f8e-a2d9-7e1b5c3f9a06",
"permission": 7,
"classifications": ["public", "internal"]
},
{
"object_type": "text-splitters",
"object_id": "019252e9-b4a0-7713-9a69-d701b4f4a2d1",
"permission": 1,
"classifications": null
}
]
Key check
The following requests authenticate with the new key instead of the administration key. POST /login confirms that the key authenticates:
curl -X POST "$FOUNDATION4_URL/login" \
-H "x-api-key: $INDEXER_KEY_ID" \
-H "x-api-key-secret: $INDEXER_KEY_SECRET"
Expected result: status 201. The permissions object lists permissions on object types only, so the object is empty for a key that holds permissions on individual objects:
{
"message": "OK",
"key": "support-indexer",
"description": "Indexing service for the support knowledge base",
"permissions": {}
}
GET /permissions/{object_type}/{object_id} returns the permission of the calling key on one object, combined from the key's permission on the object and on the object type:
curl "$FOUNDATION4_URL/permissions/pipelines/$PIPELINE_ID" \
-H "x-api-key: $INDEXER_KEY_ID" \
-H "x-api-key-secret: $INDEXER_KEY_SECRET"
Expected result: status 200 and the permission value as a bare JSON number:
7
Permission changes
POST /api-keys/{id}/permissions also changes and removes permissions. An entry replaces the key's permission on the same object, and an entry with permission set to 0 removes the key's permission on that object. The permissions on objects not named in the request remain unchanged. Scoped keys lists the rules.
The allow-list is part of each pipeline entry, so a request that names a pipeline always carries the complete allow-list for that pipeline. The following request limits the indexing key to public documents:
curl -X POST "$FOUNDATION4_URL/api-keys/$INDEXER_KEY_ID/permissions" \
-H "x-api-key: $FOUNDATION4_API_KEY" \
-H "x-api-key-secret: $FOUNDATION4_API_SECRET" \
-H "Content-Type: application/json" \
-d '{
"permissions": [
{"object_type": "pipelines", "object_id": "'"$PIPELINE_ID"'", "permission": 7}
],
"classifications": ["public"]
}'
Expected result: status 200 and an empty body. GET /api-keys/{id}/permissions shows the pipeline entry with the allow-list ["public"] and the unchanged text splitter entry. A request of the key that adds an internal document to the pipeline then returns HTTP 404 with the message Classification not found.
Key expiration
A key authenticates only before the key's expiration time. After that time, every request of the key returns HTTP 401 with the message Invalid API Key or Secret. PATCH /api-keys/{id} changes the expiration time:
curl -X PATCH "$FOUNDATION4_URL/api-keys/$INDEXER_KEY_ID" \
-H "x-api-key: $FOUNDATION4_API_KEY" \
-H "x-api-key-secret: $FOUNDATION4_API_SECRET" \
-H "Content-Type: application/json" \
-d '{"expiration": "2027-03-31T00:00:00Z"}'
Expected result: status 200 and the key without the secret:
{
"id": "59971347-4b50-4edd-b6f6-12184d2af420",
"created_at": "2026-09-30T10:00:00.615180Z",
"updated_at": "2026-09-30T10:30:00.842569Z",
"name": "support-indexer",
"description": "Indexing service for the support knowledge base",
"active": true,
"expiration": "2027-03-31T00:00:00Z"
}
A request with "expiration": null removes the expiration time, and the key then does not expire. The following request to GET /api-keys lists keys in order of expiration, so the keys to replace next appear first:
curl "$FOUNDATION4_URL/api-keys?order_by=expiration&first=20" \
-H "x-api-key: $FOUNDATION4_API_KEY" \
-H "x-api-key-secret: $FOUNDATION4_API_SECRET"
Expected result: status 200 and a page of keys, as described in Pagination. Keys past the expiration time come first, and keys without an expiration time come last. The following response is shortened to the first two keys and omits page_info and query_info:
{
"data": [
{
"id": "59971347-4b50-4edd-b6f6-12184d2af420",
"created_at": "2026-09-30T10:00:00.615180Z",
"updated_at": "2026-09-30T10:30:00.842569Z",
"name": "support-indexer",
"description": "Indexing service for the support knowledge base",
"active": true,
"expiration": "2027-03-31T00:00:00Z"
},
{
"id": "14b7d6bc-8cf7-4766-86ac-2cc66530baee",
"created_at": "2026-09-29T12:05:12.734519Z",
"updated_at": "2026-09-29T12:05:12.734519Z",
"name": "support-portal-search",
"description": "Search key for the support portal",
"active": true,
"expiration": "2027-09-30T00:00:00Z"
}
]
}
Key rotation
A secret cannot be changed or recovered, so a key is rotated by replacing the key. The procedure keeps the old key available until the client application runs on the replacement key. The example replaces the indexing key one month before the key expires.
-
Create the replacement key with a new name and a new expiration time:
curl -X POST "$FOUNDATION4_URL/api-keys" \-H "x-api-key: $FOUNDATION4_API_KEY" \-H "x-api-key-secret: $FOUNDATION4_API_SECRET" \-H "Content-Type: application/json" \-d '{"name": "support-indexer-2028","description": "Indexing service for the support knowledge base","expiration": "2028-03-31T00:00:00Z"}'Expected result: status 201 and the replacement key with the
secretfield, as in Key creation. SetREPLACEMENT_KEY_IDandREPLACEMENT_KEY_SECRETfrom theidandsecretfields. -
Grant the replacement key the permissions of the old key.
GET /api-keys/$INDEXER_KEY_ID/permissionslists the entries to copy. The request body repeats each entry and each pipeline's allow-list:curl -X POST "$FOUNDATION4_URL/api-keys/$REPLACEMENT_KEY_ID/permissions" \-H "x-api-key: $FOUNDATION4_API_KEY" \-H "x-api-key-secret: $FOUNDATION4_API_SECRET" \-H "Content-Type: application/json" \-d '{"permissions": [{"object_type": "pipelines", "object_id": "'"$PIPELINE_ID"'", "permission": 7},{"object_type": "text-splitters", "object_id": "'"$TEXT_SPLITTER_ID"'", "permission": 1}],"classifications": ["public"]}'Expected result: status 200 and an empty body.
GET /api-keys/$REPLACEMENT_KEY_ID/permissionsreturns the same entries as the old key. -
Change the client application's configuration to the replacement key, and confirm that the replacement key authenticates:
curl -X POST "$FOUNDATION4_URL/login" \-H "x-api-key: $REPLACEMENT_KEY_ID" \-H "x-api-key-secret: $REPLACEMENT_KEY_SECRET"Expected result: status 201 with
keyset tosupport-indexer-2028. -
Deactivate the old key:
curl -X PATCH "$FOUNDATION4_URL/api-keys/$INDEXER_KEY_ID" \-H "x-api-key: $FOUNDATION4_API_KEY" \-H "x-api-key-secret: $FOUNDATION4_API_SECRET" \-H "Content-Type: application/json" \-d '{"active": false}'Expected result: status 200 and the old key with
activeset tofalse:{"id": "59971347-4b50-4edd-b6f6-12184d2af420","created_at": "2026-09-30T10:00:00.615180Z","updated_at": "2027-03-01T09:30:00.207914Z","name": "support-indexer","description": "Indexing service for the support knowledge base","active": false,"expiration": "2027-03-31T00:00:00Z"} -
Confirm that the old key no longer authenticates:
curl -X POST "$FOUNDATION4_URL/login" \-H "x-api-key: $INDEXER_KEY_ID" \-H "x-api-key-secret: $INDEXER_KEY_SECRET"Expected result: status 401 and the following body:
{"error":{"message":"Invalid API Key or Secret"}}A client application that still uses the old key receives the same error. While the old key exists,
PATCHwith{"active": true}restores the old key. -
Delete the old key with
DELETE /api-keys/{id}when the client application has run on the replacement key without errors:curl -X DELETE "$FOUNDATION4_URL/api-keys/$INDEXER_KEY_ID" \-H "x-api-key: $FOUNDATION4_API_KEY" \-H "x-api-key-secret: $FOUNDATION4_API_SECRET"Expected result: status 204 and an empty body. Deleting a key also deletes the key's permission entries.
Errors
The following errors are specific to key management. Errors lists every message.
- HTTP 401.
Invalid API Key or Secret: the calling key does not exist, the secret does not match, or the key is inactive or expired. - HTTP 403.
Unauthorized: access level <level> required for Api Key with id <id>: the administration key grants a permission or a classification that the administration key does not hold, lacks write permission on theapi-keysobject type, or deletes itself. The message namesApi Keyeven when the refused permission concerns another object, such as a pipeline, and<id>isNonewhen the request creates a key.Invalid license: <reason>: creating keys, changing permissions and changing keys require a valid license. - HTTP 404.
ApiKey not found, with the identifier indetails.id:GET /api-keys/{id}names a key that does not exist or that the administration key cannot read.Api Key with id <id> not found: a permission request, a change or a deletion names such a key. - HTTP 409.
Conflict unique error: another key already has the name. - HTTP 422. A plain-text body, not a JSON error object, that starts with
Failed to deserialize the JSON body into the target type:and names the cause: an unknownobject_type, apermissionvalue above 7 or a missing field.