Authorize

#HTTP Auth & External Data

Three things call external HTTP APIs:

  • the http action (a synchronous request inside a job);
  • the httpCallback action (a request whose result arrives later);
  • the $http data 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:

  1. Store the value as a secret.
  2. Wire a {{ secrets.<scope>.NAME }} reference into a config slot.
  3. Point an auth profile 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>.auth in an app spec (for example { "type": "bearer" }) says how the host's render requests authenticate to the app. It is unrelated to the root auth map, 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, name and clientAuth are literals. Every other field is a string: a literal or a {{ }} template. A $jsonata object is refused on a credential field.
  • A literal basic username 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 POST to tokenUrl with Content-Type: application/x-www-form-urlencoded, Accept: application/json and the form grant_type=client_credentials, plus scope and audience when set. With clientAuth: "basic" the client id and secret go in an Authorization: Basic header (each form-encoded first, as RFC 6749 §2.3.1 requires); with "body" they go in the form as client_id and client_secret. The exchange has a 15-second timeout and is not retried.
  • A usable response is a 2xx with a non-empty string access_token. Anything else fails with HTTP_AUTH_TOKEN_EXCHANGE_FAILED.
  • Token reuse. The token is cached per tenant and per credential — the combination of tokenUrl, clientId, clientSecret, scope, audience and clientAuth — 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_in lifetime 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 numeric expires_in, the token is reused for 300 s.
  • Concurrent callers that need the same token share one exchange.
  • A 401 on a reused token triggers one refresh and one retry. A 401 on a token minted for that very call is returned as the target's answer. An http action with binaryUpload refreshes 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 http and https URLs 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) and 0.0.0.0/8 targets 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. redirect is follow (default) or manual. follow takes up to 5 hops. A 301 or 302 on a POST, or a 303 on any method other than GET/HEAD, continues as a GET without a body; 307 and 308 keep the method and body. When a hop crosses to another origin, the Authorization and Cookie headers are dropped. manual returns the 3xx response itself, with the target in headers.location.
  • Retries and timeouts (http action). maxRetries (0–5, default 2) retries only on 408, 425, 429, 5xx and a failed connection (never on a timeout), with backoff between initialRetryDelayMs (default 15 000) and maxRetryDelayMs (default 60 000). timeoutMs (1 000–115 000, default 115 000) bounds each attempt. A $http source uses these defaults.
  • Errors. An http action treats a response as an error when result.status >= 400, unless you set errorWhen.

#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 $http source that fails drops the widget that reads it, and the response carries an ERROR notification with id widget-error-<widgetId> and a generic message for the end user. A failed actions or job step also surfaces as an ERROR notification.

#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 a WARNING notification http-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, body and auth may 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.
  • $pipe on a feed. A source bound to a grid or a picker may carry only row-local map steps. Use map to 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 page is 1-based, but its first request may carry 0: clamp with $max([…, 1]) as above. Keep the limit you send equal to the grid's pageSize (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 / .desc and ….state.data_grid.filters.<column>.

  • The page always carries a total, which the grid's pager needs:

    Declared total
    totalPath pointing at a number That number.
    hasNextPath offset + rows on this page, plus pageSize more while the value at hasNextPath is truthy.
    Neither ("paged": {}) offset + rows on this page, plus pageSize more while this page is full.

    offset is (page − 1) × pageSize. Without totalPath the total is a lower bound that grows as the user pages forward and settles on the last page. With pageSize 5 and a full page 2, "paged": {} reports 15. Point hasNextPath only 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. Without hasNextPath, 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.