#Secrets
A secret (an API key, a provider token) is stored write-only and is never returned by any API again. Workflows and apps don't read secrets directly: they read a reference you wire into their config, and the value is injected into server-side execution only when an action runs. The model is config-wiring:
- Store the value under a scope.
- Wire a reference —
{{ secrets.<scope>.<NAME> }}— into config. The presence of the reference in config is the read authorisation. - Read the config key from the definition. A definition never names
secrets.*itself.
Authenticate with a Personal Access Token (HTTP Basic auth) — enter it once in the Authorize bar above and every Try it below uses it.
#Scopes
Every secret lives in exactly one scope, and every reference names the scope it
reads from. The scope decides who shares the value and how the runtime
resolves the scope_id when a reference is read:
| Scope | Reference | Who shares the value | scope_id at read time |
|---|---|---|---|
tenant |
{{ secrets.tenant.NAME }} |
Everyone in the tenant. | The tenant. |
connection |
{{ secrets.connection.NAME }} |
Every user arriving through one provider account (one registered provider organisation; a company may hold several). | The caller's provider organisation (implicit). |
user |
{{ secrets.user.NAME }} |
One person, across every provider they arrive through. | The caller (implicit). |
app |
{{ secrets.app.NAME }} |
One app activation, across its providers. | The current activation (implicit). |
team |
{{ secrets.team.<TEAM_ID>.NAME }} |
The members of one team. | The literal team id in the reference (explicit). |
The implicit scopes (connection, user, app) make one wired reference
resolve to a different value per caller, so the same config serves every user,
provider account, or activation with its own credential and nothing leaks across
them. team is always explicit because a user may belong to many teams. NAME
is UPPER_SNAKE.
#1. Store the value
Store a value under a name. The default scope is tenant; pass scope (and
scopeId — camelCase, unlike most fields — for connection / team / app)
to target another. Storing the same name in the same scope again overwrites it —
that is rotation.
POST/access/secretsStore a secret valueAPI docs ↗Try it
The value is encrypted at rest and never comes back out of the API —
GET /access/secrets lists names only, and no role or grant can retrieve a
stored value. Values surface only inside a running workflow or app action.
Writing a value under a shared scope (tenant, connection, team, app)
needs a secret write grant for that scope (a tenant admin holds it for every
scope). A user's own user-scoped value needs no grant — see
Secrets set from inside an app.
#2. Wire a reference into config
Put the scoped reference into a named slot of the config the definition runs
under. For a workflow, that is the tenant config's env:
PUT/workflows/{id}/configWire a secret into a workflow's envAPI docs ↗Try it
For an app, that is the app config, an integration config, or a principal config:
PUT/apps/{specId}/configWire a secret into an app's configAPI docs ↗Try it
Which config may carry which reference:
| Config | Written by | May reference |
|---|---|---|
Workflow tenant config (env) |
Tenant admin | Any scope. |
| App config / integration config | Tenant admin | tenant, connection, app, user — never team. |
company principal config |
Admin / granted user | {{ secrets.connection.* }} only. |
team principal config |
Admin / granted user | {{ secrets.team.<this team's id>.* }} only. |
user principal config |
The user | {{ secrets.user.* }} only. |
An app or integration config is shared by everyone using the activation, so
a team reference is refused there (SECRET_REF_SCOPE_VIOLATION): it carries
no membership check and would resolve for any caller. Wire team-scoped secrets
through a team principal config instead.
A principal config may wire only references of its own scope; a tenant
reference in a principal config, or a reference whose team id differs from the
config's team, is rejected the same way.
A bare, scope-less {{ secrets.NAME }} never resolves — but where it is
caught differs. An app, integration, or principal config rejects it on write
(INVALID_SECRET_REF). A workflow's env accepts the write and the reference
fails at run time instead, as a 400 on the action — so a 200 from
PUT /workflows/{id}/config is not confirmation that the references in it are
well-formed.
#3. Read the config key from the definition
The workflow, job, action, or app spec reads the config key — never
secrets.*. A workflow action reads config.env.PROVIDER_API_KEY; an app spec
reads the slot you wired, for example config.app.provider_token, and threads
it into an action through the step's input:
{
"kind": "actions",
"input": { "token": "{{ config.app.provider_token }}" },
"actions": [
{
"name": "http",
"config": {
"url": "https://api.example.com/v1/sync",
"headers": { "Authorization": "Bearer {{ input.token }}" }
}
}
]
}
At run time the wired reference resolves against the caller, the value is injected into server-side execution only, and it is scrubbed from every output, log, and response.
A
{{ secrets.* }}reference lives only in config. A workflow definition or app spec that contains one is rejected on save (SECRET_REF_IN_DEFINITION). A reference arriving any other way — a form value, an action result — is refused at resolve time, so nothing can escalate past what the tenant wired.
#Secrets set from inside an app
An app can let its users provide their own credentials without an admin in the loop. Two pieces compose:
- a
secretInputform field — write-only; the render shows an "is set" signal, never the value, and on submit the value may flow only into asetSecretstep; - a
setSecretstep — writes the value to the scope named in itstarget({ "scope", "scopeId"?, "name", "env"? }).
"widgets": {
"connect": {
"widgetComponent": "form",
"config": {
"header": "Connect your account",
"fields": [ { "id": "key", "label": "API key", "type": "secretInput", "target": { "scope": "user", "name": "EMAIL_API_TOKEN" } } ],
"submit": {
"label": "Save",
"command": [
{ "kind": "setSecret", "target": { "scope": "user", "name": "EMAIL_API_TOKEN" }, "value": "{{ outputs.form.key }}" },
{ "kind": "notify", "level": "INFO", "message": "Connected" },
{ "kind": "refresh" }
]
}
}
}
}
A user's own user-scoped write needs no grant; a connection / team / app
write needs the secret write grant for that scope. The render context's
secretsSet lists the names of the secrets already set, grouped by scope
(secretsSet.user, secretsSet.connection, …), so a page can gate the form:
"layout": [
[{ "$if": "$not('EMAIL_API_TOKEN' in secretsSet.user)", "$then": "connect" }],
["inbox"]
]
The wired reference — "emailToken": "{{ secrets.user.EMAIL_API_TOKEN }}" in
the app config — is the same for every user; each user's own value resolves when
they run the action.
#Rotate, list, and delete
- Rotate —
PUT /access/secretswith the samename(and scope) and a newvalue, orPOSTagain. Wired references pick up the new value on the next run. Sets are idempotent on the stored key(scope, scope_id, env, name)— two same-named values under different connections, teams, or users are distinct secrets, not a collision. - List (names only) —
GET /access/secrets, optionally?scope=…. - Delete —
DELETE /access/secrets/{name}(add?scope=&scopeId=for a non-tenant scope;scope_idis rejected with a400). A reference to a deleted value fails the action with a "missing secret" error — never a silent empty. - Revoke access without deleting — remove the reference from config; the action no longer receives the credential.
#API reference
Every secret operation, with full schemas and an interactive console, is in the API reference.