API keys for retrieval integrations: one key for each application
A practical guide to the API keys a retrieval integration needs in foundation4. It covers creating a key, granting permissions, classification allow-lists, expiry and rotation, and the errors you will see.

Every request to foundation4 carries an API key. The key identifies the caller, and the permissions on the key decide what the caller can do. This guide walks through setting up keys for a typical retrieval integration: one service that keeps a pipeline current, and one application that searches it. It uses the requests and responses from the Manage API keys and permissions guide, so you can follow along against your own deployment.
The pattern throughout is simple. Each application gets its own key, with the permissions its job requires. The docs recommend a dedicated key for each integration, because a leaked key then affects one integration, and you can replace one key without touching the others.
What a key is
An API key has two parts: an identifier in UUID format and a secret. Every request sends both, in the x-api-key and x-api-key-secret headers.
foundation4 generates the secret when it creates the key. It returns the secret once, in that response, and never again. It stores an Argon2id hash of the secret, computed with a random salt. No endpoint returns the secret or the hash. This matters for operations: a lost secret cannot be recovered or regenerated. You replace the key instead, as described in the rotation section below.
Because the secret travels in a header on every request, the docs state that every client path to foundation4 uses TLS. TLS terminates at the ingress controller or the Gateway in front of the API server.
The master key stays with administrators
Each deployment has one master key. It is created at installation and holds read, write and execute permission on every object type, with access to every classification. Its job is administration: you use it to create the scoped keys that applications use.
The master key never goes into application configuration. There is also an operational reason to keep it out of circulation. The API server and the workers authenticate with the master key at startup, so the master key must stay active and unexpired for the deployment to start. If an application leaked it, you could not simply switch it off.
You can also create a separate administration key with the permissions that key management needs: write permission on the api-keys object type, plus every permission and classification that it will grant to other keys.
Permissions are three bits
A permission is a sum of three bits:
| Permission | Value | Grants |
|---|---|---|
| Read | 4 | Listing and retrieving objects, and reading documents |
| Write | 2 | Creating, changing and deleting objects |
| Execute | 1 | Using objects, such as adding documents and searching |
Read and execute together is 5. All three is 7.
A permission applies at one of two scopes. On an object type, such as pipelines or text-splitters, it covers every object of that type, including objects created later. On an individual object, it covers that one object, identified by its ID. A key's permission on an object combines both scopes. Permissions on a pipeline also govern the documents and fragments in that pipeline.
For application keys, the docs grant permissions on individual objects. A search key for one pipeline then has no permission on any other pipeline.
What each kind of key needs
The Access control page lists the permissions each operation requires. These are the ones a retrieval integration uses:
| Operation | Required permissions |
|---|---|
| Add a document or a new version | Read and execute on the pipeline, execute on the text splitter that processes the document, and the document's classification in the allow-list |
| Expire a document | Write and execute on the pipeline, and the document's classification in the allow-list |
| Delete a document | Read, write and execute on the pipeline, and the document's classification in the allow-list |
| Search a pipeline | Read and execute on the pipeline |
From that table, two typical keys follow.
An ingestion key for a service that keeps a pipeline current holds 7 on the pipeline, so that it can add, expire and delete documents. It also holds 1 on the pipeline's default text splitter, because adding a document requires execute on the splitter that processes it. If your documents name a different splitter in text_splitter_id, the key needs execute on that splitter as well.
A search key for an application that only searches holds 5 on the pipeline. It needs nothing on the text splitter, and it cannot change or delete the pipeline, because it holds no write permission.
Creating a key
Creating a key takes two requests. The first creates the key itself:
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"
}'
The response has status 201. 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"
}
Copy the secret into the application's secret store now. This is the only response that contains it.
The name must be unique across the deployment. The expiration time is optional, but setting one gives you a date by which the key is replaced. A key without an expiration time does not expire.
Granting permissions and the allow-list
The second request grants permissions on individual objects. For the ingestion key, that is 7 on the pipeline and 1 on its default text splitter. You can read the splitter's identifier from the default_text_splitter_id field of GET /pipelines/{id}.
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"]
}'
The response has status 200 and an empty body. foundation4 applies all entries in one transaction. If any entry is refused, no entry changes.
The same endpoint changes and removes permissions. An entry replaces the key's permission on the same object, and an entry with permission set to 0 removes it. Objects not named in the request stay as they were. A key can grant only what it holds itself, so an administration key cannot hand out more than it has.
What the allow-list governs
The classifications list is the key's classification allow-list for the pipeline. It is easy to misread, so it is worth being exact about its scope.
The allow-list governs writes to documents. Adding, expiring and deleting a document requires the document's classification in the allow-list. Expiring or deleting every document in a pipeline requires every classification of the pipeline. The allow-list also governs classification management: reading or changing a pipeline's classifications requires *, which permits every classification of the pipeline, including ones added later.
The allow-list does not govern search. A search request names the reader's classifications itself, in its classification field, and results are scoped to those classifications and any they inherit. Your application chooses those classifications for each end user, because your application holds the user's identity and entitlements. A search key therefore does not limit which classifications a search can name. That responsibility sits with the application, as described in Classifications.
Two rules follow from the request format. The list applies to every pipeline entry in the request. A request that names a pipeline but omits the list removes that pipeline's allow-list, so the key can then use no classification of it. Always send the complete intended list, and grant pipelines that need different lists in separate requests.
Checking a key
GET /api-keys/{id}/permissions lists every entry on a key. The allow-list appears on pipeline entries, 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
}
]
The entries come in no fixed order. From the application side, POST /login with the new key's headers confirms that the key authenticates and returns status 201. Its permissions object lists permissions on object types only, so it is empty for a key that holds permissions on individual objects. To check one object, GET /permissions/{object_type}/{object_id} returns the calling key's combined permission as a bare number, such as 7.
Expiry, deactivation and rotation
A key authenticates only before its expiration time. After that, every request returns HTTP 401. PATCH /api-keys/{id} changes the expiration, and "expiration": null removes it. GET /api-keys?order_by=expiration lists keys with expired ones first and keys without an expiration last, so you can see which keys to replace next.
The secret cannot be changed, so rotation means replacing the key. The guide's procedure keeps the old key working until the application runs on the new one:
- Create a replacement key with a new name and a new expiration time.
- Read the old key's entries with
GET /api-keys/{id}/permissions, and grant the same entries and allow-lists to the replacement. - Switch the application's configuration to the replacement, and confirm it with
POST /login. - Deactivate the old key with
PATCH /api-keys/{id}and{"active": false}. Confirm thatPOST /loginwith the old key now returns 401. - Once the application has run on the replacement without errors, delete the old key with
DELETE /api-keys/{id}. Deleting a key also deletes its permission entries.
Deactivation and deletion take effect on the next request. Until you delete it, a deactivated key can be restored with {"active": true}, which gives you a way back if something was missed. A key cannot delete itself.
Errors you will see
These are the errors a team most often meets while setting up keys:
| Status | Message | Usual cause |
|---|---|---|
| 401 | Invalid API Key or Secret |
The key does not exist, the secret does not match, or the key is inactive or expired |
| 403 | Unauthorized: access level <level> required for Api Key with id <id> |
The administration key grants something it does not hold, or lacks write permission on api-keys |
| 404 | Pipeline not found |
A search names a pipeline the key cannot read and execute |
| 404 | Classification not found |
A document's classification is not in the key's allow-list |
| 409 | Conflict unique error |
Another key already has the name |
| 422 | Plain text starting Failed to deserialize the JSON body into the target type: |
An unknown object_type, a permission above 7, or a missing field |
Summary
Give each application its own key. Keep the master key for administration. Grant an ingestion key 7 on its pipeline and 1 on its text splitter, with the classifications it writes in the allow-list. Grant a search key 5 on its pipeline, and have the application name each reader's classifications in every search. Set an expiration on every key, and rotate by replacement.
The full procedure is in Manage API keys and permissions. To talk through how this fits your environment, request a briefing.