Skip to content

API Integrations ​

An integration is configuration that describes an external API: its base URL, the methods and paths that may be called, and the credentials to attach. It lives in your config tree as integrations/<key>.toml. A server function calls it with ctx.integrations.call, and the platform makes the request on the function's behalf — so your API keys stay server-side, resolved into the request by the platform and never visible to the function's code or to any client.

Defining an Integration ​

Create a TOML config file for each external API:

toml
# primitive/dev/integrations/weather-api.toml
[integration]
key = "weather-api"
displayName = "Weather API"

[requestConfig]
baseUrl = "https://api.weather.com/v1"
allowedMethods = ["GET"]
allowedPaths = ["/forecast/*", "/current"]

[requestConfig.defaultHeaders]
X-API-Key = "{{secrets.WEATHER_API_KEY}}"
bash
primitive config push

Key Configuration ​

  • integration.key — Unique identifier a function uses to call the integration
  • Availability — not a TOML key. Every integration you push is callable straight away; take one out of service with primitive integrations disable and put it back with primitive integrations enable. Archiving one is a third verb, primitive integrations archive — see Archiving an integration
  • integration.timeoutMs — Request timeout in milliseconds
  • requestConfig.baseUrl — The external API's base URL
  • requestConfig.allowedMethods / requestConfig.allowedPaths — The HTTP methods and paths that may be called (trailing-* wildcards supported)
  • requestConfig.defaultHeaders — Headers sent on every request; use {{ secrets.KEY }} for credentials or {{ vars.KEY }} for non-secret values
  • requestConfig.staticQuery — Query parameters appended to every request

Calling It From a Function ​

A function names the integrations it may reach in its capabilities, one integration:<key> line each — the function's egress allowlist — and calls one with ctx.integrations.call:

toml
# primitive/dev/functions/forecast.toml
[function]
key = "forecast"
entry = "functions/forecast/index.ts"
access = "true"
capabilities = ["integration:weather-api"]
ts
// primitive/dev/functions/forecast/index.ts
import { defineFunction } from "primitive-functions";

export default defineFunction(async (input: { city: string }, ctx) => {
  const response = await ctx.integrations.call("weather-api", {
    method: "GET",
    path: "/forecast/daily",
    query: { city: input.city },
  });
  if (response.errorCode) throw new Error(`weather-api: ${response.errorCode}`);
  return { status: response.status, forecast: response.body };
});

path is relative to the integration's baseUrl, and the request also takes headers, a JSON body, or a form-urlencoded form. The answer carries the upstream status, headers and body, plus durationMs, a traceId, and an errorCode when the platform or the upstream refused.

Everything the integration declares applies to what the request really asks for, rather than to the string the function wrote:

  • The host is the integration's. The path is resolved and normalized before it is checked, so //elsewhere/x and /allowed/../blocked are refused rather than quietly sent somewhere else.
  • allowedMethods and allowedPaths are enforced — a method or path outside them is refused (DISALLOWED_METHOD, DISALLOWED_PATH) before anything goes upstream.
  • Redirects are re-checked before they are followed. A hop is followed only if it stays on the integration's exact origin (same scheme, host and port), lands on an allowed path, and uses an allowed method. Otherwise the 3xx comes back to the function unfollowed, so it can see what the upstream wanted. The chain is capped at five hops.
  • The upstream request is bounded by the integration's own timeoutMs or by the calling invocation's remaining time, whichever is smaller — including while the response body is still arriving, not merely while it waits for a reply. Past it, the call answers UPSTREAM_TIMEOUT.

Who may call it is the function's business. An integration has no endpoint of its own that a client can call. The function's access gate decides who may make the code run, and the integration:<key> capability decides which integrations that code may reach; together they are the whole authorization. A function without the capability line is refused with FUNCTION_INTEGRATION_GRANT_MISSING, naming the line to add, and a disabled or archived integration refuses every call (INTEGRATION_INACTIVE). See Calling an outside service for the function side of the call.

Referencing Secrets and Vars ​

Credentials live in your app's secrets store; non-secret config values live in its vars store. Reference either from the integration config the same way — {{ secrets.KEY }} or {{ vars.KEY }} — for example, an API key in a default header and a non-secret account ID in a query param:

toml
[requestConfig.defaultHeaders]
Authorization = "Bearer {{secrets.WEATHER_API_KEY}}"

