#HTTP Auth & External Data
Three things call external HTTP APIs:
- the
httpaction (a synchronous request inside a job); - the
httpCallbackaction (a request whose result arrives later); - the
$httpdata source on an app page (rows for a grid or a picker).
All three take the same auth field. auth puts the credential on the
request for you, so you don't build Authorization headers by hand. The
credential itself never appears in a definition:
- Store the value as a secret.
- Wire a
{{ secrets.<scope>.NAME }}reference into a config slot. - Point an
authprofile at that config slot.
#Declare auth profiles
Declare each credential once, as a named profile in the root auth map of a
workflow definition (definition_json.auth) or an app spec
(spec_document.auth). Then name the profile wherever a request needs it.
POST/definition/workflowsA workflow with a root auth profileAPI docs ↗Try it
An app spec declares the same map at the root of spec_document, reading its
own config (config.app.* and the other scopes):
"auth": {
"partner": {
"type": "bearer",
"token": "{{ config.app.partner_token }}"
}
}
A profile key starts with a letter and then uses letters, digits, _ or -. A
profile is always an inline block; it cannot point at another profile.
auth takes a profile name or an inline block (see
Inline auth) on:
| Where | Example |
|---|---|
http action config |
"config": { "url": "…", "auth": "partner" } |
httpCallback action config |
"config": { "url": "…", "auth": "partner" } |
An app actions step's http action |
same as above, inside the step's actions |
An app jobs.<key> inline job |
same as above |
A page $http source |
"$http": { "url": "…", "auth": "partner", "rowsPath": "data" } |
| A job definition bound by name | resolved against the consuming document at run time (see below) |
Not the same
auth.providers.<name>.authin an app spec (for example{ "type": "bearer" }) says how the host's render requests authenticate to the app. It is unrelated to the rootauthmap, which is about the app's own outbound calls.
#Mechanisms
type |
Fields | What is sent |
|---|---|---|
basic |
username, password |
Authorization: Basic base64(username:password) (standard, padded base64). |
bearer |
token |
Authorization: Bearer <token>. |
apiKey |
in: header or query; name; value |
Header name: value, or query parameter name=value added to the URL's own query. |
oauth2 |
grant (clientCredentials, the default and only value), tokenUrl, clientId, clientSecret, scope?, audience?, clientAuth (basic default, or body) |
Exchanges client credentials for an access token and sends Authorization: Bearer <access_token>. See OAuth2 client credentials. |
type,grant,in,nameandclientAuthare literals. Every other field is a string: a literal or a{{ }}template. A$jsonataobject is refused on a credential field.- A literal
basicusername must not contain:. An empty password is allowed. - A credential that resolves to an empty value refuses the request rather than
sending it without a credential (
HTTP_AUTH_INVALID_CREDENTIAL).
#Wire credentials through config
A definition never contains {{ secrets.* }}: put the reference in the config
the definition runs under and let the profile read that slot.
For a workflow, wire the tenant config's env when you
activate it. The example above
declares its env keys with no default (null), so activation must supply them:
POST/workflows/{id}/activateActivate with the profile's secrets in envAPI docs ↗Try it
Once the workflow is active, change the same env with
PUT /workflows/{id}/config.
For an app, wire the app config (or an integration or principal config):
PUT/apps/{specId}/configWire the profile's secret into app configAPI docs ↗Try it
A user-scoped reference ({{ secrets.user.NAME }}) in an app slot resolves to
each caller's own value, so one profile serves every user with their own
credential.
#What a profile may read
A profile is resolved once per workflow run or app request and reused by every call that names it. It may read only values that are the same for the whole run or request:
| Document | A profile may read | Refused at save |
|---|---|---|
| Workflow | config.env.*, context.* |
Anything else, including config.user, outputs, input, secrets |
| App spec | config.*, provider.* / identity.*, context.*, permissions, secretsSet |
page, outputs, input, itemId, thisWidget, and whole-context reads ($$, $, *, **) |
#Missing secrets fail only the requests that need them
When a profile's secret is not set, only the requests that name that profile
fail, with HTTP_AUTH_SECRET_MISSING. Every other request and profile in the
same run or render works normally. That lets a per-user profile sit next to a
"Connect your account" form gated on secretsSet: users who have not connected
yet get a clear error on the calls that need their credential, and nothing else
breaks.
A secret read by a job step's input is different: the input must resolve
completely, so a missing secret there fails the whole job with
Missing secrets. and details.missing_secrets.
#Inline auth
An inline block is a full auth object in place of a profile name. Where it can
read from depends on where it sits.
On an action, the block is part of the action's own config, which sees
only input and outputs (see
Smart Values → Action scope). Thread the
config value through the step's input first:
{
"kind": "actions",
"stepId": "lookup",
"input": { "api_key": "{{ config.app.partner_key }}" },
"actions": [
{
"stepId": "call",
"name": "http",
"config": {
"url": "https://api.partner.example.com/lookup",
"method": "GET",
"auth": {
"type": "apiKey",
"in": "header",
"name": "X-Api-Key",
"value": "{{ input.api_key }}"
}
}
}
]
}
On a $http source, the whole source is evaluated with the page, so an
inline block reads the render context directly:
"$http": {
"url": "https://api.partner.example.com/items",
"auth": { "type": "bearer", "token": "{{ config.app.partner_token }}" },
"rowsPath": "data"
}
Prefer a profile when the value comes straight from config: it is declared once, checked at save, and reused. Use an inline block for a value computed earlier in the same job.
#Bound jobs
A job definition published on its own has no root auth map. When one of its
actions names a profile ("auth": "partner"), the name resolves at run time
against the root auth map of the workflow or app spec that runs the job — so
the same job authenticates differently in each document that binds it. An
undeclared name fails the action with HTTP_AUTH_PROFILE_UNKNOWN, and a header
or query collision with HTTP_AUTH_CONFLICT; neither is checked when the job
definition is saved. See Job Bindings.
#OAuth2 client credentials
An oauth2 profile exchanges its client credentials at tokenUrl
(RFC 6749) and
sends the access token as a bearer token:
"auth": {
"partner": {
"type": "oauth2",
"tokenUrl": "{{ config.env.PARTNER_TOKEN_URL }}",
"clientId": "{{ config.env.PARTNER_CLIENT_ID }}",
"clientSecret": "{{ config.env.PARTNER_CLIENT_SECRET }}",
"scope": "orders.read",
"clientAuth": "basic"
}
}
- The exchange is a
POSTtotokenUrlwithContent-Type: application/x-www-form-urlencoded,Accept: application/jsonand the formgrant_type=client_credentials, plusscopeandaudiencewhen set. WithclientAuth: "basic"the client id and secret go in anAuthorization: Basicheader (each form-encoded first, as RFC 6749 §2.3.1 requires); with"body"they go in the form asclient_idandclient_secret. The exchange has a 15-second timeout and is not retried. - A usable response is a
2xxwith a non-empty stringaccess_token. Anything else fails withHTTP_AUTH_TOKEN_EXCHANGE_FAILED. - Token reuse. The token is cached per tenant and per credential — the
combination of
tokenUrl,clientId,clientSecret,scope,audienceandclientAuth— and reused by later calls. Rotating the secret, or changing any of those fields, starts a separate entry and a fresh exchange. - Refresh timing. A token is reused until shortly before its
expires_inlifetime ends: the refresh margin is 60 seconds, or half the lifetime (rounded up) when that is shorter. A 3600 s token is reused for 3540 s, a 120 s token for 60 s, a 60 s token for 30 s. A lifetime of 1 s or less is used once and not reused. When the response carries no numericexpires_in, the token is reused for 300 s. - Concurrent callers that need the same token share one exchange.
- A
401on a reused token triggers one refresh and one retry. A401on a token minted for that very call is returned as the target's answer. Anhttpaction withbinaryUploadrefreshes but does not resend, because the upload stream is already consumed; the next run uses the fresh token.
#Network rules
These apply to http, httpCallback, $http and the OAuth2 exchange:
- Targets. Only
httpandhttpsURLs to public hosts. Loopback, private-network (10/8, 172.16/12, 192.168/16), link-local (including cloud metadata addresses), carrier-grade NAT (100.64/10) and0.0.0.0/8targets are refused, however they are spelled (decimal, hex or octal IPv4, IPv6, or IPv4-mapped IPv6). The check runs on the URL and on every redirect hop. - Redirects.
redirectisfollow(default) ormanual.followtakes up to 5 hops. A 301 or 302 on aPOST, or a 303 on any method other thanGET/HEAD, continues as aGETwithout a body; 307 and 308 keep the method and body. When a hop crosses to another origin, theAuthorizationandCookieheaders are dropped.manualreturns the3xxresponse itself, with the target inheaders.location. - Retries and timeouts (
httpaction).maxRetries(0–5, default 2) retries only on408,425,429,5xxand a failed connection (never on a timeout), with backoff betweeninitialRetryDelayMs(default 15 000) andmaxRetryDelayMs(default 60 000).timeoutMs(1 000–115 000, default 115 000) bounds each attempt. A$httpsource uses these defaults. - Errors. An
httpaction treats a response as an error whenresult.status >= 400, unless you seterrorWhen.
#Validation and errors
#At save
Workflow definitions (POST / PUT /definition/workflows…) answer 422
with error.code: "VALIDATION_FAILED". Each problem is a string in
details.errors[] (repeated in details.validation_errors[]), prefixed with
its path:
{
"error": {
"status": 422,
"code": "VALIDATION_FAILED",
"message": "…",
"details": {
"errors": [
"definition_json.jobs.0.actions.0.config.auth: auth profile \"nope\" is not declared in the definition's root `auth` map (declared: partner)."
]
}
}
}
Checked in inline job steps (including onSuccess / onError): an undeclared
profile name; auth together with an explicit headers.Authorization
(case-insensitive); an apiKey whose header or query name is also set by
hand; a profile reading outside its scope; a basic username with :; a
profile key that is not an identifier. An apiKey profile next to an unrelated
Authorization header is accepted. Steps that reference a job by name are not
checked at save — they fail at run time instead.
App specs (POST /definition/specs, PUT /definition/specs/{id}) answer
422 with details.code: "SPEC_STRUCTURE_INVALID" and details.issues[] of
{ path, code, message }:
issues[].code |
Raised for |
|---|---|
auth_profile_unknown_reference |
A profile name not in the root auth map — in jobs, a widget actions step, or a $http source |
auth_conflict |
auth plus a hand-set Authorization header, or an apiKey name also set by hand |
auth_profile_scope |
A profile reading page, outputs, input, itemId, thisWidget or the whole context |
{
"path": "dashboards.main.pages.root.dataSources.remote.$http.auth",
"code": "auth_profile_unknown_reference",
"message": "auth profile \"nope\" is not declared in the spec's root `auth` map (declared: svc). Declare it there, or use an inline auth block."
}
A spec that contains a {{ secrets.* }} reference anywhere, profiles included,
is refused with 422 VALIDATION_FAILED and
details.code: "SECRET_REF_IN_DEFINITION".
#At run time
A run-time auth failure is a 400 whose top-level code is BAD_REQUEST; the
specific reason is in details.code. The credential itself never appears in
an error.
details.code |
Meaning | Other details |
|---|---|---|
HTTP_AUTH_PROFILE_UNKNOWN |
The name is not in the running document's root auth map (a bound job). |
profile, declared[] |
HTTP_AUTH_SECRET_MISSING |
A secret behind this profile is not set. Only requests naming this profile fail. | profile, missing_secrets[] |
HTTP_AUTH_INVALID_CREDENTIAL |
A credential resolved empty, or a basic username contains : or is empty. |
field |
HTTP_AUTH_CONFLICT |
The profile's header or query parameter is also set on the request (a bound job). | conflict (header / query), name |
HTTP_AUTH_TOKEN_EXCHANGE_FAILED |
The OAuth2 token endpoint answered non-2xx, or 2xx without an access_token. |
status, token_url, reason?, body_preview (redacted) |
HTTP_AUTH_TOKEN_CACHE_UNAVAILABLE |
OAuth2 cannot run in this environment. | — |
HTTP_AUTH_ARM_NOT_PERSISTED |
httpCallback only (status 500): the retry state an OAuth2 callback needs could not be saved, so the request was not sent. |
action_run_id, iteration, reason |
token_url carries only the origin and path of tokenUrl, and body_preview
(at most 200 characters) has the client secret and any returned tokens replaced
with ***.
Where you see it:
- Workflows — on the failed action, in
GET /workflows/runs/workflows/{id}→job_runs[].action_log[].error. - Apps — a
$httpsource that fails drops the widget that reads it, and the response carries anERRORnotification with idwidget-error-<widgetId>and a generic message for the end user. A failedactionsorjobstep also surfaces as anERRORnotification.
#External data sources ($http)
A $http source fetches rows from an external JSON API for a page. Declare it
in the page's dataSources and bind a widget to it by name (see
Apps → Data sources).
| Field | Type | Notes |
|---|---|---|
url |
string | Required. A smart string. |
method |
GET or POST |
Default GET. |
headers |
object of strings | Smart strings. |
query |
object of strings | Added to the URL's query. Smart strings. |
body |
any JSON | Sent as the JSON body. |
auth |
profile name or inline block | See Declare auth profiles and Inline auth. |
rowsPath |
string | Dotted path to the row array in the response body (data, result.items). Without it, the body itself must be the array. |
paged |
{ totalPath?, hasNextPath? } |
The API pages the rows itself. See Server-paged sources. |
"dataSources": {
"partnerOrders": {
"$http": {
"url": "{{ config.app.partner_url }}/orders",
"query": { "status": "open" },
"auth": "partner",
"rowsPath": "data"
},
"$pipe": [{ "map": { "id": "{{ order_id }}", "name": "{{ customer.name }}", "total": "{{ amount }}" } }]
}
}
- Rows. If the value at
rowsPath(or the body) is not an array, the source has no rows. At most 5 000 rows are kept; beyond that the response carries aWARNINGnotificationhttp-source-truncated("Showing the first 5000 rows — the data source returned more. Filter at the source to see the rest."). - Secrets. Config slots holding secret references resolve inside the source
exactly as they do for a job, so
url,headers,query,bodyandauthmay all read such slots. - Fetch once per request. Identical sources are fetched once per request and fetched again after a command writes, so the page shows fresh data.
$pipeon a feed. A source bound to a grid or a picker may carry only row-localmapsteps. Usemapto rename fields, compute display values, or add the fields a row action reads.- Paging, sorting and filtering. Without
paged, the grid receives the whole result on every render and pages, sorts and filters it itself.
#Server-paged sources
When the API has more rows than you want to fetch at once, let it page. Build
the upstream window from the requesting grid's own state,
page.widgets.<gridId>.state.data_grid.page and .pageSize, and declare
paged:
"dataSources": {
"partnerOrders": {
"$http": {
"url": "{{ config.app.partner_url }}/orders",
"auth": "partner",
"query": {
"page[limit]": "{{ $string($default(page.widgets.orders.state.data_grid.pageSize, 20)) }}",
"page[offset]": "{{ $string(($max([$default(page.widgets.orders.state.data_grid.page, 1), 1]) - 1) * $default(page.widgets.orders.state.data_grid.pageSize, 20)) }}"
},
"rowsPath": "data",
"paged": { "totalPath": "meta.total" }
}
}
},
"widgets": {
"orders": {
"widgetComponent": "data_grid",
"config": {
"dataSource": "partnerOrders",
"pageSize": 20,
"columns": [{ "type": "string", "field": "name", "header": "Customer" }]
}
}
}
The grid's
pageis 1-based, but its first request may carry0: clamp with$max([…, 1])as above. Keep the limit you send equal to the grid'spageSize(default 20).The rows you return are the page: the grid does not slice, sort or filter them. To sort or filter, map the grid's state upstream —
….state.data_grid.sorting[0].id/.descand….state.data_grid.filters.<column>.The page always carries a
total, which the grid's pager needs:Declared totaltotalPathpointing at a numberThat number. hasNextPathoffset + rows on this page, pluspageSizemore while the value athasNextPathis truthy.Neither ( "paged": {})offset + rows on this page, pluspageSizemore while this page is full.offsetis(page − 1) × pageSize. WithouttotalPaththe total is a lower bound that grows as the user pages forward and settles on the last page. WithpageSize5 and a full page 2,"paged": {}reports 15. PointhasNextPathonly at a value the API leaves out (or sets falsy) on its last page — a value that is always present makes the pager offer another page forever. WithouthasNextPath, a full last page offers one empty page more.An empty page is still returned, so the grid clears its old rows.
A picker's type-ahead reads the rows and filters them itself, without paging.
When a widget the source reads acts, the grid returns to page 1.
For the grid side — page size, column sort and filter flags, stable row ids —
see Data grids → Server-paged $http.
#API reference
The definition, activation and render routes, with full schemas, are in the API reference.