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:
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"]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.
Declaring instances
Section titled “Declaring instances”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.
Connecting an account
Section titled “Connecting an account”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 ----------|- Start. The signed-in app calls
POST /integrations/{name}/connectwithreturn_to(one ofauth.redirect_urls) and the profile’sconnectvalues, such as a Shopify shop. The answer isconnect_url, a link valid for 60 seconds and usable once. - Open. The app opens
connect_urlin 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. - 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 toreturn_towithintegrationandstatus=connected. A refusal at the provider, or any failure after the state is checked, redirects toreturn_towitherror=<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 exampleintegration 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.
Calling the provider
Section titled “Calling the provider”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 connectionasync 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 userasync 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 oneasync with kirak.integrations.client("github", connection_id=conn_id) as gh: ...
# One call, no client to manageresponse = 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).
API keys
Section titled “API keys”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/keyAuthorization: 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.
Rate limits and paging
Section titled “Rate limits and paging”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, anddetails.retry_atsays 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 clientclient()returns) givesremaining,limitandreset_atas 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.
Proxy routes and connection check
Section titled “Proxy routes and connection check”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%3DopenPOST /integrations/github/proxy?path=/repos/o/r/issuesThe 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.
How a provider reports errors
Section titled “How a provider reports errors”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.
Webhooks
Section titled “Webhooks”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.
- The body is read (at most 1 MB) and the handshake, if the profile has one, is answered.
- The signature is checked. A forged or unsigned delivery answers 401 (
WEBHOOK_SIGNATURE_INVALID), is logged, and auditsintegrations.webhook_rejected. Nothing is stored. - 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 answersduplicate: trueand runs nothing again. - The event is matched to the connection whose account id equals the body’s
account_pathvalue. No match still records the event, with no connection. - 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).
What kirak validate checks
Section titled “What kirak validate checks”- The type is
oauth2orapi_key. An unknown type is a warning, because a type can be registered in code. base_urlis present, unless the instance names a profile (every profile has one).client_id(foroauth2) andkey_owner(forapi_key) are present, andkey_ownerisapporuser.- A
profileis bundled or inintegrations/, and the instance’ssettingsmatch the profile. - A project profile file is valid, and its name matches its file name (see Profiles).
- Every URL is
https://. - An
api_keyinstance sets noscope. - No secret is written in
kirak.json. KIRAK_AUTH_ENCRYPTION_KEYSis 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.
Profiles
Section titled “Profiles”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:
scopein kirak.json replaces the profile’s default scopes. The profile’s offline scope (for exampleoffline_access) is still added, so refresh keeps working.allowed_hostsin kirak.json adds to the profile’s list and never removes from it.pkcein 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).
Writing a project profile
Section titled “Writing a project profile”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.
Settings and providers
Section titled “Settings and providers”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. |