# HTTP API connector Any HTTPS API: a base URL, an auth style and a key. Methods and paths stay fenced. URL: https://docs.qaldy.com/connectors/http/ - **Status:** [[Available]] - **Shelf:** Developer - **How you connect:** API key - **Regions:** global (not available to Kazakhstan-only workspaces) - **The service’s own API reference:** https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication Connect any HTTPS API without waiting for a native connector: a base URL on a public host, how the key is sent, the methods and path prefix apps may use, and optional fixed headers. Apps call it through the gate; the key never reaches app code, the host is pinned, and every call is audited. ## Connect one (admins) /connectors → **Add an HTTP API** (or **Set up** on a service's card for a form filled in from its preset, which only asks for a name, the key and, where the service has one, your own address): | Field | Rule | |---|---| | Name | How it appears on the gate in the City | | Base URL | `https://[/path]`. No IP literals, no `localhost`/`.local`/`.internal`/`.test` names, no userinfo, no query, port 443 only, host ≤ 100 characters. The API also refuses a name that resolves to a private address | | How the key is sent | **Bearer token** (`Authorization: Bearer …`), **Custom header** (`X-Api-Key: …`, or a raw `Authorization`), **User name and password** (`Authorization: Basic …`), **Secret in the URL path** (webhook-style: [see below](#path-token)), **No key** (public APIs: the fence and the audit still apply). There is no query-parameter style: [why](#why-there-is-no-query-parameter-style) | | Key | Write-only, ≥ 8 characters (basic passwords excepted), printable ASCII for header styles; for a path token, plain URL segments. Sealed to **this connection and this host**: a new host, auth style or user name needs it typed again | | Methods | Any of GET, POST, PUT, PATCH, DELETE. Read-only? Keep GET alone | | Path prefix | Apps may only call paths under it (`/items` allows `/items` and `/items/3`, never `/itemsx`). `/` allows everything | | Test path | A harmless GET for **Test connection** (defaults to the prefix). A connection that can't `GET`, or that says `testable: false` (a service that only sends), is not probed: Test connection only opens the sealed key and says nothing was sent | | Fixed headers | Up to 8 non-secret headers the gate adds (`Notion-Version`, Ozon's `Client-Id`). Shown to admins: never a key | A connection made from a service's preset also carries `preset` (the catalog entry's id) so the gallery shows it on that service's card; the HTTP API connector's own card lists every connection. After saving, the dialog shows the connection id (`conn_…`), the SDK call and the manifest entry, and **Test connection** makes one real GET through the gate ("Works. api.example.com answered 200 in 142 ms", or what failed and the next step). A refused key (401/403) marks the gate Expired. API: `POST /v1/workspaces/:ws/connectors/http` with `HttpConnectorInput`, `PUT /v1/workspaces/:ws/connections/:id/http` with `HttpConnectorUpdate` (schemas in `packages/contracts/src/connector-catalog.ts`). ## Use it from an app The manifest names the connection in `scope` (the builder writes this; a person approves it): ```json { "id": "http", "actions": ["request"], "scope": "conn_k3v9…" } ``` ```ts const api = ctx.connectors.http('conn_k3v9…') const low = await api.get<{ items: Item[] }>('/items', { query: { below: 5 } }) await api.post('/items/BEAN-1/reorder', { json: { qty: 20 } }) // put, patch, delete too. A reply outside 2xx throws a ConnectorError (code `upstream_error`, // with the API's own message); pass { throwOnError: false } to read it yourself. ``` `HttpResponse` is `{ status, ok, headers, body, text }`; `body` is parsed JSON when the reply is JSON. Refusals the gate writes itself carry `x-qaldy-gate: refused`, so the SDK maps them to `egress_blocked` (with `reason`), `not_connected`, `policy_unavailable`… instead of an upstream error. ## Path token Some services make the URL the credential: a Slack incoming webhook (`https://hooks.slack.com/services/T000/B000/XXXX`), a Bitrix24 inbound webhook (`https://.bitrix24./rest///`). For those, pick **Secret in the URL path** (`path_token`). The base URL holds the part that is not secret (`https://hooks.slack.com/services`, `https://acme.bitrix24.kz/rest`); the secret (`T000/B000/XXXX`, `1/abcdef…`) is sealed, and the gate builds every upstream path as ``` / / ``` - The secret is one or more `/`-separated segments of letters, digits and `- _ . ~`, none empty and none `.` or `..`, at most 512 characters (`isHttpPathToken` in contracts, `is_path_token` in the gate). It can add neither a query nor a fragment nor a dot segment. - It always comes **before** anything the app chose, and the app's path is normalised first (`..`, `%2e`, `%2f`, `\` refused), so an app can't replace it, shorten it or climb out of it. A path that repeats the secret is just more path after the real one. - The reply is scrubbed of the whole secret, of each segment of 6+ characters, and of their percent-encoded forms, in the body and every header (also across chunk boundaries). - Nothing records the path: audit rows, City events and logs carry the host and the status only. - In the console a webhook preset takes the **whole URL** and reads the host (if it is the admin's own) and the secret part from it (`parsePresetWebhookUrl`). Rust tests: `http_connector::tests::a_path_token_is_spliced…`, `…an_app_cannot_override_the_path_token…`, `flow_tests::http_flows::path_token_connections_splice_the_secret_and_the_app_cannot_read_or_override_it`. ## Why there is no query-parameter style A key in `?api_key=…` is copied into the upstream's access logs, proxies and browser histories, and into any error message that quotes the URL. The style was **removed**: - Zod refuses `{ style: "query" }` with the reason ("A key in the URL query ends up in the service's access logs … Use a header, a bearer token or a path token") on create and update. - The gate refuses a policy that still carries it: `503 connector_query_auth_removed`, nothing sent. - A connection **stored** with it before the removal is read, not lost: the API reports it as *not working*, Test connection answers `query_auth_removed`, the console says why and opens the form on a bearer token. Moving it to another style needs the key typed again (it was sealed for that style). ## What the gate does on every call `https://connector.qaldy.internal/http//?`, signed by the runtime host: 1. The version's policy must grant `{id: "http", actions: ["request"], scope: }` and list the connection (`action_not_declared` otherwise). 2. Re-validates the connection's settings (`connector_misconfigured` if they don't parse). 3. Method allowed (`method_not_allowed`), path normalised and under the prefix (`path_not_allowed`: `.`/`..` segments, `%2e`, encoded `/` or `\`, empty segments are refused). 4. Opens the sealed key with aad `|http::|` (`connector_not_connected`, `connector_secret_unavailable`). 5. Resolves the host once; every address must be public (no loopback, private, link-local or metadata ranges: `private_address`) and it connects to exactly those addresses (no DNS rebinding). TLS with SNI = the host; redirects are never followed. 6. Builds the request: the app's headers minus routing, `authorization`, cookies, proxy and `x-qaldy-*` headers; the fixed headers; the key per auth style (a header, or spliced into the path); `Accept-Encoding: identity`. 7. Streams the reply back (≤ 16 MiB, ≤ 60 s) with every echoed form of the key replaced by `[redacted]` in headers and body, also when it is split across chunks (raw, `:` percent-encoded, form-encoded, base64 basic credentials). `Set-Cookie` and trailers are dropped. A compressed reply is refused (`connector_upstream_encoded`), because it can't be checked for the key. 8. Records `connector.called` (connection, host, method, status, duration; never paths, queries, bodies or headers) → an audit row and the City's parcel between the app and its gate. ## Threat notes | Threat | Mitigation | |---|---| | App reaches an internal service through the connection | Host fixed by the admin, public addresses only, pinned per call, path prefix enforced after normalisation | | App exfiltrates the key | It never enters the isolate; echoes are scrubbed; compressed replies refused | | Key replayed against another host | aad binds the key to the connection id **and** host: a moved base URL can't open it | | Compromised control plane repoints a key | Same binding: the API can't send an existing key to a new host without the plaintext | | Short key scrubbing mangles replies | Keys under 8 characters are refused (basic passwords: the base64 credentials are scrubbed) | | Residual | The upstream sees the key (by design). Responses up to 16 MiB stream through; size and rate limits per connection are planned |