HTTP Requests
Place a credential where the vendor expects it, send a POST body, and pin the response format — the request half of a declarative poll source
The request block of a declarative poll source describes the call the platform makes on every tick. url, method, headers and query are the plain parts. This page covers the three that decide whether a real vendor API works at all:
request.auth— where the credential named byauthRefis placed: a bearer token, a custom header, or a query parameter.request.body— the payload for aPOST, including GraphQL.response.format— an explicit decode hint (json,xml,csv,jsonl) when a vendor mislabels its own payload.
All three are optional. A manifest written before they existed parses and behaves exactly as it did: authRef alone means Authorization: Bearer <apiKey>, and the response format is detected from the payload's own signals.
Where the request goes — url, or the connection's endpoint
A request names its endpoint exactly once:
url— a fixedhttps://URL, the same for every organization that installs the extension.endpointRef+path— the base URL comes from the connection an administrator creates:endpointRefis the key of the extension's{ type: 'url', purpose: 'endpoint' }config field, andpathis appended to it.
request: {
endpointRef: 'baseUrl',
path: '/api/states',
authRef: 'token',
auth: { placement: 'bearer' },
}A path already on the connection's URL is kept (https://proxy.example.com/ha + /api/states). path must start with /, and carries no query string, fragment or .. — static parameters go in query as usual. Everything below applies to both forms. See Endpoints from a connection for declaring the field and what an administrator enters.
request.auth — where the credential goes
authRef names which stored credential to use. auth says where to put it. Setting auth without authRef is a validation error — there would be no credential to place.
placement | What the platform sends | Extra fields |
|---|---|---|
bearer | Authorization: Bearer <secret> | credential |
header | <name>: <prefix><secret> | name (required), prefix, credential |
query | ?<name>=<secret> | name (required), credential |
Which credential field
credential names a field on the connection the platform resolved for you — never a secret value. Every placement carries one opaque secret, so there are exactly two fields to choose from:
| Field | Typical source |
|---|---|
apiKey | A static API key connection |
accessToken | An OAuth connection's current access token |
These two are the vocabulary because they are what a data-source connection actually resolves to. An OAuth connection hands over the access token only — refresh material must never reach a source. Any other field name is a build-time error rather than a manifest that validates and then fails on every tick.
Bearer (the default)
Omit auth entirely and you get this. Write it out only to pick a different field:
request: {
url: 'https://api.example.com/v1/records',
authRef: 'example-api-key',
auth: { placement: 'bearer', credential: 'accessToken' },
}A custom header
The common x-api-key shape. prefix is written verbatim in front of the secret — include the trailing space if the vendor expects one:
request: {
url: 'https://api.example.com/v1/records',
authRef: 'example-api-key',
auth: { placement: 'header', name: 'x-api-key' },
}name may not be Authorization, in any casing. placement: 'bearer' is the one way to set that header, so there is exactly one code path that can produce it. An inline Authorization entry in request.headers is rejected for the same reason.
A query parameter
Some older APIs want the key on the URL:
request: {
url: 'https://api.example.com/v1/records',
query: { limit: '500' },
authRef: 'example-api-key',
auth: { placement: 'query', name: 'api_key' },
}name must be URL-unreserved (letters, digits, ., _, ~, -) and may not collide with a key already in request.query or a parameter already written into url (?api_key=...) — the credential would silently overwrite your static parameter, which is the kind of bug you find in production. Rename one of them.
Prefer bearer or header when the vendor supports either. A credential in a query string travels through proxy logs, browser referrers and CDN access logs in a way a header does not. Scrydon redacts it everywhere it writes the URL (see Credentials never reach a log) — it cannot redact the vendor's own logs.
There is no basic placement. HTTP Basic needs a username/password pair, and no data-source connection kind resolves one today — a basic placement would validate at build time and then fail on every tick. It returns alongside the basicAuth-connection resolution that populates the pair. For an API that only speaks Basic, use header with a prefix once such a connection exists.
request.body — POST payloads
A body is only valid when method is 'POST'. A GET with a body is rejected at build time rather than sent and silently ignored.
JSON
request: {
url: 'https://api.example.com/v1/search',
method: 'POST',
body: { kind: 'json', value: { since: '2026-01-01', pageSize: 500 } },
}value is any JSON value — object, array, string, number, boolean or null. It is serialized with JSON.stringify and sent with Content-Type: application/json.
GraphQL
GraphQL is not a separate kind. It is a JSON body whose value carries query and variables:
ingest: {
mode: 'poll',
intervalSec: 300,
request: {
url: 'https://api.example.com/graphql',
method: 'POST',
authRef: 'example-api-key',
body: {
kind: 'json',
value: {
query: 'query Vessels($limit: Int!) { vessels(limit: $limit) { mmsi lat lon } }',
variables: { limit: 500 },
},
},
},
response: { itemsPath: '$.data.vessels' },
mapping: {
mmsi: { path: 'mmsi' },
latitude: { path: 'lat' },
longitude: { path: 'lon' },
},
}Note that itemsPath reaches through GraphQL's data envelope. A GraphQL error response has no data.vessels array, so the tick fails loudly rather than writing zero rows.
Text
For anything that is not JSON. contentType is required — nothing can infer it:
request: {
url: 'https://api.example.com/v1/query',
method: 'POST',
body: {
kind: 'text',
value: 'from(bucket:"telemetry") |> range(start:-5m)',
contentType: 'application/vnd.flux',
},
}A Content-Type you set yourself in request.headers always wins. The body kind only supplies one when you did not — application/json for kind: 'json', and the declared contentType for kind: 'text'.
response.format — pinning the decode
By default the platform detects the body format from the response Content-Type, then the URL extension, then the first non-whitespace byte. That is format: 'auto', and it is what you get when you say nothing.
Set format when a vendor is wrong about its own payload — a CSV export served as application/octet-stream, a JSONL feed labelled text/plain, or an RSS feed served as application/json:
response: { itemsPath: '$', format: 'csv' }| Value | Meaning |
|---|---|
auto | Detect from Content-Type → URL extension → first byte (the default) |
json | A JSON document; itemsPath selects the row array inside it |
xml | An XML document — RSS, Atom, or any other dialect; itemsPath selects the entries inside it (rss.channel.item, feed.entry) |
csv | Header row plus data rows; itemsPath is '$' |
jsonl | One JSON object per line; itemsPath is '$' |
There is deliberately no rss or atom value: all three parse identically, and the only difference between them is the entry path your itemsPath already names. See Response formats for the full feed walkthrough.
An explicit format is a decode instruction, not a hint: if the body cannot be read that way the tick fails with a parse error, rather than guessing a second time.
Credentials never reach a log
A credential resolved for a tick exists only for the duration of that request.
- The manifest carries field names, never values.
.strict()on therequestblock rejects any stray field, and an inlineAuthorizationheader is rejected outright. - With
placement: 'query'the secret is necessarily in the URL. Every URL the platform writes to a log line or an error message has that parameter replaced with***; the static parameters around it survive, so the log still tells you which call failed. - An error raised because a connection is missing the field you named says which field was absent (
credentials.accessToken), never what any other field contained.
This protects Scrydon's own logs and error surfaces. It cannot protect the vendor's — one more reason to prefer bearer or header over query.
Validation summary
These are build-time errors, caught by extension build before an archive is ever uploaded:
| Rejected | Why |
|---|---|
Both url and endpointRef/path, or neither | A request names its endpoint exactly once |
endpointRef without path, or path without endpointRef | Each needs the other |
A path not starting with /, starting with //, or carrying ?, #, .., %2e, %2f, %5c, a backslash or a space | The path may not lead the request off the connection's URL |
endpointRef naming no { type: 'url', purpose: 'endpoint' } config field of the extension | There is no endpoint for the connection to supply |
authRef naming no credential in the extension's auth.credentials | There is no credential for the connection to hold |
auth without authRef | No credential is named, so there is nothing to place |
auth.name = Authorization (any casing) | placement: 'bearer' is the one way to set that header |
auth.name colliding with a query key or a parameter in url | The credential would silently overwrite a static parameter |
credential naming anything but apiKey or accessToken | A placement carries one opaque secret, and those are the only two a connection resolves |
placement: 'basic' | No connection kind resolves a username/password pair yet |
body without method: 'POST' | A GET body is never sent |
kind: 'text' without contentType | Nothing can infer it |
Any unlisted field in request, auth or body | The blocks are .strict() — a typo is an error, not a silent no-op |