#Auth & API Gateway
Every Helm API call goes through one gateway. It authenticates the caller, applies rate limits, and routes the request to the surface that owns it.
#Base URLs
| Environment | URL |
|---|---|
| Production | https://api.helm.bridge-labs.com |
| Staging | https://api.staging.bridge-labs-dev.com |
The interactive console on this site (the Try it buttons and the API reference) targets staging by default, so experimenting never touches production.
#Authentication
Authenticate with a Personal Access Token using HTTP Basic auth — your
client_id and client_secret encoded as base64(client_id:client_secret) in
the Authorization header. To create a token, see
Provisioning.
Enter your token once in the Authorize bar at the top of this page, then Send the call below — it returns the actions your token is allowed to perform, which is the quickest way to confirm your credentials work:
GET/access/me/permissionsWho am I — verify the tokenAPI docs ↗Try it
When a call is refused before it reaches the resource:
| Answer | Cause |
|---|---|
401 UNAUTHORIZED |
No Authorization header, a malformed one, or an invalid Bearer JWT |
403 FORBIDDEN — Invalid API token |
Unknown client_id or wrong client_secret |
403 FORBIDDEN — API token has been revoked |
The token was revoked |
403 FORBIDDEN — API token has expired |
The token's expires_at has passed |
A 403 for a valid token means the token lacks the permission the call needs.
#Exchange the token for a JWT
Instead of sending the token on every call, you can exchange it once for a
short-lived JWT (OAuth 2.0 client-credentials) and send that as
Authorization: Bearer <access_token>. The exchange takes the credentials as
HTTP Basic auth, a form-encoded body, or JSON:
curl -X POST https://api.helm.bridge-labs.com/auth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=client_credentials
POST/auth/tokenExchange a token for a JWTAPI docs ↗Try it
The 200 is the standard OAuth body — not wrapped in data:
{ "access_token": "eyJ…", "token_type": "Bearer", "expires_in": 3600 }
The JWT is valid for expires_in seconds (one hour); exchange again when it
expires. An expired or tampered JWT answers 401.
| Answer | Cause |
|---|---|
400 BAD_REQUEST |
Credentials missing, client_id not a UUID, or grant_type other than client_credentials; details.errors names each field |
403 FORBIDDEN |
Unknown client_id, wrong, revoked or expired secret (same messages as the table above) |
429 TOO_MANY_REQUESTS |
Ten failed exchanges from your IP within 60 seconds |
After ten failed exchanges from one IP, every exchange from that IP — valid
ones included — answers 429 until the 60-second window has passed.
#Routes
The gateway routes by path prefix:
| Prefix | What lives here |
|---|---|
/entity/* |
Entity types, entities, relationships, query, GraphQL, artifacts |
/schema/* |
JSON schemas and validation |
/definition/* |
Workflow, job, and app-spec definitions |
/workflows/* |
Workflow activation, triggering, and run monitoring |
/apps/* |
App activation (config, integrations, tokens) and render |
/access/* |
Users, tokens, roles, policies, grants, shares, manifests, secrets |
/event/* |
The change events recorded for entities and relationships |
GET /healthz is unauthenticated and reports gateway health.
Two other routes are reachable without an Authorization header, both because the caller is a browser holding no credentials, and both authorised by a signed capability in the URL rather than by a token:
| Route | Who follows it |
|---|---|
GET /entity/temp-files/{id}/download |
anyone holding a minted download link |
POST /apps/upload/{integrationId}/{uploadId} |
a provider's upload widget, POSTing the picked file |
The carve-outs are exact — the sibling temp-file routes stay authenticated — and
the upload ingest gets its own body-size ceiling (25 MiB) rather than the 1 MiB
default that applies to ordinary API calls. A body over the ceiling answers
413; on ordinary calls the code is REQUEST_BODY_TOO_LARGE.
#Rate limits
Production applies two independent limits, each over a rolling 60-second window:
| Limit | Ceiling | Applied | Keyed on |
|---|---|---|---|
| Per-IP | 10,000 req / 60 s | before auth | the originating client IP |
| Per-identity | 1,000 req / 60 s | after auth | the authenticated identity (see below) |
A request must satisfy both. The per-IP ceiling is deliberately generous so a shared egress (an office or VPN where many users share one IP) isn't throttled as a group; in normal authenticated use you reach the per-identity 1,000/60 s limit first. "Identity" is whatever credential you present, and each gets its own independent budget:
- a signed-in user (per user);
- an API token / machine client (per client);
- an activated app rendering for one of its end users (per end user, per integration) — so one busy end user can't exhaust every other end user of the same app;
- an inbound webhook (per workflow).
Because the windows are rolling, capacity recovers continuously as your older requests age out of the trailing 60 seconds — there's no clock-aligned reset to wait for.
When you exceed a limit the API returns HTTP 429:
{
"error": {
"status": 429,
"code": "TOO_MANY_REQUESTS",
"message": "Too many requests. Please try again later.",
"details": { "scope": "identity", "limit": 1000 }
}
}
details.scope is "ip" or "identity" (which limit you hit) and
details.limit echoes that ceiling; no identifying data is included. There is
no Retry-After header — back off (exponential, with jitter) and retry. On
successful responses the API returns X-Rate-Limit-Limit and
X-Rate-Limit-Remaining; these are not present on a 429. To stay under the
limits: batch where the API supports it, cache reusable responses, and spread
bulk work over time rather than bursting.
#Responses
A success body wraps the resource in data:
// single resource (200/201)
{ "data": { "id": "…", "name": "…" } }
// list (200) — pagination is a SIBLING of data, never inside it
{ "data": [ … ], "pagination": { "page": 1, "limit": 20, "total": 42, "total_pages": 3 } }
A few endpoints answer in a shape of their own, and the
API reference shows each one: the token exchange
(POST /auth/token), GET /access/me/permissions, POST /entity/graphql, a
run dispatch (POST /workflows/wr), an app render (POST /apps/run), and file
downloads and exports.
Lists that count their rows carry pagination — most /access/* lists and each
resource's …/{id}/grants list; the API reference shows which. Other lists take
the same page / limit parameters but return only data — keep requesting
pages until one comes back shorter than limit. Keyset-paginated endpoints
(POST /entity/query) return a next_cursor sibling instead, and only when a
full page came back — its absence means you have reached the end. Paging is
forward-only; there is no prev_cursor.
Token-mint endpoints return the raw secret once — capture it then, because
it is stored only as a hash and is never retrievable again. Where it sits
depends on the endpoint: minting a PAT (POST /auth/tokens/api,
POST /access/api-tokens) returns client_secret inside data alongside its
client_id, while issuing an app token
(PUT /apps/{specId}/integrations/{integrationId}/token) returns raw_token as
a sibling of data, so audit-log redaction can target that one key.
#Errors
Every error uses one envelope:
{
"error": {
"status": 422,
"code": "VALIDATION_FAILED",
"message": "Human-readable explanation.",
"details": {}
}
}
The HTTP status is authoritative; code is a stable machine-readable string and
details, when present, is code-specific (validation errors carry the offending
path; a 409 names what collided, such as a name and version). Common
codes: 400 BAD_REQUEST, 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND,
409 CONFLICT, 413 REQUEST_BODY_TOO_LARGE, 422 VALIDATION_FAILED,
429 TOO_MANY_REQUESTS.
Two codes share the 422: VALIDATION_FAILED when the body or query failed
schema validation, and UNPROCESSABLE_ENTITY when it parsed but the request
could not be carried out. Branch on the status; use code to tell the two
apart.
A request that fails validation carries details.validation_errors: one
"<path>: <reason>" line per problem, pointing at the value that caused it.
details.errors repeats the same lines.
{
"error": {
"status": 422,
"code": "VALIDATION_FAILED",
"message": "Invalid request body",
"details": {
"errors": [
"sort: Invalid input: expected array, received string",
"limit: Invalid input: expected number, received string"
],
"validation_errors": [
"sort: Invalid input: expected array, received string",
"limit: Invalid input: expected number, received string"
]
}
}
}
Where a field takes one of a fixed set of values, the reasons are merged: a bad
filter operator comes back as one line naming every operator the field
accepts. A field that takes one of several types lists one line per type it
could have been, and a pair of mutually-exclusive fields names both. Array
positions are included, so a bad filter nested in a composite reports
filter.filters.0.operator. Beyond twenty lines the rest are counted
(… and 43 more) rather than listed.
Entity attributes that fail the entity type's JSON schema are also a
422 VALIDATION_FAILED; there details.errors points into the attributes with
a JSON-pointer path, such as
"/state: Instance does not match any of [\"open\",\"done\"].".
#Conventions
- Casing — request/response fields are
snake_case; the onlycamelCasesurface is an app spec'sspec_document(the authored definition), which the platform translates at the boundary. - Methods —
GETreads,POSTcreates (201),PUTreplaces or mints,PATCHpartially updates,DELETEremoves. - Idempotency — send an
Idempotency-Keyheader on anyPOST,PUT,PATCHorDELETEto make a retry safe. For 24 hours, repeating the call with the same key and the same body returns the original successful response (withX-Idempotency-Replay: true) instead of running it again; the same key with a different body answers409 IDEMPOTENCY_KEY_PAYLOAD_MISMATCH. Only successful responses are replayed, so a failed call can be retried with the same key. Without the header, every call runs.
#API reference
The complete, interactive API — every route, schema, and a Try-It console — is
at /openapi/.
The spec describes the requests. The documents that travel inside them — a
workflow's definition_json, an app's spec_document, one action's or widget's
config — are published as JSON Schema alongside it, because the spec carries
those as free-form objects. /schemas/index.json lists
what is available and names the commit that built it. Each schema keeps the name
GET /schema/schemas/{name} resolves, so the static copy and the API agree.