Skip to content

Integrations

The Integrations module lets a signed-in user connect their own third-party account (Google Drive, GitHub, HubSpot and so on), or lets the app use one API key. Kirak holds the tokens and keys, encrypted, and refreshes them. Your app writes the API calls.

This page covers the setup: installing the module, enabling it, declaring instances, and what kirak validate checks. The connect flow, the authenticated client and the webhook routes are added in later steps of the module’s build, and this page gains a section for each one when it ships.

Install:

Terminal window
pip install "kirak[integrations]"

The extra has no dependencies of its own: httpx, social-auth-core and cryptography are already part of Kirak.

Enable: add it to modules in kirak.json, and set the keyring:

"modules": ["integrations"]
Terminal window
export KIRAK_AUTH_ENCRYPTION_KEYS="$(python -c 'import os,base64;print(base64.b64encode(os.urandom(32)).decode())')"

The module refuses to start without KIRAK_AUTH_ENCRYPTION_KEYS. There is no fallback to the JWT secret, so stored tokens do not depend on it.


Each named entry under integrations.providers is one integration. The name is what routes and code use, for example github. Its type is oauth2 (a user connects an account) or api_key (one key, from the environment or saved by each user).

{
"modules": ["integrations"],
"integrations": {
"providers": {
"github": {
"type": "oauth2",
"base_url": "https://api.github.com",
"client_id": "Iv1.abc123"
},
"weather": {
"type": "api_key",
"base_url": "https://api.openweathermap.org/data/3.0",
"key_owner": "app"
}
}
}
}

Secrets are never in kirak.json. They come from the environment as KIRAK_INTEGRATION_<INSTANCE>_<FIELD>:

Instance Variable Used for
an oauth2 instance KIRAK_INTEGRATION_GITHUB_CLIENT_SECRET the OAuth client secret (required)
an oauth2 instance whose profile signs webhooks with webhook_secret KIRAK_INTEGRATION_GITHUB_WEBHOOK_SECRET webhook signatures (optional; without it every webhook is refused)
an api_key instance with key_owner: app KIRAK_INTEGRATION_WEATHER_API_KEY the app’s key (required)

An api_key instance with key_owner: user reads no variable: each user saves their own key. kirak env and kirak integrations list show exactly these variables for each instance, read from the environment and the project’s .env.

kirak integrations check <name> proves an oauth2 instance’s client id and secret with one request to the token endpoint, as the profile’s auth.check says: code (the default) sends a code that cannot be valid with the instance’s real callback URL, which needs base_url; a provider that accepts the client and refuses the code passes. client_credentials sends a client_credentials grant (Microsoft, which refuses a bad code before it checks the client). none sends nothing (Slack, for the same reason): the result is unverified, and connecting an account is the test.

The settings for each type are listed in the generated tables below.


The user connects an oauth2 instance through a short redirect flow. Kirak holds the tokens; the app only sees the outcome.