[requestConfig.staticQuery]
account = "{{vars.ACCOUNT_ID}}"

Both resolve server-side when the platform makes the request, so neither ever reaches your function's code. They diverge in two ways worth knowing: a {{ secrets.KEY }} reference is checked when you save the integration — naming a secret that doesn't exist fails the save — while a {{ vars.KEY }} reference isn't validated at save time, so a nonexistent var just leaves the literal placeholder unresolved at call time. And request previews and logs redact any value that resolved from a secret, but never one that resolved from a var — vars aren't secret, so they stay visible. Set a secret with primitive secrets set; a var is a key in vars.toml, applied with primitive config push. See App Secrets for managing both.

Testing and Inspecting ​

bash
primitive integrations test <integration-id> --path /current   # make a request through the integration
primitive integrations logs <integration-id>                    # recent calls, with the function that made each
primitive integrations logs <integration-id> --run <run-id>     # only the calls that one run made

Every call is recorded on the integration's own log with the calling function's key, so primitive integrations logs answers "what did this function send out, and when".

Each row also names where the call came from. The RUN column carries the run the call was made from — a workflow run for a workflow step, a task run or a trigger fire for a server function — and --run <run-id> narrows the log to exactly that run's calls, so a run you are debugging leads straight to the requests it sent. Under --json the same ids are correlation.runId and correlation.functionId, beside detail.functionKey. A call made from an HTTP request invocation has no run — a request invocation writes no run row — so its RUN cell is blank and --run never returns it.

An integration can carry a regression suite the same way prompts do: test cases are TOML files in a sidecar directory beside the integration — integrations/<key>.tests/<case>.toml — applied by config push like the rest of the config tree. primitive config fields integration lists the case file's keys; deleting a case is removing its file and running primitive config push --prune. The file's name is the case's identity, and run-all runs the registered cases, not whatever is on disk — see Test Case Identity for the push step that closes that gap and config diff's counters.

A case's inputVariables is the request the run issues, at the top level with no wrapper key: method, path, headers, query, and the body — body, or form for form-urlencoded, or bodyMode plus multipartFields for multipart. A key the case omits falls back to the integration's own configuration, exactly as a live call does (defaultMethod for the method, / for the path, defaultHeaders/staticQuery merged either way), so a case against a POST-only integration can name just its path and body. There is no configured body: a case that authors no body or form sends none.

toml
# integrations/plaid.tests/mints-a-link-token.toml
[test]
name = "mints a link token"
inputVariables = '''
{
  "method": "POST",
  "path": "/link/token/create",
  "body": { "client_name": "Acme", "products": ["transactions"] }
}
'''
expectedOutputPattern = "link-sandbox-"

CLI Reference ​

An integration is defined in integrations/<key>.toml and applied with config push; the integrations commands read and test what is there.

bash
primitive config create integration my-api       # Scaffold integrations/my-api.toml
primitive config push --only integration/my-api  # Create or update it on the server
primitive integrations list                      # List all integrations, with their IDs
primitive integrations get <integration-id>      # View integration details
primitive integrations test <integration-id>     # Test the integration
primitive integrations logs <integration-id>     # View recent call logs
primitive integrations disable <integration-id>  # stop serving it now
primitive integrations enable <integration-id>
primitive integrations archive <integration-id>  # archive it (soft delete)

An integration's availability is server-owned — see Availability is server-owned.

Archiving an integration ​

primitive integrations archive retires an integration for good — see Archive vs prune for what archiving keeps, what config push --prune destroys, and how to recover from an archive. It addresses the integration by its ID, not by the key its TOML file is named for:

bash
primitive integrations list                            # the ID column is the argument
primitive integrations archive <integration-id>        # confirms first; -y skips the prompt
primitive integrations archive <integration-id> --json # the server's envelope on stdout

Delete an integration by removing its file and running primitive config push --prune.

The Inbound Half: Webhooks ​

An integration is the outbound half — your app calling a third-party API. Most real integrations also need the inbound half: a webhook the provider calls when something happens on their side (a payment succeeds, a repo is pushed). That is a webhook trigger declared on a server function, with signature verification built in — the provider's delivery runs the function. Integrating a service like Stripe or GitHub usually means setting up both.

Next Steps ​

Documentation validated against js-bao-wss-client 3.4.0 · js-bao 0.11.0 · primitive-admin 1.0.62 · primitive-app 3.1.0 — 2026-09-30