> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sempera.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Secrets and request signing

> Workspace secrets tools reference by name, and JWT request signing with a public key set receivers verify against.

Two owner settings keep credentials out of your tool definitions and let a receiver check where a request came from. **Secrets** are values you store once for the workspace, such as the token a tool sends to your own system; Sempera never returns a value. **Request signing** adds a short-lived signed token, verifiable with public keys, to the requests Sempera sends in your name.

Both are workspace settings. Writes need the `owner` role and the scope shown; a secret or key of another workspace answers `404` exactly like one that never existed. A write from the Sempera App also passes the CSRF check ([Errors and limits](https://docs.sempera.org/errors-and-limits.md)).

## Secrets

| Route | Scope | Notes |
| - | - | - |
| `GET /v1/secrets` | `tools:read` | The workspace's secrets, unpaged, in the compatibility envelope `{"data": {...}, "errors": null}`: `secrets` beside `types`, `statuses`, `sorts`, `reference_formats` and `configured` |
| `POST /v1/secrets` | `tools:write` | Add one. `201` with the secret |
| `PATCH /v1/secrets/{id}` | `tools:write` | Rename it, replace a Static value, or pause and resume it |
| `POST /v1/secrets/{id}/refresh` | `tools:write` | Fetch a Refresh secret's value now |
| `DELETE /v1/secrets/{id}` | `tools:write` | Delete it: the stored value goes with it. Answers the secret with `"deleted": true` |

`GET /v1/secrets` takes `q` (a name search, up to 80 characters), `type` (`all`, `static`, `refresh`), `status` (`all`, `active`, `paused`) and `sort` (`created_desc` by default, `created_asc`, `updated_desc`, `updated_asc`); any other value is refused with `400 validation_failed`. Other routes answer the secret itself.

A secret has a name in `CAPITALS_WITH_UNDERSCORES`, 2 to 80 characters, starting with a letter, unique in the workspace. There are two types.

```http theme={null}
POST /v1/secrets
{"name": "ORDERS_TOKEN", "value": "<the value>"}
```

```http theme={null}
POST /v1/secrets
{"name": "ORDERS_ACCESS", "type": "refresh",
 "refresh": {"url": "https://orders.example.com/oauth/token", "method": "POST",
             "headers": {"X-Client": "sempera"}, "body": "{\"grant_type\": \"client_credentials\"}",
             "interval_minutes": 55, "json_path": "$.access_token"}}
```

| Field | Rule |
| - | - |
| `name` | Required |
| `type` | `static` (default) or `refresh` |
| `value` | Static only: up to 8192 characters, no line breaks or control characters. A Refresh secret takes none |
| `refresh.url` | Refresh only, required: `https`, no username, password, fragment or template, and not a local or private address |
| `refresh.method` | `GET` (default) or `POST` |
| `refresh.headers` | Up to 20, each value up to 2000 characters. Not `Host`, `Content-Length`, `Transfer-Encoding` or `Connection` |
| `refresh.body` | POST only, up to 8192 characters |
| `refresh.interval_minutes` | Required, 1 to 1440 |
| `refresh.json_path` | Required, up to 200 characters: `$.access_token`, `$.data.items[0].token` or `$['access-token']`. It must reach one text, number or true or false value |

A Refresh secret is fetched when you create it, on `.../refresh`, and again, when a tool is about to use it, if its last attempt is older than the interval. The request goes out with a 10 second timeout, and the answer must be a `2xx` JSON document. A failed fetch is recorded in `refresh.last_error` (a sentence that never contains the value or the answer) and keeps the last good value; creating a secret whose first fetch fails still succeeds, and the secret says why it has no value. A refresh needs the deployment's encryption key, like every stored secret; without it writes answer `503 owner_setup_required`.

A secret reads `id`, `object: "secret"`, `name`, `key`, `type` (`Static` or `Refresh`), `status` (`Active` or `Paused`), `reference` (`{{secret.NAME}}`), `id_reference` (`{{secret.id.<id>}}`), `has_value`, `created_by`, `updated_by`, `created_at`, `updated_at` and `refresh`, null for a Static secret, otherwise `url` (without its query string), `method`, `header_names` (never the values), `interval_minutes`, `json_path`, `last_refreshed_at`, `last_attempt_at` and `last_error`.

`PATCH` takes `name`, `value` and `status` (`active` or `paused`), at least one. A value on a Refresh secret is refused, and a Refresh secret's settings cannot be edited, only its name and status. `.../refresh` on a Static secret is `400`, and on a paused one `409 conflict`.

### Using a secret in a tool

A tool names a secret by its name in `auth` (`{"type": "bearer_reference", "secret": "ORDERS_TOKEN"}`, or `api_key_reference` with a `header`), or by a template in its request URL path or query, a static header or a static body: `{{secret.ORDERS_TOKEN}}` or `{{secret.id.<id>}}` (never in the host). A tool your workspace owns resolves them from your workspace's secrets only. A reference to an unknown secret, a paused secret or one with no value refuses the call before anything is sent, and the value is masked wherever it would appear in a result, including an answer that echoes it ([Tools and integrations](https://docs.sempera.org/tools-and-integrations.md)).

## Request signing

When signing is on, the requests Sempera sends to your systems in your workspace's name carry one more header holding a JWT: an owner-built tool call that reaches your system (a sandbox test or a live conversation), every outbound webhook delivery, every webhook action of an automation trigger ([Automations](https://docs.sempera.org/automations.md)) and a widget's chat webhooks ([Website widgets](https://docs.sempera.org/widgets.md)). It adds to `X-Sempera-Signature` ([Webhooks](https://docs.sempera.org/webhooks.md)); it never replaces it.

| Route | Scope | Notes |
| - | - | - |
| `GET /v1/signing` | `webhooks:read` | The settings, the keys and where to verify |
| `PATCH /v1/signing` | `webhooks:write` | Change settings; fields you leave out keep their values |
| `POST /v1/signing/keys` | `webhooks:write` | Generate the first key, or rotate. `201`, with the settings view and the new key as `generated` |
| `POST /v1/signing/keys/revoke-previous` | `webhooks:write` | Withdraw every key but the active one, now |
| `GET /v1/workspaces/{workspace_id}/.well-known/jwks.json` | none | The published public keys |

These answer plain objects, not the compatibility envelope. `GET /v1/signing` answers `object: "request_signing"`, `workspace_id`, `algorithm` (`RS256`), `settings`, `keys`, `issuer`, `jwks_url`, `jwks_path`, `key_storage_configured` and `signed_requests` (`["tool_calls", "webhooks"]`). A key reads `id`, `object: "signing_key"`, `kid`, `algorithm`, `status` (`active`, `previous` or `revoked`), `public_key_pem`, `created_at`, `retired_at`, `revoked_at` and, in the list, `published`. A private key is never returned.

| Setting | Rule |
| - | - |
| `enabled` | `false` until you turn it on, which needs an active key: without one, `409 conflict` |
| `header_name` | Default `X-Sempera-JWT`. Letters, digits and hyphens, up to 64 characters; not `Authorization`, `Content-Type`, `Content-Length`, `Host`, `Cookie` or a header Sempera itself sets for events |
| `header_prefix` | Default `Bearer`. Up to 32 printable characters |
| `audience` | Blank by default, which means the target's origin. Up to 300 characters |
| `include_protocol` | `true` by default. When `false` and no `audience` is set, the audience is the host alone |
| `ttl_seconds` | Token lifetime, 60 to 3600 (default 300) |
| `custom_claims` | Up to 20 string values (names up to 64 characters, values up to 500) added to every token. `iss`, `sub`, `aud`, `iat`, `nbf`, `exp`, `jti` and `body_sha256` are Sempera's own and refused |

The header value is the prefix followed by an RS256 token. Its header carries `kid`; its claims are `iss` (`https://api.sempera.org/v1/workspaces/<workspace_id>`), `sub` (the workspace), `aud`, `iat`, `nbf`, `exp`, a fresh `jti`, `body_sha256` (the hex SHA-256 of the exact body bytes sent, of the empty string for no body) and your custom claims.

To verify, read `kid` from the token, take that key from the JWKS, check the RS256 signature, `iss`, `aud` and `exp`, and compare `body_sha256` with the SHA-256 of the raw body you received. A request without the header should fail that check: if the key cannot be read when a request is sent, the request goes out without the header rather than not at all.

Rotating makes the new key the one that signs and keeps the previous key in the JWKS for 24 hours, so tokens already in flight still verify; at most three keys are published, and older ones are revoked. Revoking previous keys removes every earlier key from the JWKS at once, for a suspected compromise, and answers the settings view with `revoked`, the number withdrawn. A key is 2048-bit RSA and its private half is stored encrypted under the deployment's key; without that key no signing key can be made. The JWKS needs no credential, answers `{"keys": []}` for a workspace without keys (or one that does not exist) and may be cached for 5 minutes.

## Refusals

| Status | When |
| - | - |
| `400 validation_failed` | A field breaks a rule above |
| `403 forbidden` | The credential's role is not `owner`; `403 csrf_failed` for an App-session write without its proof |
| `404 not_found` | No such secret in this workspace |
| `409 conflict` | A name is taken, a paused secret is refreshed, or signing is turned on without a key |
| `503 owner_setup_required` | The deployment has no encryption key for stored credentials |
| `503 service_unavailable` | The deployment has no customer database |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.