App Kirak Provider
| POST /integrations/github/connect | |
| {"return_to": "..."} (user's token) | |
|<-- connect_url (ticket, 60 s) ---------| |
| browser opens connect_url | |
| GET .../connect?ticket= ------------->| 302 to authorize URL |
| |----------------------->| user signs in, consents
| |<--- GET .../callback?code&state
| | token request, account, save
|<-- 302 return_to?integration=github&status=connected ----------|
  1. Start. The signed-in app calls POST /integrations/{name}/connect with return_to (one of auth.redirect_urls) and the profile’s connect values, such as a Shopify shop. The answer is connect_url, a link valid for 60 seconds and usable once.
  2. Open. The app opens connect_url in the browser (or, in a mobile app, in the system browser session). Kirak sets a state cookie in that browser and redirects to the provider. The state is signed with the JWT secret, and PKCE is used when the profile asks for it.
  3. Callback. The provider redirects to {base_url}/integrations/{name}/callback. Kirak checks the state’s signature and expiry, that it was not used before, that it names this integration, and that the browser’s cookie matches it. Then it exchanges the code, reads the account, saves the connection encrypted, and redirects to return_to with integration and status=connected. A refusal at the provider, or any failure after the state is checked, redirects to return_to with error=<code>. The server log says why, in one WARNING line: the step that failed (token exchange, expiry lookup, connection values, account lookup, save), the provider’s HTTP status, its error name and short description – for example integration github connect failed at the token exchange step: ...; HTTP 401; provider error invalid_client (Client authentication failed). At DEBUG, each request of the connect flow has a line with its method, host, path and status. Neither ever carries the code or a token.

The callback must be registered with the provider as {base_url}/integrations/{name}/callback, where base_url is the one in kirak.json. Kirak refuses to start a connect without base_url.

A connect that cannot be checked (a bad state, a replayed link, a missing cookie) is not redirected: the route answers the problem’s status and code, because there is no trusted return_to to send the user to.

Routes (all under /integrations; bearer means the signed-in user’s token):

Method and path Does Auth
GET /integrations The configured integrations and whether the user has a connection to each bearer
GET /integrations/connections The user’s connections: integration, account_label, status, scopes, expires_at and created_at (ISO text, or null). Never a token bearer
POST /integrations/{name}/connect Starts a connect; answers connect_url bearer
GET /integrations/{name}/connect?ticket= Opened by the browser; redirects to the provider ticket
GET /integrations/{name}/callback The provider’s redirect; redirects to return_to state and cookie
DELETE /integrations/{name}/connections/{id} Revokes at the provider when the profile has a revoke_url (best effort), then deletes the row bearer

Account and connection values. The profile’s account section says where the account id and label come from: the ID token, the token response, the connection values, or a request to the API with the new token. The profile’s connection section saves values with the connection, such as Slack’s team id, and a profile pattern checks each one. A value the provider does not give fails the connect.

Scopes. The connection stores the scopes the provider granted, which may differ from those asked for.

Limits. limits.max_connections in the profile caps the connections to a service across all users; a new connection past the cap is refused with INTEGRATION_CONNECTION_LIMIT. A reconnect of the user’s existing connection is never refused.

Reconnect. With multiple_accounts off (the default), a second connect by the same user replaces the row’s credentials and account, and the audit event is integrations.reconnected. With multiple_accounts on, each account is its own row.

Deleting a user. Closing an account revokes and deletes the user’s connections, with their webhook events, at the close. An app that deletes users some other way calls disconnect_all(user_id) first (the table cannot cascade on the users table). It revokes each connection at the provider where the profile allows, and deletes the rows.

Errors. Each one is an error envelope with its code (see API error codes):

Code Status When
INTEGRATION_UNKNOWN 404 No instance with that name in kirak.json
INTEGRATION_NOT_OAUTH 400 The instance is an api_key one
INTEGRATION_CONNECT_INVALID 400 A connect value is missing, not declared, or fails its pattern
INVALID_REDIRECT 400 return_to is not in auth.redirect_urls
INTEGRATION_STATE_INVALID 400 The ticket or state is expired, forged, reused, or the cookie does not match
INTEGRATION_CANCELLED 400 The user refused at the provider (redirect error)
INTEGRATION_PROVIDER_ERROR 400 The provider refused the code, or its answer lacked a value Kirak needs
INTEGRATION_PROVIDER_UNAVAILABLE 502 The provider did not answer in time or failed; the outcome is unknown
INTEGRATION_CONNECTION_LIMIT 409 The app’s cap on connections is reached
INTEGRATION_CONNECTION_NOT_FOUND 404 No connection with that id belongs to the user
INTEGRATION_RATE_LIMITED 429 A rate limit: Kirak’s (see Rate limiting) or the provider’s (details.retry_at)
INTEGRATION_KEY_INVALID 422 The provider does not accept the key, or the key or label is not in the form Kirak takes
INTEGRATION_NOT_USER_KEY 400 The instance takes the app’s own key; users do not save keys for it
INTEGRATION_PATH_INVALID 400 A call’s path is not a path on the instance’s API
INTEGRATION_RESPONSE_TOO_LARGE 413 A call’s answer is over 64 KB (proxy routes and agent tools)
INTEGRATION_NEEDS_REAUTH 409 The connection must be made again
INTEGRATION_NOT_CONNECTED 409 The user has no connection to this integration
INTEGRATION_PROXY_NOT_ALLOWED 403 A proxy route was called for an instance with key_owner: app; call it with client() in a route of your own
INTEGRATION_HOST_NOT_ALLOWED 500 A call would go to a host outside base_url and allowed_hosts; nothing was sent

Mobile apps. A mobile app starts the connect with its own token, opens connect_url in the system browser, and receives the result on its return_to URL, which must be in auth.redirect_urls. The provider’s callback is always the web URL {base_url}/integrations/{name}/callback.

Audit. With monitoring on, integrations.connected, integrations.reconnected and integrations.disconnected are written to audit_events, with the integration and connection id in metadata. Tokens are never in an event.

The app calls the provider’s API with the user’s credentials, and Kirak adds them, refreshes them when they must be, and refuses any call to a host that is not the instance’s.

# The current request's user, one connection
async with kirak.integrations.client("github") as gh:
r = await gh.get("/user/repos", params={"per_page": 100})
r.raise_for_status()
# A scheduled job or hook in system context names the user
async with kirak.integrations.client("github", user_id=owner_id) as gh:
await gh.post("/repos/o/r/issues", json={"title": "Hello"})
# Several connections of one user: name the one
async with kirak.integrations.client("github", connection_id=conn_id) as gh:
...
# One call, no client to manage
response = await kirak.integrations.get("github", "/user", user_id=uid)
# A FastAPI route gets the signed-in user's client, closed after the response
@app.get("/my/repos")
async def my_repos(gh=Depends(kirak.integrations.dependency("github"))):
return (await gh.get("/user/repos")).json()

client() returns an httpx.AsyncClient, so everything httpx does (params, JSON, streaming, timeouts) works unchanged. The client takes a path relative to base_url; an absolute URL to another host is refused (INTEGRATION_HOST_NOT_ALLOWED) before anything is sent.

Which connection. connection_id wins. Otherwise the request’s signed-in caller (in your own routes too, with no code to set it), or user_id in system code. A user with no connection gets INTEGRATION_NOT_CONNECTED (409); with several, INTEGRATION_CONNECTION_REQUIRED (pass connection_id). With no user at all, INTEGRATION_USER_REQUIRED. Kirak never guesses whose account a call is for. A connection marked needs_reauth raises INTEGRATION_NEEDS_REAUTH (409), which a route can pass to the app so it can offer “Reconnect”.

Credentials. Each request carries the connection’s bearer token. An API key goes where the instance’s auth says: a header (with an optional prefix) or a query parameter. The key is never in a response, a log line, or an audit record.

Refresh. When the access token expires within 60 seconds, the call refreshes it first, using the profile’s refresh endpoint. After a 401 the call refreshes once and retries once, unless the body was a stream (those are sent once), the answer means a missing scope (WWW-Authenticate: error="insufficient_scope", or a name in the profile’s api.errors.scope_missing), or it is a plain 401 for a token issued or refreshed less than a minute ago. In those cases the answer is returned as it is and the connection is unchanged, so a page that keeps calling an endpoint outside the grant does not refresh the token on every call. An answer the profile names in api.errors.token_invalid (Slack’s token_revoked in a 200) counts as a 401; see How a provider reports errors. Only one worker refreshes a connection at a time: concurrent calls in one process share one refresh, and workers coordinate through a lease in the connection’s row. A provider that rotates refresh tokens sees each one once. A provider that omits the refresh token keeps the stored one.

Provider answer to a refresh Result
An error in the body (invalid_grant, GitHub’s bad_refresh_token, any other name), at any HTTP status, even 200; or a 401 The connection becomes needs_reauth (last_error set to the error); integrations.needs_reauth is audited; the after_reauth_needed hook runs; the call raises INTEGRATION_NEEDS_REAUTH
429, a 5xx, a timeout, or the error temporarily_unavailable, server_error or slow_down The connection stays active and the call raises INTEGRATION_PROVIDER_UNAVAILABLE (502); the next call tries again
A success with neither an access token nor an error The connection stays active and the call raises INTEGRATION_PROVIDER_ERROR (400)
Tokens received, but the database write fails three times The connection becomes needs_reauth, because a rotating provider has already spent the old refresh token

A token’s expiry comes from the token response’s expires_in; a value of 0 or less (Mailchimp sends 0) means the token does not expire. A connection whose token has expired with no refresh token (GitHub’s OAuth apps, for one) needs reauthorization once it expires. A plain 401 on such a connection is checked first: Kirak runs the profile’s verify request, and when that still accepts the token the 401 is returned as it is and the connection stays (the endpoint was outside the grant, or the provider had a glitch); when verify is refused too, or the profile has none, the connection needs reauthorization. A row sealed with an older key is re-sealed with the current key on its next use.

Where the token goes. An OAuth access token is sent as Authorization: Bearer <token>, unless the profile’s auth.key names another place, as for an API key: Shopify’s Admin API takes it in X-Shopify-Access-Token ("key": {"in": "header", "name": "X-Shopify-Access-Token"}). The client, the proxy, the connection check and the account and expiry lookups all use it.

Shared pool. Each instance has one connection pool, shared by its calls. Closing a call’s client ends that call and leaves the pool open.

Last use. last_used_at is written at most once a minute per connection.

Audit of calls. An instance with "audit_requests": true records each call to integration_calls when monitoring is on: method, host, path without the query, status, duration, size, connection id. The setting audit_sample_rate (0 to 1, default 1) draws a share of calls. See Monitoring.

Python API, hooks and the proxy routes. The Python functions do not fire hooks; the operations do. The proxy routes {GET|POST|PUT|PATCH|DELETE} /integrations/{name}/proxy?path=<target> run the same call through the operations (integrations.get and the others) for the app’s UI: path is the provider’s path with its own query string, the body is JSON, and an answer over 64 KB is refused (INTEGRATION_RESPONSE_TOO_LARGE). POST /integrations/{name}/connections/{id}/check runs the profile’s verify request and reports the scopes the provider did not grant (INTEGRATION_SCOPE_MISSING).

An api_key instance with "key_owner": "app" takes the app’s own key from KIRAK_INTEGRATION_<INSTANCE>_API_KEY. The key goes where the instance’s auth says (a header, with an optional prefix, or a query parameter). The app’s code calls it with client(); the proxy routes refuse it (see Proxy routes).

An instance with "key_owner": "user" takes each user’s own key. The user saves it with:

PUT /integrations/weather/key
Authorization: Bearer <the user's token>
{"api_key": "sk-...", "label": "Work account"}

Kirak checks the key first, when the profile (or the instance’s verify) names a verify request: one call with the key, which must not get a 401 or 403 (INTEGRATION_KEY_INVALID, 422). A provider that cannot be reached saves nothing (INTEGRATION_PROVIDER_UNAVAILABLE). The key is then sealed like a token, the same way and with the same binding to the row. A label is optional; without one, the row shows the key’s last four characters.

The user has one key per instance (multiple_accounts off). Saving again replaces it and audits integrations.key_saved with outcome replaced. Disconnecting removes it and audits integrations.key_removed. With multiple_accounts on, each key is its own row, keyed by a digest of the key.

No response, listing, log line, or monitoring record carries the key. Its query-parameter form is not logged either: the call’s log and integration_calls keep only the path.

A user’s key is used by the same calls as a connection, through client(), get() and the other shortcuts. save_key is an operation: tool_safe is off, since a model should not carry a key.

The provider’s own limits are read from its answers. The profile’s rate_limit section names the headers (remaining, limit, reset or reset_epoch, retry_after) and the statuses that mean “limited” (status, default 429), and says whether the limit is per connection or per integration.

  • Holding. A call made when the remaining count is 0 and the reset is near waits for the reset, up to 10 seconds. A reset further away is refused with INTEGRATION_RATE_LIMITED (429), without sending, and details.retry_at says when to try again.
  • Retrying. A call that the provider answers with a limited status is retried after Retry-After (at most 60 seconds), up to three times. Only idempotent methods (GET, HEAD, PUT, DELETE, OPTIONS) are retried.
  • Low quota. When the remaining count falls under 10% of the limit, one warning is logged per window.
  • Seeing it. client.quota (on the client client() returns) gives remaining, limit and reset_at as the last answer said them, or None until an answer has. These are kept in process memory, per process.

This mirrors what the provider reports. It is not Kirak’s own limiter on outgoing calls, and the counts are not shared between processes.

Paging. paginate() yields every item of a paged list, following the profile’s pagination section:

async for repo in kirak.integrations.paginate("github", "/user/repos", user_id=uid, params={"per_page": 100}):
...
# One profile, several APIs: the call names where the list is (Drive: files, Calendar: items, Gmail: messages)
async for f in kirak.integrations.paginate("google", "/drive/v3/files", items_path="files"):
...

items_path and next_path override the profile’s items_path and next for one call. A page whose list is not where items_path points raises INTEGRATION_PROVIDER_ERROR, and the message names the lists the page has.

pagination.style How the next page is found
link_header The rel="next" URL of the Link header
cursor The next field of the body, sent as the param query parameter
next_url The next field of the body, a full URL (on an allowed host only)
offset offset_param goes up by the page size; stops at the total field, or at a short or empty page
page param goes up by one; stops at an empty page

items_path names the list in the body (dotted); without it the body is the list. A profile without a pagination section is refused with a configuration error.

For the app’s UI there are five proxy routes, one per method. Each carries the provider’s path in the path query parameter, with its own query string, and the body as JSON for POST, PUT and PATCH:

GET /integrations/github/proxy?path=/repos/o/r/issues%3Fstate%3Dopen
POST /integrations/github/proxy?path=/repos/o/r/issues

The answer is {status, body, provider_error, elapsed_ms}: provider_error is the provider’s error name when it reports a failure (read as the profile’s api.errors says, so also Slack’s {"ok": false} in a 200), else null. A path must start with / and must not name another host or contain .. (INTEGRATION_PATH_INVALID). An answer over 64 KB is refused (INTEGRATION_RESPONSE_TOO_LARGE).

The proxy routes serve the caller’s own connection or saved key only. An instance with "key_owner": "app" is refused with 403 INTEGRATION_PROXY_NOT_ALLOWED: the caller picks the path, method and body, and with the app’s key that would give every signed-in user the app owner’s whole account. Call such an instance from a route of your own, which decides who may call it and what it sends:

@app.get("/forecast")
async def forecast(city: str):
async with kirak.integrations.client("weather") as api:
return (await api.get("/forecast", params={"q": city})).json()

Agent and MCP tools still reach app-key instances: their fixed.integration and fixed.path and their own access level limit what a caller can do.

POST /integrations/{name}/connections/{id}/check runs the profile’s verify request for the connection and answers verified and the HTTP status, with missing_scopes: the scopes the instance asks for that the provider did not grant. A missing scope is reported as INTEGRATION_SCOPE_MISSING; the connection stays usable. The profile’s offline scope (scopes.offline, such as offline_access) is never reported: services do not echo it, and the refresh token proves it. A profile’s scopes.satisfied_by counts a scope as granted when the service grants it under another name or a wider scope implies it (Google grants email as https://www.googleapis.com/auth/userinfo.email; Shopify’s write_products implies read_products). verified is false when the provider reports a failure, also in a 200 when the profile has api.errors.success_path, and provider_error then names it.

Some providers’ HTTP statuses do not say what happened: Slack answers every call with 200 and puts {"ok": false, "error": "token_revoked"} in the body; Atlassian answers a missing scope with a plain 401. A profile describes this in api.errors, with four optional keys:

Key Meaning Default
code_path Where the provider’s error name is in the body (a dotted path) error
success_path For a provider that answers 200 for errors: a body field that is true on success none: the status decides
token_invalid Error names meaning the token is no longer good: the call refreshes once, then the connection needs reauthorization []
scope_missing Error names meaning the grant lacks a scope: no refresh; the check reports INTEGRATION_SCOPE_MISSING []
"api": {
"base_url": "https://slack.com/api",
"errors": {
"code_path": "error",
"success_path": "ok",
"token_invalid": ["invalid_auth", "token_revoked", "token_expired", "account_inactive"],
"scope_missing": ["missing_scope"]
}
}

Without the section, an RFC 6750 WWW-Authenticate challenge still counts (error="invalid_token" or error="insufficient_scope"), and a plain 401 means an invalid token. The bundled Slack, Jira and Confluence profiles have the section. client() always returns the provider’s response as it is; the section only decides the refresh, the reconnect, the connection check, the key check and provider_error.

A provider posts its events to POST /integrations/{name}/webhooks. The route takes no user token: the provider’s signature authenticates the delivery. The profile’s webhooks section says how:

"webhooks": {
"secret": "webhook_secret",
"signature": {"header": "X-Hub-Signature-256", "algorithm": "hmac-sha256", "encoding": "hex", "prefix": "sha256=", "signed": "{body}"},
"event": {"header": "X-GitHub-Event", "action_path": "action"},
"delivery_id": {"header": "X-GitHub-Delivery"},
"account_path": "sender.id"
}

The signature is checked in constant time against KIRAK_INTEGRATION_<NAME>_WEBHOOK_SECRET (or the client secret, when the profile says "secret": "client_secret"). Optional fields cover the rest: signature.timestamp (a timestamp header with a tolerance in seconds, or milliseconds), signature.encoding: "base64", and handshake (a request the provider makes to check the URL: a query parameter echoed back, or a body field such as Slack’s challenge, answered before the signature is checked). GitHub’s profile is the bundled example.

What happens to a delivery.

  1. The body is read (at most 1 MB) and the handshake, if the profile has one, is answered.
  2. The signature is checked. A forged or unsigned delivery answers 401 (WEBHOOK_SIGNATURE_INVALID), is logged, and audits integrations.webhook_rejected. Nothing is stored.
  3. The event’s name is the event header, with the body’s action after a dot when the profile names one (issues.opened). The delivery is recorded once per integration and delivery id; a repeat answers duplicate: true and runs nothing again.
  4. The event is matched to the connection whose account id equals the body’s account_path value. No match still records the event, with no connection.
  5. The provider gets its answer at once. The registered handlers then run in the background.

Handlers. Register one per event, or "*" for every event of the integration:

@kirak.integrations.on_webhook("github", "issues.opened")
async def on_issue(event, connection):
if connection is None:
return
async with kirak.integrations.client("github", connection_id=connection["id"]) as gh:
await gh.post(event["payload"]["issue"]["comments_url"], json={"body": "Thanks, we're on it."})

A handler gets the event (event and its payload, the body) and the connection (its id, label and scopes; never a token). A handler that raises marks the event failed with the exception’s type in last_error; the message is not stored. The outcome is in integration_webhook_events (status, attempts, last_error).

Background, not the scheduler. The handlers run in an asyncio task in the same process, after the answer, so a slow handler never makes the provider time out and retry. They are not sent to the scheduler yet, so a failed handler is not retried automatically: its event row shows failed, and the app can run the handler again. Call await kirak.integrations.drain_webhooks() on shutdown to let running handlers finish.

Retention. Event rows, and their payloads, are kept for 7 days by prune_webhook_events(days=7). That method deletes the older rows; call it from a scheduled job in the app. Nothing calls it on its own yet.

Limits. Each integration accepts 300 deliveries a minute from one IP (see Rate limiting).

  • The type is oauth2 or api_key. An unknown type is a warning, because a type can be registered in code.
  • base_url is present, unless the instance names a profile (every profile has one). client_id (for oauth2) and key_owner (for api_key) are present, and key_owner is app or user.
  • A profile is bundled or in integrations/, and the instance’s settings match the profile.
  • A project profile file is valid, and its name matches its file name (see Profiles).
  • Every URL is https://.
  • An api_key instance sets no scope.
  • No secret is written in kirak.json.
  • KIRAK_AUTH_ENCRYPTION_KEYS is set when instances are configured.

Outbound calls made by the module go through kirak.core.http, which refuses any host outside the instance’s base_url and allowed_hosts, so a token is never sent to another host.


A profile is data about one service: where it authorizes, where its API lives, how to learn who connected, and the service’s quirks. An oauth2 instance with "profile": "github" gets the profile’s URLs, default scopes, PKCE setting and account lookup. kirak.json still holds this app’s client_id, its scopes and its settings. A profile has no Python code.

The bundled profiles are github, google, microsoft, slack, notion, hubspot, salesforce, shopify, jira, confluence, xero, quickbooks, dropbox, airtable and mailchimp. Their facts were taken from the vendors’ documentation pages in a search and are not all confirmed yet: each profile’s notes says what is open, and a live check against a real account is still to be done before a release. kirak catalog --json lists them under integration_profiles, with their scopes, settings and notes.

Some profiles need a value that depends on the app. Microsoft’s tenant is one; it defaults to common. Set it under the instance’s settings:

"outlook": {
"type": "oauth2",
"profile": "microsoft",
"client_id": "00000000-0000-0000-0000-000000000000",
"settings": {"tenant": "contoso.onmicrosoft.com"}
}

Merging. The profile’s values come first and kirak.json’s values win over them:

  • scope in kirak.json replaces the profile’s default scopes. The profile’s offline scope (for example offline_access) is still added, so refresh keeps working.
  • allowed_hosts in kirak.json adds to the profile’s list and never removes from it.
  • pkce in kirak.json wins over the profile. With neither set, PKCE is on.

Default headers. The profile’s api.default_headers (an API version, such as Notion’s Notion-Version or GitHub’s X-GitHub-Api-Version) go on every request to the provider’s API: client(), the proxy routes, the connection check, and the account and expiry lookups at connect and refresh. A header the request sets itself wins; Authorization is always the connection’s own.

Placeholders. A URL in a profile may hold {settings.<key>}, {connect.<key>} or {connection.<key>}. Each key must be declared by the profile, and each must have a pattern that the value has to match in full. A value that fails its pattern is refused, so a token is never sent to another host. kirak validate checks the declarations and the instance’s settings. The values are filled in when the connect flow and the client are built (Phases 4 and 5).

Settings are checked. A setting that a profile URL needs and that has no default is an error (integration_setting_missing). A value that fails its pattern is an error (integration_setting_invalid). A setting the profile does not declare is a warning and is ignored (integration_setting_unknown).

For a service that is not bundled, add integrations/<name>.json next to kirak.json. The file name must be the profile’s name, and the name must not be one of the bundled names. The schema is in kirak/schemas/integration_profile.schema.json; this is a complete minimal profile:

{
"profile_version": 1,
"name": "acme",
"title": "Acme",
"docs_url": "https://docs.acme.example/oauth",
"auth": {
"type": "oauth2",
"authorize_url": "https://auth.acme.example/authorize",
"token_url": "https://auth.acme.example/token",
"pkce": true
},
"scopes": {"default": ["read"], "offline": "offline_access"},
"api": {"base_url": "https://api.acme.example"}
}

Then point an instance at it, with "profile": "acme" and a client_id. Run kirak validate: a broken file names the field that is wrong, for example api: 'base_url' is a required property.

docs_url should be the vendor page the facts were checked against. A profile that is wrong about a URL or a scope fails at connect time for every app that uses it, so check each fact against the vendor’s documentation before you rely on it.

integrations key Type Description
providers object Integration instances (types oauth2, api_key), by name.

<INSTANCE> is the provider instance’s name in kirak.json, upper-cased: an instance named main reads ..._MAIN_....

oauth2 (no extra install) – An OAuth 2 provider. Each user connects their own account.

Credential check (check()): the token endpoint with the client credentials, once a profile or token_url is known.

Name Where Required / default Description
base_url kirak.json API base URL, https. Required unless the profile gives one. Relative paths in client() resolve against it.
settings kirak.json Values the profile asks for, such as a tenant (Microsoft) or a shop (Shopify).
client_id kirak.json required The app’s OAuth client id.
profile kirak.json A profile name (bundled or in integrations/) that fills the URLs, default scopes, PKCE and the account lookup.
authorize_url kirak.json Authorize endpoint, https. Needed without a profile.
token_url kirak.json Token endpoint, https. Needed without a profile.
refresh_url kirak.json Refresh endpoint, https. Defaults to token_url.
revoke_url kirak.json Revoke endpoint, https. Used on disconnect when it is set.
scope kirak.json Scopes to ask for. Replaces the profile’s default scopes. A list, or a space or comma separated string.
authorize_params kirak.json Extra query parameters on the authorize URL.
pkce kirak.json S256 PKCE. Default from the profile, else true.
token_auth kirak.json How the client id and secret go to the token endpoint: basic (default) or body.
allowed_hosts kirak.json Extra hosts the client may call. The base_url host is always allowed.
multiple_accounts kirak.json Allow more than one connection per user. Default false.
timeout kirak.json Request timeout in seconds. Default 30.
audit_requests kirak.json Record each outgoing call (method, host, path, status, duration, size) in integration_calls, when monitoring is on. Default false.
audit_sample_rate kirak.json The share of calls recorded when audit_requests is on, from 0 to 1. Default 1.
KIRAK_INTEGRATION_<INSTANCE>_CLIENT_SECRET environment required The OAuth client secret.
KIRAK_INTEGRATION_<INSTANCE>_WEBHOOK_SECRET environment Checks webhook signatures, when the profile signs them with it.

api_key (no extra install) – An API key. The app’s own key from the environment, or one key each user saves.

Credential check (check()): the profile’s verify request with the key, once a profile or verify is set.

Name Where Required / default Description
base_url kirak.json API base URL, https. Required unless the profile gives one. Relative paths in client() resolve against it.
settings kirak.json Values the profile asks for, such as a tenant or a shop.
key_owner kirak.json required app: one key from the environment. user: each user saves their own. Required.
auth kirak.json Where the key goes: in (header or query), name, and an optional prefix.
profile kirak.json A profile name (bundled or in integrations/) that fills the verify request and the auth placement.
verify kirak.json One request that proves the key works: method and path.
allowed_hosts kirak.json Extra hosts the client may call. The base_url host is always allowed.
multiple_accounts kirak.json Allow more than one saved key per user (key_owner user). Default false.
timeout kirak.json Request timeout in seconds. Default 30.
audit_requests kirak.json Record each outgoing call (method, host, path, status, duration, size) in integration_calls, when monitoring is on. Default false.
audit_sample_rate kirak.json The share of calls recorded when audit_requests is on, from 0 to 1. Default 1.
KIRAK_INTEGRATION_<INSTANCE>_API_KEY environment The app’s key, when key_owner is app. Users save their own keys in the app.