KeyContain API reference

KeyContain sits between your agents and the services they use. You give KeyContain an upstream key with the permissions it needs, and KeyContain gives your agent a KeyContain key whose rules are as narrow as you want. Every request is checked against those rules. Allowed requests reach the service, everything else is refused before it leaves KeyContain.

Proxy and APIshttps://api.keycontain.comAgent instructionshttps://api.keycontain.com/agentsDashboard, for peoplehttps://app.keycontain.com

This reference is served at https://api.keycontain.com/docs.

1Overview

An agent calls an upstream service through KeyContain by putting https://api.keycontain.com/p/ in front of the service's own URL and sending its KeyContain key instead of the service's credential.

KeyContain keyA credential KeyContain issues, kc_live_…. An agent holds only this key. It has a name, and one or more attached services.
ServiceAn upstream connection in your account: a type, a base URL, and the upstream key KeyContain uses. One service can be attached to many KeyContain keys.
ACLA key's permissions, per attached service, as a list of rules. A request is refused unless one of the matched service's rules covers it.
RuleOne catalog permission, a method and path, or a git action and repository path, optionally narrowed by where conditions. Its access is allow, which lets requests through, or confirm, which lets them through once a person approves. Each rule is one row in the dashboard.
ScopeOn GitLab, GitHub, Cloudflare, Porkbun, Namecheap, Google Cloud, Firebase and Play, App Store Connect, Plaid, Google Drive and the KeyContain account, a rule for a permission about one project, repository, zone, domain, app, bank account, folder or key can be limited to some of them with projects, repositories, zones, domains, apps, accounts, folders or keys. Without it, the rule covers all of them.
PermissionOne thing that can be done to one object, from KeyContain's catalog, such as invoice.read (Invoices, Read) or merge_requests.merge. Rules name it in operation. KeyContain rewrites it into method or git rules on every request.
KeyContain accountThe built-in service svc_account. Attached to a key, it lets that key use the management API within its rules. Changes always wait for a person.
ConfirmationA request matched only by rules with access confirm waits for a person signed in to the dashboard to approve or decline it. No KeyContain key can decide.
PairingHow an agent gets its first key: it asks for a pairing, its person approves a link with a matching code, and the key goes to the agent once.
KeyContain master keyThe key pairing creates. It reads everything in the account, and every change it makes, such as a change set, waits for the person.
CapacityOne pool of requests a minute and traffic a month, shared by all keys in the account. It is free up to 10 requests a minute and 100 MB a month. With capacity units at $0.50 a month each, taken day by day from the account's balance, it is 50 requests a minute and 1 GB a month per unit. Keys are free, in any number, and a key can have limits of its own.
IP groupA named list of IP addresses and ranges, defined once for the account. A key's ACL can limit it to some groups and addresses.

Customers manage keys, services and rules in the dashboard at https://app.keycontain.com. This reference covers what agents and developers call directly: the instructions for agents, pairing, the proxy, git over HTTPS through the proxy, confirmations, the meta API for inspecting a key and its confirmations, and the management API for reading the account and managing keys, services, IP groups, ACLs and capacity, with a person's confirmation wherever a change could widen access.

Reading responses. KeyContain adds fields to its JSON responses over time without changing /v1. Ignore fields you do not recognise, and do not rely on the order of fields.

2Quickstart

An agent starts by pairing. The later examples use the Ops agent key, which has QuickBooks Online (company 9130354) and a self-managed GitLab at https://code.acme.dev attached. Its rules are shown in §9.

Pair your agent

curl -X POST https://api.keycontain.com/v1/pairings \
  -H "KeyContain-Agent: setup-claude"
# show the person the url and code from the response, then wait for their answer:
curl "https://api.keycontain.com/v1/pairings/par_4MX8QZ7cLw9Tn3Kd8Rb5Yf?wait=90"

When the person approves, the second call returns the agent's KeyContain master key, once (§4). The agent then usually sets up services, keys and rules with one change set (§14).

Query QuickBooks invoices

curl "https://api.keycontain.com/p/https://quickbooks.api.intuit.com/v3/company/9130354/query?query=select%20*%20from%20Invoice" \
  -H "Authorization: Bearer kc_live_7Hq2mV9xPz4Lw8NcT3bR6yKd1Fs5c21e" \
  -H "Accept: application/json"

KeyContain matches the URL to the QuickBooks service, checks the invoice.read permission (the query's entity is Invoice), swaps your key for a fresh QuickBooks access token and forwards the request. The response is QuickBooks' own. This service is connected through KeyContain's Intuit app, so the read costs $0.01, taken from the account's balance (§11).

Call the GitLab REST API on a self-managed instance

curl https://api.keycontain.com/p/https://code.acme.dev/api/v4/projects/42/issues \
  -H "Authorization: Bearer kc_live_7Hq2mV9xPz4Lw8NcT3bR6yKd1Fs5c21e"

Clone and push with git

git sends the password as basic auth, which KeyContain accepts. Any username works.

git clone https://api.keycontain.com/p/https://code.acme.dev/acme/api.git
# Username: agent
# Password: kc_live_7Hq2mV9xPz4Lw8NcT3bR6yKd1Fs5c21e
cd api
git switch -c agent/fix-retry
git commit -am "Retry failed webhook deliveries"
git push origin agent/fix-retry      # waits for a person: pushes to agent/ branches need confirmation
git push origin HEAD:main            # refused by KeyContain, see §15

Point an SDK at KeyContain

Set the SDK's base URL to KeyContain's URL for the service, and give it the KeyContain key as its token.

import { Gitlab } from "@gitbeaker/rest";

const gitlab = new Gitlab({
  host: "https://api.keycontain.com/p/https://code.acme.dev",
  oauthToken: process.env.KEYCONTAIN_KEY,   // sent as Authorization: Bearer
});

const issues = await gitlab.Issues.all({ projectId: 42 });

Make a change that needs a person's confirmation

The Bookkeeping agent key creates QuickBooks invoices for two regular customers directly, and for anyone else only after Dana confirms each one (§9). This invoice is for customer 58, so it waits for her. The agent says which agent is asking, what the call is for and why it is needed. KeyContain removes those three headers before forwarding.

curl -X POST "https://api.keycontain.com/p/https://quickbooks.api.intuit.com/v3/company/9130354/invoice" \
  -H "Authorization: Bearer kc_live_5Tb1Vq8Nz3Lw6Rc9Xd2Kp4Hm7Ys07f3a" \
  -H "Content-Type: application/json" \
  -H "KeyContain-Agent: invoice-runner-2" \
  -H "KeyContain-Purpose: Invoice Northwind Traders for October consulting" \
  -H "KeyContain-Reason: The engagement letter bills monthly, and 10 hours were logged in October" \
  -d '{ "CustomerRef": { "value": "58" }, "Line": [ { "Amount": 1250.00, "DetailType": "SalesItemLineDetail" } ] }'

KeyContain holds the request for up to 90 seconds while Dana is notified on her phone. If she approves in time, the response is QuickBooks' own. Otherwise the agent gets a confirmation ID to check and resends after approval (§10).

Ask what the key may do

curl https://api.keycontain.com/v1/key \
  -H "Authorization: Bearer kc_live_7Hq2mV9xPz4Lw8NcT3bR6yKd1Fs5c21e"

The response lists the key's services, its rules as written, and the effective rules after rewriting (§13).

3Instructions for agents

A person new to KeyContain gives their agent one sentence: Set up KeyContain for me: https://api.keycontain.com/agents. That URL returns short instructions written for agents, in Markdown. Every link an agent follows is on https://api.keycontain.com. The root domain is only the marketing site.

GET https://api.keycontain.com/agents

No key and no headers are needed. It returns 200 with Content-Type: text/markdown. This page, the full reference, is served at https://api.keycontain.com/docs.

# KeyContain for agents

KeyContain lets your person give you narrow access to services such as QuickBooks Online, GitHub and GitLab. You call those services through KeyContain with a KeyContain key, and KeyContain checks every request against the rules your person approved.

## 1. Pair with your person's account

1. Start a pairing, naming yourself in the KeyContain-Agent header:

       POST https://api.keycontain.com/v1/pairings
       KeyContain-Agent: setup-claude

2. Show your person the `url` and `code` from the response, for example:
   "To connect me to KeyContain, open https://app.keycontain.com/pair/KC7Q-2MVX, check that the page shows the code KC7Q-2MVX, then approve."
3. Wait for the answer: call GET on `statusUrl` with `?wait=90`, and repeat while `status` is `pending`. A pairing expires after 15 minutes.
4. When `status` is `approved`, the response contains your key, `kc_live_...`, exactly once. Store it securely. It is your KeyContain master key: you can read everything in the account, and every change you make waits for your person's approval.

## 2. Use your key

- Send it in the Authorization header: `Authorization: Bearer kc_live_...` (for git, as the password).
- Call a service by putting `https://api.keycontain.com/p/` in front of its full URL, for example `https://api.keycontain.com/p/https://gitlab.com/api/v4/projects`.
- See what your key may do: `GET https://api.keycontain.com/v1/key`.
- Set up services, keys and rules with one change set: `POST https://api.keycontain.com/v1/changes`. Your person approves it once and types any upstream secrets into KeyContain, or for QuickBooks Online connects the company with Intuit. Never ask your person to give you upstream secrets.

## 3. Requests that need your person's confirmation

- Send `KeyContain-Agent`, `KeyContain-Purpose` (what the request is for) and `KeyContain-Reason` (why it is necessary). Without them the request is refused with 400 DESCRIPTION_REQUIRED.
- KeyContain holds the request for up to 90 seconds. If your person approves in time, you get the service's normal response.
- Otherwise you get 403 CONFIRMATION_REQUIRED with `statusUrl` and `expiresAt`. Check `statusUrl` later, and when `status` is `approved`, send the identical request again before `expiresAt`. A confirmation waits 24 hours unless your person chose another time.
- 403 CONFIRMATION_DENIED means your person declined. Do not send the same request again without asking them.
- 503 TOO_MANY_WAITING: nobody was asked. If the message says your account's waiting requests hold 1 GB, ask your person to decide on the requests waiting in the KeyContain dashboard, then try again. Otherwise try again in a minute.

## QuickBooks Online

- Your person connects QuickBooks Online with Intuit in KeyContain. Never ask them for Intuit credentials, a client ID or secret, a refresh token or a company ID.
- To add a company, request a service with `services.create` in a change set, or with `POST https://api.keycontain.com/v1/services`. Send only `"type": "quickbooks"` and an optional `label`. Your person connects the company when they approve, and the new service's `baseUrl` ends with its company ID, for example `https://quickbooks.api.intuit.com/v3/company/9130354`.
- Reads can cost money. On a service connected through KeyContain's Intuit app, each successful read (a GET or HEAD, including queries and reports) costs $0.01, taken from your person's balance. Writes are free. Check `pricePerRead` on each service in `GET https://api.keycontain.com/v1/key`. A service without it has free reads.
- Attachments: `GET .../download/{id}` returns a temporary link to the file at Intuit. Fetch that link directly, without KeyContain and without your KeyContain key. KeyContain records only the request for the link, which counts as a read.
- To upload a file, send a multipart `POST .../upload`. If it waits for your person's confirmation, it can be at most 10 MiB, and your person sees the files before approving. Build the multipart body once and keep it: after approval, send the identical bytes again, with the same boundary.
- Send uploads as standard multipart/form-data without Content-Encoding: for each file, one `file_metadata_N` part with its JSON metadata and one `file_content_N` part with its bytes, and a boundary that appears only at the delimiters. KeyContain refuses an upload it can't read unambiguously with 400 UPLOAD_UNREADABLE, whose message says what to change.
- Your person may allow uploads only for some files, for example one image attached to an expense. Rules for `attachable.upload` in `GET https://api.keycontain.com/v1/key` show these as `upload.` conditions, which must hold for every file and every record it attaches to. An upload outside them waits for your person or is refused.
- 502 UPSTREAM_CREDENTIAL_FAILED on a QuickBooks service: ask your person to reconnect it. Send `PATCH https://api.keycontain.com/v1/services/{service}` with `{ "askForSecrets": true }`, and they reconnect the same company when they approve.

## Plaid

- Each Plaid service is one bank your person connected, with the accounts they shared. `GET https://api.keycontain.com/v1/key` lists them, with each account's `id` and whether Plaid has PDF statements for it (`"statements": true`).
- Call Plaid's API as Plaid documents it, at `https://api.keycontain.com/p/https://production.plaid.com/...`, and put the Plaid service's ID where Plaid expects an access token, for example `"access_token": "svc_plaid_fifththird"`. Leave out `client_id` and `secret`. KeyContain adds them.
- `/statements/list` shows which months exist for each account, `/statements/download` with a `statement_id` returns the PDF, and `/statements/refresh` with a `start_date` and `end_date` fetches newer or older months.
- KeyContain forwards only the Plaid endpoints in its catalog. Others are refused with 403 OPERATION_NOT_ALLOWED.
- Your person adds and reconnects Plaid services in the KeyContain dashboard, where they sign in to their bank. On 502 UPSTREAM_CREDENTIAL_FAILED, tell your person which bank to reconnect there.

## Google Drive

- The connection has full Drive read and write access. Your key's rules decide which native Drive requests KeyContain forwards, including requests for files shared with your person.
- Your person may limit you to My Drive paths such as `Bank statements/**`, or exact folder IDs such as `folder://1SharedA/**`. Both include descendants. The selected folder itself is outside that scope. The current ACL from `GET https://api.keycontain.com/v1/key` includes the ID for an ID-based selection. Use it in Google’s native `q` parameter: `'1SharedA' in parents`. Give every new folder or file its `name` and `parents`. Upload with `uploadType=multipart`. Resumable uploads aren't supported.
- To add a Google Drive service, request one with `"type": "googledrive"` and an optional `label`, as for QuickBooks Online. Your person connects their Google account when they approve.

## Limits

- All keys in your person's account share one pool: 10 requests a minute and 100 MB of traffic a month for free, or 50 requests a minute and 1 GB a month for each capacity unit, at $0.50 a month per unit. Your person can also give your key limits of its own. Every request you make with your key counts, including calls to KeyContain itself.
- 429 RATE_LIMITED or TRAFFIC_LIMIT_REACHED: the message says whether this key's limit or your account's limit was reached. Wait for the `Retry-After` header's number of seconds. Check your usage with `GET https://api.keycontain.com/v1/key`. If the account's limit is too small for your work, tell your person they can add capacity in the KeyContain dashboard.
- 402 PAYMENT_REQUIRED: the account's balance can't pay for what you asked, a change to the account's capacity or a QuickBooks read that costs money. Tell your person to add funds in the KeyContain dashboard. Writes and other services keep working.
- 403 IP_NOT_ALLOWED: your key can't be used from your current IP address. Tell your person which address you are calling from.

## Errors

Every KeyContain error has `error.code` and a `message` written for you. KeyContain may add fields to its JSON responses at any time, so ignore fields you do not recognise. 403 SERVICE_NOT_ATTACHED and OPERATION_NOT_ALLOWED mean your key's rules do not cover the request: check `GET https://api.keycontain.com/v1/key`.

Full reference: https://api.keycontain.com/docs

4Pairing

An agent gets its first KeyContain key by pairing. The agent asks KeyContain for a pairing, shows its person a link and a code, and the person approves it in the dashboard. The key goes straight to the agent, once. Nobody copies or reveals a key.

POST https://api.keycontain.com/v1/pairings

No Authorization. The KeyContain-Agent header names the agent and is required: without it the request is refused with 400 AGENT_REQUIRED (§15). No body.

POST https://api.keycontain.com/v1/pairings
KeyContain-Agent: setup-claude
HTTP/1.1 201 Created
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZP1A2B

{
  "pairing": "par_4MX8QZ7cLw9Tn3Kd8Rb5Yf",
  "code": "KC7Q-2MVX",
  "url": "https://app.keycontain.com/pair/KC7Q-2MVX",
  "expiresAt": "2026-10-05T14:46:00Z",
  "statusUrl": "https://api.keycontain.com/v1/pairings/par_4MX8QZ7cLw9Tn3Kd8Rb5Yf"
}
  • url is for the person, on the dashboard at https://app.keycontain.com. code is what the person checks on that page. The agent shows both, for example: "To connect me to KeyContain, open https://app.keycontain.com/pair/KC7Q-2MVX, check that the page shows the code KC7Q-2MVX, then approve."
  • A pairing expires 15 minutes after it is created.
  • The pairing ID is long and random (128 bits) and is the only thing that can read the pairing's result, so the agent keeps it to itself.
  • Pairing needs no key, so it has its own limits, per IP address: 10 new pairings an hour, and 120 status reads a minute. Over either, the request gets 429 RATE_LIMITED with Retry-After (§15).

GET https://api.keycontain.com/v1/pairings/{pairing}

No Authorization. With ?wait=90, KeyContain holds the request for up to 90 seconds and answers as soon as the status changes. Without wait, it answers at once. Values above 90 are treated as 90.

StatusMeaning
pendingThe person has not decided yet.
approvedThe person approved it. The first response after approval contains key and keyId. Later responses say "keyDelivered": true and never contain the key again.
declinedThe person declined it.
expiredNobody decided within 15 minutes.
HTTP/1.1 200 OK
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZP2C3D

{
  "pairing": "par_4MX8QZ7cLw9Tn3Kd8Rb5Yf",
  "status": "approved",
  "key": "kc_live_Mk7Q2vR9xT4Lw8Nc3Bd6Yk1Hs5Fp0Z4m",
  "keyId": "key_MK7Q2V"
}
{
  "pairing": "par_4MX8QZ7cLw9Tn3Kd8Rb5Yf",
  "status": "approved",
  "keyId": "key_MK7Q2V",
  "keyDelivered": true
}

A pairing can be read for 1 hour after it was created. After that, and for an unknown pairing ID, the answer is 404 PAIRING_NOT_FOUND (§15). If the person approved but the agent never collected the key within that hour, KeyContain deletes the key.

What the person sees

The person opens url, signs in, or signs up if they have no account yet (the pairing waits through sign-up). The page shows the code, the agent's name from KeyContain-Agent, when it was asked, and what the agent will get. The person checks that the code matches the one the agent showed, can rename the key, and approves or declines. Matching the code stops someone from pairing their own agent with a link sent to the person.

The KeyContain master key

Approving creates a key named KeyContain master key (or the name the person chose) with the KeyContain account service attached (§14). It can read everything in the account, and every change it makes waits for the person's confirmation. Its value is delivered once, to the agent, and is never shown in the dashboard. KeyContain sees it only while creating and delivering it, and never stores it (§6). If the agent loses it, it pairs again. Like every key it is free, and its requests count toward the account's limits (§11).

{
  "services": {
    "svc_account": {
      "rules": [
        { "operation": "keys.read", "access": "allow" },
        { "operation": "keys.rotate", "access": "confirm" },
        { "operation": "keys.revoke", "access": "confirm" },
        { "operation": "keys.delete", "access": "confirm" },
        { "operation": "acl.read", "access": "allow" },
        { "operation": "acl.update", "access": "confirm" },
        { "operation": "keys.list", "access": "allow" },
        { "operation": "keys.create", "access": "confirm" },
        { "operation": "services.read", "access": "allow" },
        { "operation": "services.create", "access": "confirm" },
        { "operation": "services.update", "access": "confirm" },
        { "operation": "services.delete", "access": "confirm" },
        { "operation": "ipGroups.read", "access": "allow" },
        { "operation": "ipGroups.create", "access": "confirm" },
        { "operation": "ipGroups.update", "access": "confirm" },
        { "operation": "ipGroups.delete", "access": "confirm" },
        { "operation": "capacity.update", "access": "confirm" },
        { "operation": "activity.read", "access": "allow" },
        { "operation": "billing.read", "access": "allow" }
      ]
    }
  }
}

Its rules have no keys scope, so its key permissions apply to every key in the account, including keys created later.

From there the agent usually sets up the account with one change set (§14).

5Authentication

Every request to the proxy, the meta API or the management API carries exactly one KeyContain key, in the Authorization header. GET /agents and the two pairing endpoints need none.

Key format

  • Production keys are kc_live_ followed by 32 base62 characters, for example kc_live_7Hq2mV9xPz4Lw8NcT3bR6yKd1Fs5c21e. Keys outside production start with kc_test_. The fixed prefix lets secret scanners recognize leaked keys.
  • The full key is delivered once: to the agent, when pairing (§4) or the management API (§14) creates it, or on screen, when a person creates or rotates a key in the dashboard. A key KeyContain creates for an agent passes through its servers once, to be delivered, and is never stored. A key the person creates or rotates in the dashboard is generated in their browser, so KeyContain never sees it (§6). KeyContain stores only a hash of each key and its public half, and afterwards shows only its last four characters (for example c21e). A lost key is rotated, or the agent pairs again.

Sending the key

Send it in one of two forms, both in Authorization:

Authorization: Bearer kc_live_7Hq2mV9xPz4Lw8NcT3bR6yKd1Fs5c21e
Authorization: Basic YWdlbnQ6a2NfbGl2ZV83SHEybVY5eFB6NEx3OE5jVDNiUjZ5S2QxRnM1YzIxZQ==
# base64 of  agent:kc_live_7Hq2mV9xPz4Lw8NcT3bR6yKd1Fs5c21e
Only Authorization. KeyContain reads no other header for its key, and there is no KeyContain-specific credential header. Because the key replaces the service's credential in the same header, tools built for the upstream service work unchanged once their base URL points at KeyContain.

A request without a valid KeyContain key in Authorization (missing, malformed, unknown, revoked or deleted) is refused with 401 KEY_INVALID and is not forwarded (§15).

When that request is a git request, the 401 also carries WWW-Authenticate: Basic realm="KeyContain", so git asks for the key (or uses its credential helper) instead of failing. KeyContain recognizes a git request by its smart HTTP path: it ends in /info/refs with a service=git-upload-pack or service=git-receive-pack query, or in /git-upload-pack or /git-receive-pack (§7). Other 401s carry no WWW-Authenticate header.

Right after the key, KeyContain checks where the request comes from and the limits, for every use of the key, including the meta and management APIs:

  • If the key's ACL limits it to certain IP addresses (§9), a request from any other address is refused with 403 IP_NOT_ALLOWED.
  • A request over the account's request or traffic limit, or over the key's own limit if its ACL sets one (§9), is refused with 429 RATE_LIMITED or TRAFFIC_LIMIT_REACHED (§11).

Only QuickBooks reads through KeyContain's Intuit app can be refused for payment, when the account's balance can't pay for them (§11). When the balance can't pay for the account's capacity, its keys keep working within the free allowance (§11).

6How upstream keys are stored

KeyContain holds upstream keys on its customers' behalf, so it stores them in a way that a copy of its database cannot decrypt. Each KeyContain key unlocks the upstream keys of its own services, and the person's password unlocks the account's copy, in their browser. Nothing changes in how agents call KeyContain.

Keys unlock their own services

  • Each service's upstream key is stored only encrypted, under a random data key of its own (AES-256-GCM, bound to the account and the service).
  • Every KeyContain key turns into its own key pair. KeyContain stores only the public half and a hash for looking the key up. The private half can only be rebuilt from the key itself.
  • For every key a service is attached to, KeyContain stores the service's data key sealed to that key's public half. Sealing needs only the public half, so KeyContain can seal for any key, but only the key itself can unseal.
  • On each request, the key in Authorization rebuilds its private half in memory, unseals the data key and decrypts the upstream key. KeyContain forwards the request and then discards all three. A QuickBooks refresh token that changes during a request is encrypted again under the same data key.

The person's password unlocks the account's copy

  • When the person signs up, their browser creates the account's own key pair. Each service's data key is also sealed to the account's public half, so the person can give a service to more keys later.
  • The browser turns the password into two separate keys (Argon2id, then HKDF). The sign-in key goes to KeyContain, which stores only a hash of it and checks it when the person signs in. The unlock key never leaves the browser. KeyContain stores the account's private half encrypted under the unlock key, and a second copy encrypted under a recovery code that the browser shows once at sign-up.
  • KeyContain never receives the password, the unlock key, the account's private half or the recovery code. Signing in is by email and password only, with optional two-factor authentication. Passwords have at least 16 characters, and common passwords are refused.
  • After sign-in the account's private half stays unlocked in that browser tab's memory only. A new tab or a reload is locked again, and the first action that needs it asks for the password.

What needs the password

ActionWhat happens
Adding a service, updating an upstream key, reconnecting a QuickBooks Online or Google Drive service, or a change set that creates or reconnects a serviceNo password. The person's browser sends the upstream key over TLS. For QuickBooks Online, Intuit sends KeyContain the connection's tokens after the person connects the company, and the browser sends the client ID and client secret of the person's own Intuit app if they use one. For Google Drive, Google sends KeyContain the connection's tokens after the person signs in. For Plaid, the browser sends the client ID and secret, and after the person connects a bank in Plaid's window, KeyContain's server exchanges Plaid's token for the connection's access token, as it exchanges a QuickBooks code. KeyContain checks the key with the upstream service, encrypts it under a new data key, seals that to the account and to every key the service is attached to, and discards the plain value.
Reconnecting a Plaid service, on its page in the dashboardThe password, once per tab. Plaid reconnects the same connection, which needs its access token, so the browser unseals the service's data key with the account's private half and hands it to KeyContain's server for that one request. KeyContain decrypts the access token, opens Plaid's window for the person and discards both.
Giving an existing service to a key: attaching it in the dashboard, or approving an acl.update or change set that does (§14)The password, once per tab. The browser unseals the data key with the account's private half and sends KeyContain only the new sealed copy.
Creating or rotating a key in the dashboardThe browser generates the new key itself and shows it once, so KeyContain never sees it. If the key has services, the browser seals their data keys to it, which needs the password.
Approving an agent's request to rotate another key (§14)The password. The browser seals the key's data keys to the new value.
An agent rotating its own keyNo password. The request carries the old key, so KeyContain unseals and seals again for the new one. The person only approves, as the key's rules require.
Revoking or deleting a key, deleting a serviceNo password. KeyContain deletes the sealed copies, and for a service its encrypted upstream key too, so nothing is left that could decrypt it.

Agents see no difference, except that an approval that needs the password may take a moment longer.

Forgotten password

The person resets the password by email. On the reset page they enter the recovery code, and the account's private half is encrypted again under the new password. Without the recovery code the reset still works and every key keeps working, but existing services can't be given to more keys until their upstream key is entered again, or a QuickBooks Online or Google Drive service is reconnected. A Plaid service can't be reconnected then, since that needs its data key from the account, so the person adds the bank again as a new service. In Settings the person can make a new recovery code, and the old one stops working.

When KeyContain sees plain values

  • Upstream keys: when the person enters one, connects a QuickBooks Online company, a Google account or a bank, or reconnects a Plaid service, and in memory for the length of each request that uses it. Never in logs.
  • Data keys: when the person reconnects a Plaid service or checks folders chosen for a Drive scope. Their unlocked browser hands that service's data key to KeyContain for that request, and KeyContain discards it afterwards. Stored Google access and refresh tokens never go to the dashboard.
  • KeyContain keys: on every request, because the key is the credential, and once when KeyContain creates a key to deliver it to an agent (pairing, keys.create, an agent's rotation). Never stored.
  • Never: the person's password, the unlock key, the account's private half, the recovery code, or any data key at rest.

What a breach would expose

BreachExposed
A copy of the database or a backupNo upstream key can be decrypted without a KeyContain key or the person's password. The encrypted account key can be attacked offline by guessing passwords, which is why passwords need at least 16 characters.
The database and a leaked KeyContain keyThat key's services only, which the key already reaches through the proxy.
An attacker running KeyContain's serversUpstream keys used in requests, or entered, connected or reconnected, while they are inside. They could also change the dashboard's code to capture the next password typed, a limit of any encryption done in a browser. Upstream keys that are not used or entered during that time stay encrypted.

7Proxy

Prefix the service's complete URL with https://api.keycontain.com/p/. Every upstream URL has exactly one KeyContain URL.

Upstream URLKeyContain URL
https://quickbooks.api.intuit.com/v3/company/9130354/invoice/1043https://api.keycontain.com/p/https://quickbooks.api.intuit.com/v3/company/9130354/invoice/1043
https://code.acme.dev/api/v4/projects/42/merge_requestshttps://api.keycontain.com/p/https://code.acme.dev/api/v4/projects/42/merge_requests
https://github.com/acme-inc/api.githttps://api.keycontain.com/p/https://github.com/acme-inc/api.git
  • Query strings are part of the upstream URL and are forwarded as sent.
  • Only https:// upstream URLs. Anything else after /p/ (an http:// URL, a relative path, a malformed URL) is refused with 400 UPSTREAM_URL_INVALID, so upstream keys never travel unencrypted.
  • URLs KeyContain refuses. An upstream URL longer than 8 KiB, one with a . or .. path segment, or one with a user name or password (https://user:pass@host/…) is refused with 400 UPSTREAM_URL_INVALID. This stops path tricks from reaching endpoints a rule doesn't cover and credentials from being smuggled upstream. A segment counts as . or .. also when it is percent-encoded once or twice (%2e%2e, %252e%252e), when parameters follow it (..;x, ..%3Bx), and when an encoded slash or backslash, once or twice encoded, separates it from its neighbours (a%2F.., a%252F.., ..%5Cb). A raw backslash or an encoded NUL (%00) anywhere in the path is refused too.
  • No method overrides. Some upstreams treat these as the request's real method, so a rule that allows one method could otherwise be used for another. Send the real method instead. KeyContain refuses with 400 REQUEST_INVALID a request with an X-HTTP-Method-Override, X-HTTP-Method or X-Method-Override header or a _method query parameter, and one whose form-encoded, multipart or JSON body has a _method field at its top level. Names are compared after percent-decoding, and every occurrence counts. To check them, KeyContain reads form-encoded and JSON bodies (gzip ones decoded) up to 10 MiB, and a larger one is refused with 413 REQUEST_TOO_LARGE. A multipart body of any size is checked as it streams, and the request is cut off before the upstream receives it in full if a _method part appears.
  • Collapsed double slash. Some HTTP clients merge // into /, sending /p/https:/code.acme.dev/…. KeyContain treats that form as identical to /p/https://code.acme.dev/….

Choosing the service

KeyContain checks every request in this order and stops at the first failure, so a request with several problems gets the error of the earliest step (§15).

  1. It authenticates the key (§5). Without a valid key: 401 KEY_INVALID.
  2. It checks the caller's IP address against the key's ips (§9), then the account's and the key's own request and traffic limits (§11): 403 IP_NOT_ALLOWED, 429 RATE_LIMITED or 429 TRAFFIC_LIMIT_REACHED.
  3. It checks the URL after /p/. Not a complete https:// URL, longer than 8 KiB, with a . or .. segment (in any of the forms above), a backslash or %00, or with a user name or password: 400 UPSTREAM_URL_INVALID. Then a method override header or query parameter: 400 REQUEST_INVALID.
  4. Among the services attached to the key, it picks the one whose base URL is the longest prefix of the upstream URL. A GitHub service has two base URLs, its API base URL and its git base URL. A Google Cloud, Firebase and Play service has six, one for each Google host it reaches (§8). Any of them can match. If none matches, the request is refused with 403 SERVICE_NOT_ATTACHED.
  5. Plaid services share one base URL, https://production.plaid.com, so the request names its service. KeyContain forwards only the Plaid endpoints in its catalog (§12): any other method and path is refused with 403 OPERATION_NOT_ALLOWED, with its own message, whatever the rules say. Then it reads access_token from the JSON body, where Plaid expects an access token. It must be the ID of a Plaid service attached to the key, such as svc_plaid_fifththird, and that service is the one matched. Missing, or not a Plaid service attached to this key: 400 PLAID_SERVICE_REQUIRED (§15). A body that isn't a JSON object, or is over 10 MiB, has no access_token KeyContain can read.
  6. The rest of the upstream URL after the base URL is the path that rules match. For https://code.acme.dev/api/v4/projects/42 on a GitLab service with base URL https://code.acme.dev, the path is /api/v4/projects/42.
  7. It evaluates the matched service's rules (§9). If a rule with access allow covers the request, the request goes on. Otherwise, if a rule with access confirm covers it, the request needs a person's confirmation (§10). Otherwise it is refused with 403 OPERATION_NOT_ALLOWED. A request matched to any attached service whose URL is the sign-in, token or revoke endpoint of a provider KeyContain connects services through (Intuit's for QuickBooks Online, Google's for Google Drive, such as https://www.googleapis.com/oauth2/v4/token) is refused with 403 OPERATION_NOT_ALLOWED whatever the rules say, with its own message (§15): KeyContain connects the service and refreshes its access itself.
  8. For a read on a QuickBooks Online service connected through KeyContain's Intuit app, it checks that the account's balance can pay for the read, before holding it for confirmation and again before forwarding it (§11). If not: 402 PAYMENT_REQUIRED.
  9. It forwards the request, as described below.
Upstream URLMatched servicePath for rules
https://api.github.com/repos/acme-inc/api/pullsGitHub, API base URL https://api.github.com/repos/acme-inc/api/pulls
https://github.com/acme-inc/api.git/git-receive-packGitHub, git base URL https://github.comrepository /acme-inc/api.git
https://quickbooks.api.intuit.com/v3/company/9130354/queryQuickBooks, …/v3/company/9130354/query
https://fcm.googleapis.com/v1/projects/acme-app/messages:sendGoogle Cloud, Firebase and Play, base URL https://fcm.googleapis.com/v1/projects/acme-app/messages:send
https://production.plaid.com/statements/download with "access_token": "svc_plaid_fifththird"Plaid, Fifth Third Bank, base URL https://production.plaid.com/statements/download
https://www.googleapis.com/upload/drive/v3/files?uploadType=multipartGoogle Drive, base URL https://www.googleapis.com/upload/drive/v3/files

What KeyContain changes in a forwarded request

A forwarded request is the request you sent, unmodified, except for these changes:

  1. Credential replaced. The KeyContain key in Authorization is removed and the service's upstream credential is added where that service type expects it (§8). For most types that is Authorization itself, for Google Drive a Google access token as Bearer. For a Generic HTTP service with a custom credential header, Authorization is removed and the configured header is set.
  2. Plaid's credentials. On a Plaid service, KeyContain sets PLAID-CLIENT-ID and PLAID-SECRET to the service's client ID and secret, replacing any the request sent. In the JSON body it replaces access_token, the service's ID, with the bank connection's real access token, and removes client_id and secret, keeping every other field. Content-Length is set for the new body.
  3. Description headers removed. KeyContain-Agent, KeyContain-Purpose and KeyContain-Reason (§10) are removed from every request, whether or not it needed confirmation.
  4. Host. Host is set to the upstream host.
  5. HTTP transport. Hop-by-hop headers (Connection and the headers it names, Keep-Alive, TE, Trailer, Transfer-Encoding, Upgrade, Proxy-Authorization) are handled per HTTP rather than forwarded.

The method, the path and query, the body (byte for byte, apart from the credential fields Porkbun and Plaid services take in the body), and every other header are forwarded as sent. KeyContain adds no forwarding headers of its own.

Responses

The upstream response is returned unchanged, with one added header, KeyContain-Request-Id, and no rate limit headers of KeyContain's own (usage is in GET /v1/key, §13). KeyContain-Request-Id identifies the request in the dashboard's activity log and in support requests. A request that went through after a person approved it also carries KeyContain-Confirmation: cnf_…; approved (§10). Upstream errors (for example a GitLab 404) pass through unchanged. Only refusals and errors that KeyContain itself produces use the KeyContain error envelope (§15).

GET /p/https://code.acme.dev/api/v4/projects/42/issues HTTP/1.1
Host: api.keycontain.com
Authorization: Bearer kc_live_7Hq2mV9xPz4Lw8NcT3bR6yKd1Fs5c21e
Accept: application/json

# forwarded as
GET /api/v4/projects/42/issues HTTP/1.1
Host: code.acme.dev
Authorization: Bearer glpat-••••••••••••a9f2
Accept: application/json

# returned to the caller
HTTP/1.1 200 OK
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZK2M8T
...GitLab's response body, unchanged...
POST /p/https://production.plaid.com/statements/download HTTP/1.1
Host: api.keycontain.com
Authorization: Bearer kc_live_7St3Mb9Qw2Lx5Rv8Nc4Yd1Hk6Tp0st3m
Content-Type: application/json

{ "access_token": "svc_plaid_fifththird", "statement_id": "eYupqX1mZkEUs1yRbgqBTyNmrYlD7ZtqWp4nB" }

# forwarded as
POST /statements/download HTTP/1.1
Host: production.plaid.com
Content-Type: application/json
PLAID-CLIENT-ID: 64f0c2e1a9b83d0019e4c7a2
PLAID-SECRET: ••••••••••••••••••••••••9d2c

{ "access_token": "access-production-••••••••-••••-••••-••••-••••••••4b1e", "statement_id": "eYupqX1mZkEUs1yRbgqBTyNmrYlD7ZtqWp4nB" }

git over HTTPS

KeyContain proxies git's smart HTTP protocol, so clone, fetch, pull and push work through it with the KeyContain key as the password. Without a valid key, git gets 401 with WWW-Authenticate: Basic realm="KeyContain", so it prompts for the key (§5).

git actionRequests under …/p/https://{git host}/{repo}.gitRule kind
clone, fetch, pullGET/info/refs?service=git-upload-pack
POST/git-upload-pack
"git": "fetch"
pushGET/info/refs?service=git-receive-pack
POST/git-receive-pack
"git": "push"

For a push, KeyContain reads the ref-update commands at the start of the push (old object, new object, ref name) and evaluates each ref update separately, setting git.ref, git.force and git.delete for it. If any ref update is not allowed, the whole push is refused before anything reaches the upstream, and git shows KeyContain's explanation (§15). A push whose ref updates are covered only by confirm rules waits for a person (§10).

KeyContain through KeyContain

A service of type keycontain holds another KeyContain key as its upstream key. That lets one KeyContain key be narrowed further by another, for example when a team receives a key from someone else and hands an agent an even narrower one. The agent holds only the outer key. Each KeyContain replaces the key in Authorization with the next credential.

Here the outer key has a KeyContain service with base URL https://api.keycontain.com/p/https://gitlab.com, whose stored inner key has a GitLab service at https://gitlab.com attached.

1
GET /p/https://api.keycontain.com/p/https://gitlab.com/api/v4/projects/42 HTTP/1.1
Host: api.keycontain.com
Authorization: Bearer kc_live_A7d2Xq9Lm4Pz8Rt1Wb6Nc3Ky5Hs0Fj2o
2
GET /p/https://gitlab.com/api/v4/projects/42 HTTP/1.1
Host: api.keycontain.com
Authorization: Bearer kc_live_B91xTq4Wn7Ld2Ys8Kc5Pr0Mh3Vb6Ga1i
3
GET /api/v4/projects/42 HTTP/1.1
Host: gitlab.com
Authorization: Bearer glpat-••••••••••••77d0

The outer key's rules are evaluated on the path after the KeyContain service's base URL (/api/v4/projects/42), and the inner key's rules again at the inner KeyContain. A request must be allowed by both.

8Services

A service is created in the dashboard, or by an agent with a person's confirmation (§14), then attached to any number of KeyContain keys. Its type decides its fields, its base URL, and how KeyContain presents the upstream credential.

Every service has an id (svc_…), its type, and an optional label the customer chooses to tell apart several services of the same type (for example "Acme Holdings" for one QuickBooks company). label is null when unset. Upstream secrets are never shown again after entry. The dashboard shows them masked, for example ••••a9f2. For a key file it shows whose key it is instead: the service account's email and key ID for Google Cloud, Firebase and Play, the key ID and issuer for App Store Connect.

A QuickBooks Online service is one company, the one the person picked when connecting it. The dashboard shows that company and the service's Intuit app instead of a masked secret. A company can be connected once in an account, and reconnecting a service must pick the same company. Reads on a service connected through KeyContain's Intuit app cost $0.01 each, and on the customer's own Intuit app they are free (§11).

A Plaid service is one bank connection (a Plaid Item), made with the customer's own Plaid account, like one QuickBooks company. It has the accounts the person chose to share when connecting the bank, each with its Plaid account ID, name, last 4 digits, type and whether Plaid returns PDF statements for it, which only US checking and savings accounts have. The dashboard shows the bank, its accounts, the client ID and the secret masked. Several Plaid services share one base URL, so a request names its service in access_token (§7). Connecting asks Plaid for as few statements as it allows, and new and older months come from Statements · Refresh (§12).

A Google Drive service is one Google account, connected with Google's sign-in through KeyContain's Google app with read and write access to the account's Drive (Google's https://www.googleapis.com/auth/drive scope). KeyContain limits each key through its permissions, optional folder scopes and Only when conditions. The dashboard shows the account's email instead of a masked secret. A Google account can be connected once in an account, and reconnecting a service must pick the same account.

TypeDashboard fieldsBase URLCredential KeyContain adds
QuickBooks Online
quickbooks
None to paste. The person chooses the Intuit app, KeyContain's (the default) or their own, whose Client ID and Client secret they type after adding KeyContain's redirect URI, https://app.keycontain.com/oauth/callback, to it. Then they connect with Intuit's sign-in and pick the company. Production companies only.https://quickbooks.api.intuit.com/v3/company/{company ID}, with the ID of the company picked at IntuitAuthorization: Bearer {access token}. KeyContain obtains and refreshes access tokens itself with the connection's refresh token, through the service's Intuit app. It stores the refresh token, and for the customer's own Intuit app that app's client ID and client secret, encrypted like any upstream key (§6).
GitHub
github
API base URL (default https://api.github.com), Git base URL (default https://github.com), Access tokenTwo: the API base URL for REST, the git base URL for gitREST: Authorization: Bearer {token}
git: Authorization: Basic of x-access-token:{token}
GitLab
gitlab
Base URL (default https://gitlab.com. A self-managed instance uses its own, e.g. https://code.acme.dev), Access tokenThe base URL, for both REST and gitREST: Authorization: Bearer {token}
git: Authorization: Basic of oauth2:{token}
Cloudflare
cloudflare
API token (user or account owned), with at least Zone Readhttps://api.cloudflare.com/client/v4Authorization: Bearer {token}
Porkbun
porkbun
API key, Secret API keyhttps://api.porkbun.com/api/json/v3X-API-Key: {API key} and X-Secret-API-Key: {secret API key}. KeyContain removes any apikey or secretapikey from a JSON body first, since Porkbun would use those instead.
Namecheap
namecheap
API user, API key. The dashboard shows KeyContain's outgoing address to whitelist in Namecheap, which only answers whitelisted IPv4 addresses.https://api.namecheap.comQuery parameters ApiUser and UserName (the API user), ApiKey and ClientIp (KeyContain's outgoing address), replacing any the agent sent. KeyContain never logs the forwarded query.
Google Cloud, Firebase and Play
googlecloud
Service account key, the JSON key file of a Google Cloud service account, which the person drops or chooses. KeyContain checks it by getting an access token with it.Six, one per Google host: https://firebase.googleapis.com, https://fcm.googleapis.com, https://serviceusage.googleapis.com, https://cloudresourcemanager.googleapis.com, https://iam.googleapis.com and https://androidpublisher.googleapis.comAuthorization: Bearer {access token}. KeyContain makes short-lived access tokens from the key itself, for Google Cloud and for Play publishing only, so the key can't reach Drive, Gmail, Photos or any other Google data. The key never leaves KeyContain.
App Store Connect
appstoreconnect
Issuer ID, Key ID, API key (the .p8 file of an App Store Connect team key, which the person drops or chooses). Checked with GET /v1/apps?limit=1.https://api.appstoreconnect.apple.comAuthorization: Bearer {token}, a token KeyContain signs with the key, valid for at most 20 minutes.
Plaid
plaid
Client ID and Secret of the customer's own Plaid account, for production. Then the person connects one bank in Plaid's own window (Plaid Hosted Link): they pick the bank, sign in and choose the accounts to share. The Plaid account must list https://app.keycontain.com/plaid/done in its Allowed redirect URIs, since Plaid's window returns there. The label starts as the bank's name.https://production.plaid.comPLAID-CLIENT-ID: {client ID} and PLAID-SECRET: {secret}, and in the JSON body the connection's access token in place of the service's ID in access_token. KeyContain removes any client_id or secret from the body (§7). It stores the client ID, the secret and the access token encrypted like any upstream key (§6).
Google Drive
googledrive
None to paste. The person connects a Google account with Google's sign-in, through KeyContain's Google app, and grants full Drive read and write access. Each key is limited by its permission rules.https://www.googleapis.com. Drive's paths start with /drive/v3/ and /upload/drive/v3/Authorization: Bearer {access token}. KeyContain obtains and refreshes access tokens itself with the connection's refresh token, which it stores encrypted like any upstream key (§6).
Generic HTTP
http
Base URL, Credential header (default Authorization), Credential format (default Bearer {secret}), SecretThe base URLAuthorization is removed, then the credential header is set to the credential format with {secret} replaced, e.g. X-API-Key: sk_••••0d4e
KeyContain
keycontain
Base URL (must start with https://api.keycontain.com/p/https://), KeyContain keyThe base URL, e.g. https://api.keycontain.com/p/https://gitlab.comAuthorization: Bearer {stored KeyContain key}. For git, the stored key as the basic-auth password
KeyContain account
account
None. Built in: every account has exactly one, svc_account, which cannot be deleted.https://api.keycontain.com/v1None. There is no upstream key: KeyContain answers these calls itself (§14).

Customers allow KeyContain's address at Cloudflare, Porkbun and Namecheap, so only services of those types can reach their API hosts: a GitLab, GitHub, Generic HTTP or KeyContain service can't use api.cloudflare.com, api.porkbun.com, api.namecheap.com or api.sandbox.namecheap.com as its base URL, and is never selected for a request to them (§15). The same applies to production.plaid.com, so every request to Plaid goes through a Plaid service and its catalog.

A Google Cloud, Firebase and Play service is one Google service account, which reaches every project and app the account was given, in Google Cloud and in Play Console. Granted on a Google organization or folder, and with Play Console's account permissions, it also reaches projects and apps added later, so one service covers every app a person ships. Its rules are limited to projects for Google Cloud and Firebase permissions and to Play apps for Play permissions (§9). Drive, Gmail and Photos are not part of it. Google Drive is a service type of its own. An App Store Connect service is one App Store Connect team, through a team key, which reaches every app in the team.

The keycontain type proxies through another KeyContain key (§7). The account type is different: attaching svc_account to a key lets that key use the management API for this account, within its rules (§14).

A service whose upstream credential stops working (a revoked token, an expired or revoked QuickBooks or Google refresh token) makes requests fail with 502 UPSTREAM_CREDENTIAL_FAILED until the customer updates it in the dashboard, or for QuickBooks Online and Google Drive reconnects it. An agent can ask for that with "askForSecrets": true (§14).

A bank that asks the person to sign in again (Plaid's ITEM_LOGIN_REQUIRED) marks its Plaid service not connected. Plaid's answer passes through to the agent once, and then the service's requests fail with 502 UPSTREAM_CREDENTIAL_FAILED without calling Plaid, until the person reconnects it. They reconnect it from the service's page in the dashboard, where Plaid's window opens for the same connection, and a tab that hasn't unlocked the account key asks for the password first (§6). An agent can't ask for it (§14).

KeyContain defines each service type separately: its fields, credential, scope and permission catalog (§12). If one type's definition ever fails to load, KeyContain turns off only that type. Requests to services of that type get 503 SERVICE_TYPE_UNAVAILABLE (§15) and the dashboard shows them as unavailable. Every other type keeps working.

9ACL format

A key's ACL gives each attached service a list of rules, on every service type. Each rule names one permission (or a method and path, or a git action and path) and its access: allow, for requests that go through directly, or confirm, for requests that go through only after a person approves them. On most types, a rule can also be limited to some projects, repositories, zones, domains, apps, bank accounts, folders or keys (Scope). The same permission can have several rules, so it can be allowed under one condition and wait for a person under another. Nothing is allowed unless a rule covers it. An optional ips list limits where the key can be used from, and optional limits cap the key's own requests and traffic.

{
  "ips": ["ipg_office", "203.0.113.7"],
  "limits": { "requestsPerMinute": 50, "trafficPerMonth": 524288000 },
  "services": {
    "svc_qbo_acme": {
      "rules": [
        { "operation": "invoice.read", "access": "allow" },
        { "operation": "invoice.create", "access": "allow",
          "where": { "body.CustomerRef.value": { "in": ["61", "74"] } } },
        { "operation": "invoice.create", "access": "confirm" },
        { "operation": "customer.read", "access": "allow" }
      ]
    },
    "svc_gitlab_acme": {
      "rules": [
        { "operation": "repository.read", "access": "allow", "projects": ["acme/api"] },
        { "operation": "issues.read", "access": "allow", "projects": ["acme/api"] },
        { "operation": "merge_requests.merge", "access": "confirm", "projects": ["acme/api"] }
      ]
    }
  }
}
  • The services named in services are the key's attached services. A service with an empty rules list is attached but allows nothing.
  • Rules keep the order they are written in, which is the order of the rows in the dashboard. Order has no effect on evaluation. The fields of a rule are described below (Rules).
  • The meta API returns the ACL in this format (§13), and the management API changes it with acl.update (§14).

In the dashboard, each attached service shows its rules as rows of one table, in the same order: Permission (such as Invoices · Create, or a custom rule's method and path), Access (Allowed for allow, Ask me for confirm, with how long it waits, expiresAfter), then on a type with a scope its scope column (Projects, Repositories, Zones, Domains, Projects or apps, Apps, Accounts, Folders or Keys), and last Only when (where), on every type. + Add permission opens a picker: an object, then its action, with a search, a Custom rule entry for a method and path (or a git action and path) on every type but Plaid, and an Every Read shortcut that adds an Allowed row for every read permission not allowed yet. New rows start as Allowed for reads and for GET, HEAD and git fetch custom rules, and as Ask me otherwise. Confirm-only permissions are always Ask me. Access, conditions and scope are changed in popovers on the row. A copied row is inserted right below its original, ready for other conditions or another decision, and a removed row can be brought back with Undo. With more than 8 rows, filter chips (All, Allowed, Ask me, With conditions) and a search appear. The dashboard also offers a JSON view of exactly this format.

Rules

Every service type takes the same rule shape.

FieldTypeMeaning
operation, or method and path, or git and pathstringsWhat the rule covers: one catalog permission, a method and path, or a git action and path (Rule kinds).
access"allow" or "confirm"allow: requests the rule matches go through directly. confirm: they wait for a person's confirmation (§10).
projects, repositories, zones, domains, apps, accounts, folders or keyslist of strings, optionalThe rule's scope, on GitLab, GitHub, Cloudflare, Porkbun, Namecheap, Google Cloud, Firebase and Play, App Store Connect, Plaid, Google Drive and the KeyContain account, for scoped permissions only (below). Without it, the rule covers all of them.
whereobject, optionalConditions that must all hold (Conditions).
expiresAfterstring, optionalOnly on rules with access confirm: how long a confirmation waits for a person (below).
  • Rules add up. A request is allowed if any rule with access allow matches it, even when a rule with access confirm matches it too. Otherwise it waits for a person if any rule with access confirm matches. Otherwise it is refused. There are no deny rules, so one rule cannot take away what another allows.
  • So any number of rules may name the same permission, with different conditions and different access. The Bookkeeping agent below creates invoices directly for two regular customers and asks Dana for every other customer (example).
  • A rule equal to another, or a confirm rule that an allow rule for the same permission also matches (its scope covers the other's, and its conditions are some of the other's), is accepted and changes nothing. The dashboard marks such rows "Repeats another row" and "Never asked, an Allowed row covers it".

Scope

A rule's scope lists what it applies to. The scope field depends on the service type:

Service typeScope fieldEntries
GitLabprojectsProject paths, such as acme/api or acme/tools/cli, or patterns
GitHubrepositoriesRepositories as owner/repo, such as acme-inc/api, or patterns
CloudflarezonesZone names, such as acme.dev, or patterns
Porkbun, NamecheapdomainsDomain names, such as acme.dev, or patterns
Google Cloud, Firebase and PlayprojectsFor Google Cloud and Firebase permissions: project IDs, such as acme-app, or patterns
appsFor Play permissions: package names, such as com.acme.app, or patterns
App Store ConnectappsBundle IDs, such as com.acme.app, or patterns
PlaidaccountsPlaid account IDs of the service's bank connection, such as rz99ex9ZQotvnjXdgQLEsR81e3ArPgulVWjGj (Sapphire Preferred ••3349), without patterns. The dashboard shows each by its name and last 4 digits.
Google DrivefoldersMy Drive paths and patterns, or an exact folder ID as folder://1SharedA/**
KeyContain accountkeysKey IDs in the account, such as key_5TB1V8
QuickBooks Online, Generic HTTP, KeyContainnoneTheir rules have no scope.
  • Scoped permissions are about one project, repository, zone, domain, app, bank account, file or folder, or key, such as issues.read, statements.download or acl.update. A rule for one with a scope applies only to requests about an entry in it. Without a scope, it applies to all. On Google Cloud, Firebase and Play, a project permission's rules take projects and a Play permission's rules take apps, as the catalog groups them (§12).
  • Service-level permissions are about the whole service, such as projects.list, repositories.list, statements.list or services.read, and every permission of QuickBooks Online, Generic HTTP and KeyContain services. Their rules can't have a scope. The catalog marks which is which (§12). The dashboard shows "Whole connection" in the Accounts cell of a Plaid connection permission.
  • Method and path rules and git rules can't have a scope either, because KeyContain can't tell which project an arbitrary path is about. They apply to every project, repository, zone, domain, app, folder or key their path matches. A Plaid service takes no method and path rules at all, since KeyContain forwards only the Plaid endpoints in its catalog (§12). On Google Cloud, Firebase and Play, a method and path rule's path is relative to whichever of the service's six base URLs the request matched, so GET /v1/** covers reads on every Google host whose paths start with /v1/.
  • Patterns. * matches within one segment and ** across segments: acme/* matches acme/api but not acme/tools/cli, and acme/** matches both. Paths are compared without regard to case, as GitLab and GitHub do. A zone or domain name is one segment, so *.dev matches acme.dev, and zone and domain names are compared without regard to case too. Project IDs, package names and bundle IDs are one segment too, so acme-* matches acme-app and com.acme.* matches com.acme.app. Project IDs are compared without regard to case. Package names and bundle IDs are compared exactly, as Google and Apple compare them. In a folder path each folder's name is one segment, so Bank statements/* matches Bank statements/Chase but not Bank statements/Chase/2026, and Bank statements/** matches everything inside Bank statements but not the folder itself. Folder paths are compared exactly, including case, as Drive compares names. keys and accounts take IDs only, without patterns.
  • A scope lists at least one entry. To apply a rule to everything, leave the scope field out.
  • GitLab project IDs. GitLab REST paths can name a project by its numeric ID (/api/v4/projects/42) or its URL-encoded path (/api/v4/projects/acme%2Fapi). KeyContain resolves a numeric ID to the project's path with one upstream lookup, which it caches, so scopes always compare paths. git requests name the path directly (/acme/api.git). If an ID can't be resolved, no rule with a scope covers the request.
  • Cloudflare zone IDs. Cloudflare paths name a zone only by its ID (/zones/9c1e5d7a0b3f4e2d8a6c4b2e0f1d3a5c/dns_records). KeyContain resolves the ID to the zone's name with GET /zones/{zone} through the service, which it caches, so scopes always compare names. If an ID can't be resolved, no rule with a scope covers the request.
  • Google project numbers. Google paths can name a project by its number (projects/731249163208) as well as its ID. KeyContain resolves a number to the project's ID with one lookup through the service, which it caches, so scopes always compare IDs. A path that names the project as -, or a number that can't be resolved, isn't covered by any rule with projects. Neither is a path whose Firebase app ID or service account belongs to another project than the one the path names, so a rule limited to staging never reaches another project's app or service account.
  • App Store Connect apps. Most App Store Connect paths name a version, build or submission, not the app. KeyContain finds the request's app from /v1/apps/{app} in the path, from filter[app] in the query, from the app the body names (data.relationships.app.data.id), or by looking up the version, build, submission, app info or TestFlight group the path or body names, and caches what it finds. All of them must name the same app. It compares the app's bundle ID with the scope. A request whose app it can't find, such as a list of every app, isn't covered by any rule with apps.
  • Namecheap domains. Namecheap takes every command at one URL and names the domain in the query, as DomainName or as SLD and TLD. KeyContain reads it into namecheap.domain (for example acme.dev) and compares that with the scope. A request whose domain it can't read isn't covered by any rule with a scope.
  • Plaid accounts. Each account permission finds its accounts in its own way, and every account it finds must be in the rule's accounts:
    • Accounts · Read, Transactions · Read and Liabilities · Read take them from body.options.account_ids, or body.account_id on /transactions/sync. A request without them is about every account of the connection, so only a rule without accounts covers it.
    • Statements · Download looks up which account its statement_id belongs to, through /statements/list with the service's credential, and caches it. A statement it can't find isn't covered by any rule with accounts.
    • Connection permissions (Statements · List, Statements · Refresh, Transactions · Refresh and Bank connection · Read) can't be limited by account, since Plaid has no account filter for them. Statements · List returns which months exist for each account, never a statement.
  • Google Drive folders. KeyContain compares the path of the file or folder the request is about, from the top of My Drive, such as Bank statements/Fifth Third Bank/2026-09 Momentum Checking.pdf:
    • For Folders · Create and Files · Upload, the parent folder's path and the new name, from body.parents and body.name, or from the upload's metadata part. Without parents the parent is the top of My Drive.
    • For Files · Read, Download, Update and Delete, the file's path, which KeyContain looks up through the service from the file's name and parents, freshly for each authorization. Files · Update that moves a file (query.addParents) or renames it (body.name) must be covered for its new path too.
    • Paths remain name-based. Two folders with the same path both match, including a duplicate created after a scope was saved. The dashboard refuses an ambiguous picker selection and preserves existing API-authored patterns exactly.
    • The dashboard uses KeyContain's folder browser to choose folders. Each chosen folder includes all its descendants automatically, represented in the ACL as path/** for My Drive paths or folder://ID/** for shared-folder selections. The folder itself is not included. API authors can still use the documented path patterns.
    • Files · List with folders needs exactly one q parameter: 'folderId' in parents, followed optionally by and trashed = false and by and mimeType = 'value' or and mimeType != 'value'. The optional clauses may appear in either order, at most once each. Whitespace between tokens is insignificant. Parentheses, or, not, duplicate clauses and other terms are not covered by a scoped rule. IDs and MIME values use Google's quoted-string escaping.
    • For name-based folder scopes, the parent's current path must establish that every possible immediate child's path matches that rule's folders. A pattern ending in /* covers immediate children only. A pattern ending in /** also covers children of its matching descendants. An exact folder path or a filename pattern such as Bank statements/*.pdf cannot authorize a listing of that folder. Only when conditions also have to match.
    • Each page is authorized again, including fresh parent ancestry. KeyContain forwards the native query and Google's response unchanged. Without a covering rule, it returns the usual 403 OPERATION_NOT_ALLOWED. A List rule without folders can authorize Google's full query syntax, including a Drive-wide name search.
    • A shortcut does not grant access to its target. Name-based scopes require physical paths inside My Drive. ID-based scopes require verified physical ancestry to their exact folder ID, as described below. Unscoped rules retain Google's native reach.
    • A file whose path KeyContain can't find, including items outside My Drive, or whose name contains / or *, isn't covered by a name-based folder scope. An ID-based scope can cover shared items through their physical ancestry. A media upload has no name or parents and remains outside scoped rules.
{
  "services": {
    "svc_gitlab_acme": {
      "rules": [
        { "operation": "repository.read", "access": "allow",
          "projects": ["acme/api", "acme/web"] },
        { "operation": "repository.push", "access": "allow",
          "projects": ["acme/api"],
          "where": { "git.ref": { "startsWith": "refs/heads/agent/" } } },
        { "operation": "issues.read", "access": "allow",
          "projects": ["acme/api", "acme/web"] },
        { "operation": "issues.create", "access": "allow",
          "projects": ["acme/web"] },
        { "operation": "merge_requests.read", "access": "allow",
          "projects": ["acme/api"] },
        { "operation": "merge_requests.merge", "access": "confirm",
          "projects": ["acme/api"] }
      ]
    }
  }
}

Shared folders and exact folder IDs

The folders list also accepts folder://ID/**. This identifies one physical Google folder and covers its descendants, including when the folder is shared with the connected account and its parent is inaccessible. Its own read, update and delete remain outside that selection. Listing the selected folder’s children is covered by a Files · List rule using the native parent query.

{
  "operation": "files.list",
  "access": "allow",
  "folders": ["Bank statements/**", "folder://1SharedA/**"]
}

The dashboard shows names and owner context on removable chips. It selects folders in My Drive and Shared with me one level at a time and searches accessible folders across both locations. If a shared result also has a supported My Drive path, the picker keeps that path selection, so the same physical folder has one checkbox identity. My Drive paths that are ambiguous or have unrepresentable names keep the existing selection error. An unavailable saved ID stays visible and removable.

  • Exact syntax: folder://ID/**, with a case-sensitive Google folder ID of 1 to 128 letters, digits, underscores or hyphens. No wildcard IDs, alternate URI forms or suffixes are accepted. Invalid values use the existing 400 ACL_INVALID contract below.
  • Existing rules: My Drive paths and patterns, including **, keep their existing meaning. They do not start covering shared folders outside My Drive. API authors can use exact folder IDs for accessible folders in My Drive as well as Shared with me. Google Shared drives remain outside folder scopes.
  • Fresh checks: each request and each list page verifies the physical ancestry to the selected folder with current Google metadata. It need not read ancestors above that folder. An ID selection follows the same folder through rename or move while it remains accessible. A document moved outside the selection loses coverage on the next request. An inaccessible or deleted anchor grants nothing. A shortcut never grants access to its target.
  • Writes: creating folders and uploading files checks the destination. Updating a file that moves or renames it checks both current and resulting locations. Every required location must be covered by the same matching rule, which may contain several paths or folder IDs. Only when conditions also apply.
  • Display: confirmations show trusted file/folder names and accessible path context, independently of the folder ID used for authorization. A displayed name never grants access.
curl --get 'https://api.keycontain.com/p/https://www.googleapis.com/drive/v3/files' \
  -H 'Authorization: Bearer kc_live_…' \
  --data-urlencode "q='1SharedA' in parents" \
  --data-urlencode 'fields=files(id,name),nextPageToken'

The ID comes from the existing GET /v1/key ACL. The request, page token and Google response pass through unchanged. The existing scoped query grammar still applies. List a child folder with its own ID to continue browsing. There is no new public folder-discovery endpoint. An unrelated folder is refused with the existing 403 OPERATION_NOT_ALLOWED envelope.

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "error": {
    "code": "ACL_INVALID",
    "message": "This ACL isn't valid: \"folder://*/**\" isn't a folder path or pattern.",
    "service": "svc_account",
    "operation": "acl.update",
    "at": "/services/svc_drive_dana/rules/0/folders/0",
    "requestId": "req_01J9ZN5G5Q"
  }
}

IP limits

ips lists where the key may be used from. Each entry is one of:

  • an IPv4 or IPv6 address, such as 203.0.113.7 or 2001:db8::1,
  • a CIDR range, such as 198.51.100.0/24 or 2001:db8:10::/48,
  • the ID of one of the account's IP groups, such as ipg_office. An IP group is a named list of addresses and ranges, managed in the dashboard's Settings or through the management API (§14). Changing a group changes every key whose ips names it.
  • Without ips, or with an empty list, the key works from any address.
  • Otherwise the address the request comes from must match at least one entry, or the request is refused with 403 IP_NOT_ALLOWED before anything else about it is checked, except the key itself (§15). This applies to every use of the key: the proxy, git, the meta API and the management API.
  • The address is the one that connects to api.keycontain.com. X-Forwarded-For and similar headers are ignored. Through KeyContain through KeyContain (§7), the inner KeyContain sees the outer one's address, so limit the outer key.
  • Changing ips is a change to the ACL, so an agent that asks to change it through the management API uses acl.update, and the person sees the change as a diff (§14).
  • An entry that is not an address, a range or one of the account's IP group IDs is refused with 400 ACL_INVALID.
{
  "ips": ["ipg_office", "ipg_ci", "203.0.113.7"],
  "services": {
    "svc_gitlab_acme": {
      "rules": [
        { "operation": "repository.read", "access": "allow",
          "projects": ["acme/api", "acme/web"] },
        { "operation": "issues.read", "access": "allow",
          "projects": ["acme/api", "acme/web"] }
      ]
    }
  }
}

Key limits

All keys in the account share the account's limits (§11). limits gives one key limits of its own on top of those, so one busy agent can't use up the whole account.

FieldTypeMeaning
requestsPerMinutewhole number, optionalAt most this many requests with this key in any 60 seconds.
trafficPerMonthwhole number of bytes, optionalAt most this much traffic with this key in a calendar month (UTC). The dashboard shows it in MB or GB, with 1 MB = 1,048,576 bytes and 1 GB = 1,073,741,824 bytes, so 500 MB is 524288000.
  • Without limits, or with a field left out, the key has no limit of its own of that kind and can use as much of the account's limits as the other keys leave.
  • A request is refused when it would go over the account's limit or the key's own one, with 429 RATE_LIMITED or TRAFFIC_LIMIT_REACHED. The message says which limit was reached (§15).
  • A key's limits never give it more than the account has. A limit higher than the account's is accepted, and then only the account's limit applies.
  • They count the same requests and traffic as the account's limits, but only this key's (§11).
  • Changing limits is a change to the ACL, so an agent that asks to change it through the management API uses acl.update, and the person sees the change as a diff (§14). In the dashboard the key's header shows its limits, for example "Account limits only" or "Up to 50 requests a minute, 500 MB a month", with Change.
  • A field other than these two, or a value that is not a whole number of at least 1, is refused with 400 ACL_INVALID (§15).
{
  "limits": { "requestsPerMinute": 50, "trafficPerMonth": 524288000 },
  "services": {
    "svc_gitlab_acme": {
      "rules": [
        { "operation": "repository.read", "access": "allow", "projects": ["acme/api"] },
        { "operation": "issues.read", "access": "allow", "projects": ["acme/api"] }
      ]
    }
  }
}

Rule kinds

Paths in rules are relative to the service's base URL. Rules never repeat the domain. Every kind takes access allow or confirm, and optional where, and with confirm optional expiresAfter.

KindShapeAllows
Catalog permission{ "operation": "invoice.read", "access": "allow" }
optional scope on scoped permissions
Every request the permission covers (§12), within the rule's scope, narrowed by where. operation names one permission. For several, write one rule for each.
Method and path{ "method": "POST", "path": "/api/v4/projects/{project}/statuses/{sha}", "access": "allow" }
no scope, and not on Plaid
Requests with that method whose path matches. Methods: GET HEAD POST PUT PATCH DELETE.
git and path{ "git": "push", "path": "/acme/**", "access": "confirm" }
no scope
git fetches ("fetch") or pushes ("push") to repositories whose path matches, for example /acme/api.git.

Confirmation timeout on a rule

A rule with access confirm may carry "expiresAfter": how long a confirmation it causes waits for a person, as a number followed by m, h or d, from 15m to 7d. Without it, the account's default applies: 24 hours unless the person changed it in the dashboard's Settings (§10). expiresAfter on a rule with access allow, or outside that range, is refused with 400 ACL_INVALID.

{
  "operation": "repository.push",
  "access": "confirm",
  "projects": ["acme/api"],
  "where": { "git.ref": { "startsWith": "refs/heads/agent/" } },
  "expiresAfter": "1h"
}

Path patterns

  • A literal segment matches itself: /query.
  • {name} matches exactly one path segment, and its value is available to conditions as path.name: /reports/{report} matches /reports/ProfitAndLoss with path.report = ProfitAndLoss.
  • ** matches any remainder, including nothing: /api/v4/** matches /api/v4 and /api/v4/projects/42/issues.

Conditions

where is an object of field to condition. All conditions must hold for the rule to allow the request.

  • Numbers and strings. A value equals a number or a string with the same decimal form, so "61" matches 61 and 61 matches "61". This applies to literals, in and notIn.
  • Repeated query parameters. A query parameter that appears more than once in the request (?state=open&state=closed) makes every condition on it false, so KeyContain never guesses which value counts.
  • A field the request does not have makes every condition on it false.
ConditionHolds when the fieldExample
a literalequals the value"body.target_branch": "main", "git.force": false
{ "in": [...] }equals one of the values"path.report": { "in": ["ProfitAndLoss", "AgedReceivables"] }
{ "notIn": [...] }equals none of the values"body.impact": { "notIn": ["critical"] }
{ "startsWith": "..." }is a string starting with the value"git.ref": { "startsWith": "refs/heads/agent/" }
{ "matches": "..." }is a string matching the glob (* within a segment, ** across /)"git.repo": { "matches": "acme/*" }

Fields

FieldValue
path.{name}The segment matched by {name} in the rule's path.
query.{param}The query parameter's decoded value.
body.{dot.path}A value in a JSON request body, e.g. body.target_branch. KeyContain reads bodies up to 10 MiB for these conditions. If the body is larger, is not JSON, or cannot be parsed, every body. condition is false, so a rule that needs one does not match.
qbo.entityQuickBooks: the entity named in a query (Invoice in select * from Invoice). For a query KeyContain cannot attribute to exactly one entity, it is undefined and every condition on it is false.
qbo.actionQuickBooks: for a POST write, create, update or delete. A void (operation=void or include=void) has none, and every condition on it is false.
upload.fileCountQuickBooks upload: the number of files, one for each file_content_N part, e.g. 1.
upload.contentTypeQuickBooks upload, for each file: its content type from its metadata, or else from its part's Content-Type, in lowercase without parameters, e.g. image/jpeg or application/pdf. image/jpg reads as image/jpeg. A JPEG, PNG, GIF, WebP, TIFF or PDF file whose first bytes are of another kind has none.
upload.attachTo.typeQuickBooks upload, for each record a file attaches to: the record's type as QuickBooks names the entity, in any letter case the request uses, e.g. Purchase for an expense, Invoice or Bill. A file attached to no record has none.
upload.includeOnSendQuickBooks upload, for each file: true when it goes out with the emails of a record it attaches to (IncludeOnSend), otherwise false, also for a file attached to no record.
namecheap.commandNamecheap: the request's Command, from the query or a form body, in lowercase, e.g. namecheap.domains.dns.setcustom.
namecheap.domainNamecheap: the domain the command is about, from DomainName, or SLD and TLD joined with a dot, e.g. acme.dev. Undefined when the command names none.
fcm.targetFirebase Cloud Messaging: who a message goes to, token (one device), topic or condition (every device subscribed to topics). Undefined when the body names none or more than one.
git.repogit: the repository as owner/name without .git, e.g. acme/api.
git.refgit push: the ref being updated, e.g. refs/heads/agent/fix-retry. Evaluated once per ref update.
git.forcegit push: true if the update is not a fast-forward.
git.deletegit push: true if the update deletes the ref.

Uploads: every file and every record

A QuickBooks attachment upload can send several files, and each file can attach to several records. A condition on upload.contentType or upload.includeOnSend holds only when it holds for every file of the upload. A condition on upload.attachTo.type holds only when every file attaches to at least one record and the condition holds for every record of every file. So "upload.attachTo.type": "Purchase" means that everything the upload attaches to is an expense, and { "notIn": ["Invoice"] } means that nothing it attaches to is an invoice.

  • A file without a value makes every condition on that field false, as for any missing field: a file without a content type for upload.contentType, and a file attached to no record for upload.attachTo.type.
  • KeyContain reads an upload for these conditions only when no rule without them already allows it, and reads at most 10 MiB. A larger upload makes every upload. condition false.
  • An upload KeyContain can't read unambiguously is refused with 400 UPLOAD_UNREADABLE when its outcome depends on reading it: for these conditions, or to wait for confirmation (§12, §15).
  • The body of an upload isn't JSON, so body. conditions on attachable.upload are always false.

Which fields a rule can use

  • A catalog permission can use path., git., qbo. and upload. fields only where the catalog lists them for that permission (§12): the upload. fields only on attachable.upload. Every endpoint the permission covers has those fields, so a condition never silently fails on part of a permission. body. and query. fields can always be used.
  • A method and path rule can use path. fields named by its own placeholders, body. and query. fields, and on a QuickBooks service qbo.entity and qbo.action. A git rule can use the git. fields.
  • Which project, repository, zone, domain, app, bank account, folder or key a request is about is decided by the rule's scope, not by conditions.
  • Any other field is refused with 400 ACL_INVALID.

Evaluation

  1. If the ACL has ips, the request's address must match one of them (above). Then the request must be within the account's limits and the key's own limits (above).
  2. KeyContain selects the service (§7) and takes that service's rules from the key's ACL.
  3. It rewrites every catalog permission into method and git rules using the current catalog. This happens on every request, so catalog corrections apply to every key immediately. Rewritten rules carry "from", naming their permission, and "rule", the position of the written rule they came from. Method and path rules and git rules are used as they are.
  4. On GitLab, GitHub, Cloudflare, Porkbun, Namecheap, Google Cloud, Firebase and Play, App Store Connect, Plaid, Google Drive and the KeyContain account, it works out which project, repository, zone, domain, app, bank account, file or folder, or key a scoped request is about, resolving a GitLab project ID, a Cloudflare zone ID, a Google project number, an App Store Connect app, a Plaid statement's account or a Drive file's path if needed. A rule with a scope covers the request only if that is one of its entries. A rule without a scope covers every request.
  5. A rule matches when it covers the request, its method (or git action) and path match the request, and all of its conditions hold.
  6. If any rule with access allow matches, the request goes through directly, even if a rule with access confirm also matches.
  7. Otherwise, if any rule with access confirm matches, the request waits for a person's confirmation (§10).
  8. Otherwise it is refused. There are no deny rules: anything not covered is refused. The order of the rules makes no difference.
  9. For a push, each ref update is evaluated this way. If any ref update is refused, the whole push is refused. If none is refused and at least one needs confirmation, the whole push waits for one confirmation covering all its ref updates.

Examples

QuickBooks read-only

{
  "services": {
    "svc_qbo_acme": {
      "rules": [
        { "operation": "invoice.read", "access": "allow" },
        { "operation": "estimate.read", "access": "allow" },
        { "operation": "customer.read", "access": "allow" },
        { "operation": "vendor.read", "access": "allow" },
        { "operation": "bill.read", "access": "allow" },
        { "operation": "payment.read", "access": "allow" },
        { "operation": "item.read", "access": "allow" },
        { "operation": "account.read", "access": "allow" },
        { "operation": "purchase.read", "access": "allow" },
        { "operation": "journalentry.read", "access": "allow" },
        { "operation": "deposit.read", "access": "allow" },
        { "operation": "transfer.read", "access": "allow" },
        { "operation": "salesreceipt.read", "access": "allow" },
        { "operation": "creditmemo.read", "access": "allow" },
        { "operation": "billpayment.read", "access": "allow" },
        { "operation": "vendorcredit.read", "access": "allow" },
        { "operation": "attachable.read", "access": "allow" },
        { "operation": "employee.read", "access": "allow" },
        { "operation": "reports.run", "access": "allow" },
        { "operation": "companyinfo.read", "access": "allow" }
      ]
    }
  }
}

Each object's Read and running reports: every QuickBooks read permission, including Employees · Read. In the dashboard these 20 rows are one choice, Every Read in + Add permission.

Bookkeeping agent: two rules for new invoices

{
  "ips": ["ipg_office"],
  "services": {
    "svc_qbo_acme": {
      "rules": [
        { "operation": "invoice.read", "access": "allow" },
        { "operation": "invoice.create", "access": "allow",
          "where": { "body.CustomerRef.value": { "in": ["61", "74"] } } },
        { "operation": "invoice.create", "access": "confirm" },
        { "operation": "invoice.update", "access": "confirm" },
        { "operation": "estimate.read", "access": "allow" },
        { "operation": "customer.read", "access": "allow" },
        { "operation": "customer.update", "access": "confirm",
          "where": { "body.Active": true } },
        { "operation": "vendor.read", "access": "allow" },
        { "operation": "bill.read", "access": "allow" },
        { "operation": "payment.read", "access": "allow" },
        { "operation": "item.read", "access": "allow" },
        { "operation": "account.read", "access": "allow" },
        { "operation": "reports.run", "access": "allow" },
        { "operation": "companyinfo.read", "access": "allow" }
      ]
    }
  }
}

Reads go through directly. How a new invoice is handled depends on its customer (rules are counted from 0, as GET /v1/key counts them):

  • An invoice for customer 61 ("CustomerRef": { "value": "61" }) matches rule 1, which allows it, so it goes through directly. Rule 2 also matches, but an allow match wins.
  • An invoice for customer 58 doesn't match rule 1, which covers only 61 and 74. It matches rule 2, so it waits for Dana to approve it.

Updating an invoice, and updating a customer that stays active, also wait for Dana. Deactivating a customer (body.Active = false), deleting anything, or any other write is refused outright. In the dashboard these are the 14 rows of the QuickBooks service, two of them for Invoices · Create: Allowed only when body.CustomerRef.value is one of 61, 74, and Ask me for any request.

Receipts agent: one image to an expense goes straight through

{
  "services": {
    "svc_qbo_acme": {
      "rules": [
        { "operation": "purchase.read", "access": "allow" },
        { "operation": "attachable.upload", "access": "allow",
          "where": { "upload.attachTo.type": "Purchase",
                     "upload.fileCount": 1,
                     "upload.contentType": { "startsWith": "image/" } } },
        { "operation": "attachable.upload", "access": "confirm" }
      ]
    }
  }
}

Rule 1 allows an upload of one image attached only to expenses. Every other upload matches only rule 2 and waits for Dana:

  • staples-receipt-2026-10-08.jpg (image/jpeg) attached to Expense 123 matches rule 1 and goes through directly.
  • Two files in one upload, hilton-chicago-folio-1006.pdf to Expense 131 and uber-receipt-2026-10-06.png to Expense 132, has upload.fileCount 2, so it waits. Sent in an upload of its own, the Uber receipt would go through, and the folio would still wait, since it is a PDF.
  • One image attached to Expense 123 and Invoice 1044 waits, since one of its records isn't an expense.
  • One image attached to no record waits, since upload.attachTo.type has no value.
  • A file sent as image/png whose first bytes are a PDF has no content type, so it waits.

In the dashboard these are two rows for Attachments · Upload: Allowed only when upload.attachTo.type = Purchase, upload.fileCount = 1 and upload.contentType starts with image/, and Ask me for any upload. The Only when popover of an Attachments · Upload row suggests the four upload. fields, each with what it reads, and says that a condition on files or records must hold for every one.

{ "operation": "attachable.upload", "access": "allow",
  "where": { "upload.attachTo.type": { "in": ["Purchase", "Bill"] },
             "upload.includeOnSend": false } }

Ops agent: QuickBooks and a self-managed GitLab

{
  "svc_qbo_acme": { "type": "quickbooks", "label": "Acme Holdings",
                    "baseUrl": "https://quickbooks.api.intuit.com/v3/company/9130354",
                    "intuitApp": "keycontain", "pricePerRead": "0.01" },
  "svc_gitlab_acme": { "type": "gitlab", "label": null,
                       "baseUrl": "https://code.acme.dev" }
}
{
  "ips": ["ipg_office", "ipg_ci", "203.0.113.7"],
  "limits": { "requestsPerMinute": 50, "trafficPerMonth": 524288000 },
  "services": {
    "svc_qbo_acme": {
      "rules": [
        { "operation": "invoice.read", "access": "allow" },
        { "operation": "customer.read", "access": "allow" },
        { "operation": "reports.run", "access": "allow",
          "where": { "path.report": { "in": ["ProfitAndLoss", "AgedReceivables"] } } }
      ]
    },
    "svc_gitlab_acme": {
      "rules": [
        { "operation": "project.read", "access": "allow",
          "projects": ["acme/api", "acme/web"] },
        { "operation": "repository.read", "access": "allow",
          "projects": ["acme/api", "acme/web"] },
        { "operation": "repository.read", "access": "allow",
          "projects": ["acme/docs"] },
        { "operation": "repository.push", "access": "confirm",
          "projects": ["acme/api", "acme/web"],
          "where": { "git.ref": { "startsWith": "refs/heads/agent/" },
                     "git.force": false, "git.delete": false },
          "expiresAfter": "1h" },
        { "operation": "issues.read", "access": "allow",
          "projects": ["acme/api", "acme/web"] },
        { "operation": "issues.read", "access": "allow",
          "projects": ["acme/docs"] },
        { "operation": "issues.create", "access": "allow",
          "projects": ["acme/docs"] },
        { "operation": "merge_requests.read", "access": "allow",
          "projects": ["acme/api", "acme/web"] },
        { "operation": "merge_requests.create", "access": "allow",
          "projects": ["acme/api", "acme/web"],
          "where": { "body.target_branch": "main" } },
        { "operation": "comments.read", "access": "allow",
          "projects": ["acme/api", "acme/web"] },
        { "operation": "comments.create", "access": "allow",
          "projects": ["acme/api", "acme/web"] },
        { "operation": "pipelines.read", "access": "allow",
          "projects": ["acme/api", "acme/web"] },
        { "method": "POST", "path": "/api/v4/projects/{project}/statuses/{sha}",
          "access": "allow",
          "where": { "body.context": "ops-agent" } }
      ]
    }
  }
}
curl -X POST https://api.keycontain.com/p/https://code.acme.dev/api/v4/projects/42/merge_requests \
  -H "Authorization: Bearer kc_live_7Hq2mV9xPz4Lw8NcT3bR6yKd1Fs5c21e" \
  -H "Content-Type: application/json" \
  -d '{ "source_branch": "agent/fix-retry", "target_branch": "main", "title": "Retry failed webhook deliveries" }'
# service:  svc_gitlab_acme (the URL starts with https://code.acme.dev)
# path:     /api/v4/projects/42/merge_requests
# project:  42 is acme/api, which the rule's projects cover
# matches:  rules[8], merge_requests.create where body.target_branch = "main", access allow

On acme/api and acme/web, this key reads, comments and opens merge requests into main, and its pushes to agent/ branches wait for a person, for at most an hour. On acme/docs it may only read and open issues. On any project it may set commit statuses named ops-agent, through the method and path rule. It cannot create QuickBooks records, merge merge requests, push to main, force-push, delete branches or list projects, and it works only from the Office and CI runners IP groups and 203.0.113.7. Of the account's capacity it may use at most 50 requests a minute and 500 MB a month.

GitHub: push release branches and open pull requests, never merge

{
  "services": {
    "svc_github_acme": {
      "rules": [
        { "operation": "contents.read", "access": "allow",
          "repositories": ["acme-inc/api"] },
        { "operation": "contents.push", "access": "allow",
          "repositories": ["acme-inc/api"],
          "where": { "git.ref": { "startsWith": "refs/heads/release/" },
                     "git.force": false } },
        { "operation": "pulls.read", "access": "allow",
          "repositories": ["acme-inc/api"] },
        { "operation": "pulls.create", "access": "allow",
          "repositories": ["acme-inc/api"],
          "where": { "body.base": "main" } }
      ]
    }
  }
}

pulls.merge has no rule, so merging is refused. Each rule's scope keeps it to acme-inc/api, for REST calls and git alike. "repositories": ["acme-inc/*"] would cover every repository of acme-inc.

Statements bot: bank statements filed in Google Drive

{
  "services": {
    "svc_plaid_fifththird": {
      "rules": [
        { "operation": "statements.list", "access": "allow" },
        { "operation": "statements.download", "access": "allow" },
        { "operation": "transactions.read", "access": "allow" },
        { "operation": "statements.refresh", "access": "allow" }
      ]
    },
    "svc_plaid_chase": {
      "rules": [
        { "operation": "statements.list", "access": "allow" },
        { "operation": "statements.download", "access": "allow" },
        { "operation": "transactions.read", "access": "allow" },
        { "operation": "liabilities.read", "access": "allow",
          "accounts": ["rz99ex9ZQotvnjXdgQLEsR81e3ArPgulVWjGj"] },
        { "operation": "statements.refresh", "access": "allow" }
      ]
    },
    "svc_plaid_discover": {
      "rules": [
        { "operation": "statements.list", "access": "allow" },
        { "operation": "statements.download", "access": "allow" },
        { "operation": "transactions.read", "access": "allow" },
        { "operation": "liabilities.read", "access": "allow",
          "accounts": ["Ld8WVVnmzXcVXbPg9qKPHKyQo1R6ZWcVA6vMn"] },
        { "operation": "statements.refresh", "access": "allow" }
      ]
    },
    "svc_drive_dana": {
      "rules": [
        { "operation": "folders.create", "access": "allow",
          "folders": ["Bank statements/**"] },
        { "operation": "files.upload", "access": "allow",
          "folders": ["Bank statements/**"] },
        { "operation": "files.list", "access": "allow",
          "folders": ["Bank statements/**"] }
      ]
    }
  }
}

Every month the Statements bot fetches each bank's new statements, downloads them and files them in Dana's Drive. On every bank it may list, refresh and download statements and read transactions, and on the two credit cards it may also read liabilities, such as the statement balance and due date. In Drive it may create folders and upload files inside the existing Bank statements folder, and list its children and each descendant folder's children. Dana supplies the existing folder ID to the agent.

  • Liabilities of Sapphire Preferred (/liabilities/get on svc_plaid_chase with "options": { "account_ids": ["rz99ex9ZQotvnjXdgQLEsR81e3ArPgulVWjGj"] }) matches rule 3 of svc_plaid_chase. The same request without account_ids is about every Chase account, and no rule for Liabilities · Read leaves out accounts, so it is refused.
  • A folder Bank statements/Fifth Third Bank matches Bank statements/**. The existing Bank statements folder itself is outside that scope. A folder Taxes at the top of My Drive does not match, so creating it is refused.
  • Refreshing statements starts as Ask me in the dashboard, since Plaid's paid plans charge for each statement in the range. Dana set it to Allowed.
  • Chase isn't connected right now, so the key's Chase requests get 502 UPSTREAM_CREDENTIAL_FAILED until Dana reconnects it (§8).

In the dashboard these are 16 rows on four services. A Liabilities · Read row shows its card in the Accounts column, and a connection permission shows "Whole connection" there.

# 1. Ask for September's statements (statements.refresh, rule 3)
curl -X POST https://api.keycontain.com/p/https://production.plaid.com/statements/refresh \
  -H "Authorization: Bearer kc_live_7St3Mb9Qw2Lx5Rv8Nc4Yd1Hk6Tp0st3m" \
  -H "Content-Type: application/json" \
  -d '{ "access_token": "svc_plaid_fifththird", "start_date": "2026-09-01", "end_date": "2026-09-30" }'

# 2. See which months each account has (statements.list, rule 0)
curl -X POST https://api.keycontain.com/p/https://production.plaid.com/statements/list \
  -H "Authorization: Bearer kc_live_7St3Mb9Qw2Lx5Rv8Nc4Yd1Hk6Tp0st3m" \
  -H "Content-Type: application/json" \
  -d '{ "access_token": "svc_plaid_fifththird" }'

# 3. Download Momentum Checking's September statement (statements.download, rule 1)
curl -X POST https://api.keycontain.com/p/https://production.plaid.com/statements/download \
  -H "Authorization: Bearer kc_live_7St3Mb9Qw2Lx5Rv8Nc4Yd1Hk6Tp0st3m" \
  -H "Content-Type: application/json" \
  -d '{ "access_token": "svc_plaid_fifththird", "statement_id": "eYupqX1mZkEUs1yRbgqBTyNmrYlD7ZtqWp4nB" }' \
  -o "2026-09 Momentum Checking.pdf"

# 4. List children of the existing Bank statements folder, whose ID Dana supplied
curl -G "https://api.keycontain.com/p/https://www.googleapis.com/drive/v3/files" \
  -H "Authorization: Bearer kc_live_7St3Mb9Qw2Lx5Rv8Nc4Yd1Hk6Tp0st3m" \
  --data-urlencode "q='1Kx7qPZ0bSf3YwTn8Hc2LdEu9VaR' in parents and trashed = false" \
  --data-urlencode 'fields=nextPageToken,files(id,name,mimeType)'

# 5. Create Bank statements/Fifth Third Bank (folders.create, Bank statements/**)
curl -X POST https://api.keycontain.com/p/https://www.googleapis.com/drive/v3/files \
  -H "Authorization: Bearer kc_live_7St3Mb9Qw2Lx5Rv8Nc4Yd1Hk6Tp0st3m" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Fifth Third Bank", "mimeType": "application/vnd.google-apps.folder", "parents": ["1Kx7qPZ0bSf3YwTn8Hc2LdEu9VaR"] }'

# 6. Prepare Google's multipart/related body, using the folder ID returned by step 5
python3 - <<'PYBODY'
from pathlib import Path
metadata = b'{"name":"2026-09 Momentum Checking.pdf","parents":["1Qm4Vt8Zr2Lk6Hw0Yp3Nc5Bd7Xs9Jf1Ga"]}'
body = b'--statement_boundary\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n' + metadata
body += b'\r\n--statement_boundary\r\nContent-Type: application/pdf\r\n\r\n'
body += Path('2026-09 Momentum Checking.pdf').read_bytes() + b'\r\n--statement_boundary--\r\n'
Path('statement.multipart').write_bytes(body)
PYBODY

# Upload it (files.upload, Bank statements/**)
curl -X POST "https://api.keycontain.com/p/https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart" \
  -H "Authorization: Bearer kc_live_7St3Mb9Qw2Lx5Rv8Nc4Yd1Hk6Tp0st3m" \
  -H 'Content-Type: multipart/related; boundary=statement_boundary' \
  --data-binary @statement.multipart

For more results, repeat step 4 with Google's pageToken from nextPageToken and the same query. To browse deeper, repeat it with a child folder's ID. For an ID-based scope, the ACL already contains the first folder ID (§9). For a path-based scope, Dana can supply it from Drive's URL, or allow an unscoped files.list rule for Google's normal name search. KeyContain adds no folder-discovery API. See Google's search guide and multipart upload guide.

KeyContain finds the new folder's path from its parent, 1Kx7qPZ0bSf3YwTn8Hc2LdEu9VaR, which is Bank statements at the top of My Drive, and the upload's path, Bank statements/Fifth Third Bank/2026-09 Momentum Checking.pdf, from the folder created in step 5, 1Qm4Vt8Zr2Lk6Hw0Yp3Nc5Bd7Xs9Jf1Ga.

Generic HTTP: read everything, open incidents unless critical

{
  "services": {
    "svc_statuspage": {
      "rules": [
        { "operation": "all.read", "access": "allow" },
        { "method": "POST", "path": "/v1/incidents", "access": "allow",
          "where": { "body.impact": { "notIn": ["critical"] } } }
      ]
    }
  }
}

A KeyContain service

{
  "services": {
    "svc_partner_gitlab": {
      "rules": [
        { "method": "GET", "path": "/api/v4/projects/{project}/issues/**", "access": "allow" },
        { "git": "fetch", "path": "/**", "access": "allow" }
      ]
    }
  }
}

Here svc_partner_gitlab is a KeyContain service with base URL https://api.keycontain.com/p/https://gitlab.com. Paths are relative to it. The inner key's own rules still apply at the inner hop.

Rewritten rules

The meta API reports the rules KeyContain evaluates as "effective": one flat list per service, in the order of the written rules. A rule for a permission becomes one rewritten rule per endpoint the permission covers, with the rule's access, scope and conditions, plus "from", naming the permission, and "rule", the position of the written rule it came from. "rule" tells apart several rules for one permission. A method and path rule or git rule appears as written, with its "rule". For the Ops agent's QuickBooks service:

{
  "svc_qbo_acme": {
    "rules": [
      { "method": "GET", "path": "/invoice/{id}", "access": "allow",
        "from": "invoice.read", "rule": 0 },
      { "method": "GET", "path": "/query", "access": "allow",
        "where": { "qbo.entity": "Invoice" }, "from": "invoice.read", "rule": 0 },
      { "method": "GET", "path": "/customer/{id}", "access": "allow",
        "from": "customer.read", "rule": 1 },
      { "method": "GET", "path": "/query", "access": "allow",
        "where": { "qbo.entity": "Customer" }, "from": "customer.read", "rule": 1 },
      { "method": "GET", "path": "/reports/{report}", "access": "allow",
        "where": { "path.report": { "in": ["ProfitAndLoss", "AgedReceivables"] } },
        "from": "reports.run", "rule": 2 }
    ]
  }
}
{
  "svc_qbo_acme": {
    "rules": [
      { "method": "GET", "path": "/invoice/{id}", "access": "allow",
        "from": "invoice.read", "rule": 0 },
      { "method": "GET", "path": "/query", "access": "allow",
        "where": { "qbo.entity": "Invoice" }, "from": "invoice.read", "rule": 0 },
      { "method": "POST", "path": "/invoice", "access": "allow",
        "where": { "qbo.action": "create", "body.CustomerRef.value": { "in": ["61", "74"] } },
        "from": "invoice.create", "rule": 1 },
      { "method": "POST", "path": "/invoice", "access": "confirm",
        "where": { "qbo.action": "create" }, "from": "invoice.create", "rule": 2 },
      { "method": "POST", "path": "/invoice", "access": "confirm",
        "where": { "qbo.action": "update" }, "from": "invoice.update", "rule": 3 },
      { "method": "GET", "path": "/estimate/{id}", "access": "allow",
        "from": "estimate.read", "rule": 4 },
      { "method": "GET", "path": "/query", "access": "allow",
        "where": { "qbo.entity": "Estimate" }, "from": "estimate.read", "rule": 4 }
    ]
  }
}

Each rewritten rule carries the permission's own conditions (such as qbo.action) together with the rule's where. On the types with a scope it also keeps the rule's projects, repositories, zones, domains, apps, accounts, folders or keys, and expiresAfter when the rule has one. On Google Cloud, Firebase and Play, whose endpoints are on six hosts, each rewritten rule also carries "baseUrl", the base URL of its endpoint's host, such as "baseUrl": "https://fcm.googleapis.com", and matches only requests to that host. The Bookkeeping agent's other rules follow the same pattern.

10Confirmations

A request matched only by rules with access confirm (§9) goes through only after a person approves it. KeyContain notifies the person, holds the request for up to 90 seconds, and either forwards it, refuses it, or tells the agent to come back after approval.

Only a person decides. Approving or declining happens only in the dashboard at https://app.keycontain.com, by a person signed in to the account. KeyContain sends a push notification to the KeyContain app on their phone, or to their desktop browser. The notification only links to the request. No KeyContain key and no API endpoint can approve or decline. A key can only read the status of its own confirmations (§13).

Describing the request

A request that needs confirmation must say which agent is asking, what it is for and why it is necessary, in three request headers. The person sees them, labeled as the agent's words, next to the full request.

HeaderMeaning
KeyContain-AgentWhich agent is asking, an identifier the caller chooses, e.g. invoice-runner-2. Useful when several agents share one KeyContain key. The person sees "invoice-runner-2 using Bookkeeping agent", and the push notification names it.
KeyContain-PurposeWhat the call is for, e.g. Invoice Northwind Traders for October consulting.
KeyContain-ReasonWhy it is necessary, e.g. The engagement letter bills monthly, and 10 hours were logged in October.
  • All three are text: KeyContain-Agent at most 200 characters, KeyContain-Purpose and KeyContain-Reason at most 1,000 characters each. Non-ASCII text is sent as percent-encoded UTF-8 (Caf%C3%A9 Ouest for Café Ouest). KeyContain decodes it before counting and showing it.
  • KeyContain always removes all three headers before forwarding, on every request, so they never reach the upstream service (§7).
  • On requests that do not need confirmation they are optional. When sent, the dashboard's activity log records and shows them, so KeyContain-Agent on every request tells you which agent made it.
  • If a request needs confirmation and any of the three is missing or empty, KeyContain refuses it at once with 400 DESCRIPTION_REQUIRED and nobody is notified. A header over its length limit or with invalid percent-encoding gets 400 DESCRIPTION_INVALID (§15).
git -c http.extraHeader="KeyContain-Agent: release-runner" \
    -c http.extraHeader="KeyContain-Purpose: Ship the webhook retry fix" \
    -c http.extraHeader="KeyContain-Reason: Failed deliveries are piling up, and the fix is reviewed in !418" \
    push origin agent/fix-retry

While a person decides

KeyContain creates a confirmation (cnf_…), notifies the person, and holds the agent's request open for up to 90 seconds. One of three things happens:

Approved within 90 seconds

KeyContain forwards the request and returns the upstream response unchanged, with KeyContain-Confirmation added.

HTTP/1.1 200 OK
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZM2K7Q
KeyContain-Confirmation: cnf_7Q2MV9XB; approved

{ "Invoice": { "Id": "1044", "TotalAmt": 1250.00 }, "time": "2026-10-05T07:20:51-07:00" }

Declined within 90 seconds

HTTP/1.1 403 Forbidden
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZM2K7Q

{
  "error": {
    "code": "CONFIRMATION_DENIED",
    "message": "A person declined this request in the KeyContain dashboard.",
    "service": "svc_qbo_acme",
    "operation": "invoice.create",
    "confirmation": "cnf_7Q2MV9XB",
    "requestId": "req_01J9ZM2K7Q"
  }
}

No decision within 90 seconds

HTTP/1.1 403 Forbidden
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZM2K7Q

{
  "error": {
    "code": "CONFIRMATION_REQUIRED",
    "message": "This request needs a person's confirmation in the KeyContain dashboard. After it is approved, send the identical request again before 2026-10-06T14:20:11Z. Check its status with GET https://api.keycontain.com/v1/confirmations/cnf_7Q2MV9XB.",
    "service": "svc_qbo_acme",
    "operation": "invoice.create",
    "confirmation": "cnf_7Q2MV9XB",
    "statusUrl": "https://api.keycontain.com/v1/confirmations/cnf_7Q2MV9XB",
    "expiresAt": "2026-10-06T14:20:11Z",
    "requestId": "req_01J9ZM2K7Q"
  }
}

After CONFIRMATION_REQUIRED

  1. The confirmation stays pending until the person decides, or until it expires at expiresAt: its creation time plus its timeout. The timeout is the matching rule's expiresAfter (§9), or otherwise the account's default, 24 hours unless the person changed it. If several rules with access confirm match, the longest timeout applies.
  2. The agent checks it with GET on statusUrl (§13).
  3. Once it is approved, the agent sends the identical request again before expiresAt. Approving does not move expiresAt. Identical means the same KeyContain key, method, upstream URL and body (for a git push, the same ref updates, below). KeyContain-Agent, KeyContain-Purpose and KeyContain-Reason are not part of the comparison. KeyContain forwards it without asking again, adds KeyContain-Confirmation: cnf_…; approved to the response, and the confirmation becomes used.
  4. An approval lets exactly one request through. Any other resend (while the confirmation is still pending, or after it was declined, used or expired) is a new request: it creates a new confirmation and a new notification.
StatusMeaning
pendingWaiting for a person until expiresAt.
approvedA person approved it. The identical request goes through once before expiresAt.
usedThe approved request went through.
deniedA person declined it.
expiredNobody decided before expiresAt, or an approval was not used before it.

git pushes

KeyContain reads the push's ref updates, then holds the push the same way. git has no HTTP timeout by default, so it simply waits. The same KeyContain-Agent, KeyContain-Purpose and KeyContain-Reason headers are required (above).

remote: KeyContain: push confirmed (cnf_3HD9QW1L).
To https://api.keycontain.com/p/https://code.acme.dev/acme/api.git
   9f2c1e4..b71a0d3  agent/fix-retry -> agent/fix-retry
remote: KeyContain: a person declined this push (cnf_3HD9QW1L).
To https://api.keycontain.com/p/https://code.acme.dev/acme/api.git
 ! [remote rejected] agent/fix-retry -> agent/fix-retry (declined in KeyContain)
error: failed to push some refs to 'https://api.keycontain.com/p/https://code.acme.dev/acme/api.git'
remote: KeyContain: this push needs a person's confirmation (cnf_3HD9QW1L).
remote: Approve it in the KeyContain dashboard, then run the same push again before 2026-10-06T14:12:40Z.
To https://api.keycontain.com/p/https://code.acme.dev/acme/api.git
 ! [remote rejected] agent/fix-retry -> agent/fix-retry (awaiting confirmation)
error: failed to push some refs to 'https://api.keycontain.com/p/https://code.acme.dev/acme/api.git'

"The same push" means the same ref updates, each the same ref with the same old and new commit IDs, to the same repository, with the same key. The pack itself is not compared: git may build it differently on a second run, and the new commit IDs already fix its content. A push that needs confirmation but has no descriptions is refused in git's protocol (§15).

How large a held request can be

KeyContain keeps a request in full while it waits for a person, so a request that needs confirmation has a size limit. Proxied requests that do not need confirmation have none, apart from the traffic limits (§11).

  • A proxied REST request that needs confirmation can have a body of at most 10 MiB.
  • A management API call (§14) can have a body of at most 1 MiB, whether or not it needs confirmation.
  • A git push that needs confirmation can have a pack of at most 100 MiB.
  • An account's waiting requests can hold at most 1 GB in all (§11). A REST request that needs confirmation and would take the account over it gets 503 TOO_MANY_WAITING, and nobody is notified. It can be sent again once the person has decided on waiting requests.
  • KeyContain also limits how much it keeps waiting at once across all accounts. When that is full, a REST request that needs confirmation gets 503 TOO_MANY_WAITING, and a git push gets a remote: line saying too many pushes are waiting. Nobody is notified. Try again in a minute.
  • A larger one is refused at once with 413 REQUEST_TOO_LARGE (for git, in remote: lines), and nobody is notified (§15).
remote: KeyContain: this push is too large to wait for confirmation (req_01J9ZR2T6K).
remote: A push that needs confirmation can have a pack of at most 100 MiB. Push fewer commits at a time.
To https://api.keycontain.com/p/https://code.acme.dev/acme/api.git
 ! [remote rejected] agent/assets -> agent/assets (too large to confirm)
error: failed to push some refs to 'https://api.keycontain.com/p/https://code.acme.dev/acme/api.git'

11Limits and capacity

Every account has one pool of requests a minute and traffic a month, shared by all its keys. Keys are free, and an account can have any number of them. The pool has a free allowance, so anyone can try KeyContain without paying, and capacity units at $0.50 a month each make it larger. A key can also have limits of its own (§9). QuickBooks Online reads through KeyContain's Intuit app cost $0.01 each, also taken from the balance (below). Requests waiting for confirmation can hold at most 1 GB per account (below).

CapacityRequestsTrafficCost
No units, the free allowance10 a minute100 MB a monthNothing
1 unit50 a minute1 GB a month$0.50 a month, about $0.0164 a day
2 units100 a minute2 GB a month$1 a month, about $0.0329 a day
n units50 × n a minuten GB a month$0.50 × n a month, taken day by day from the balance

Every limit in the table is for the whole account: all its keys together. 1 MB is 1,048,576 bytes and 1 GB is 1,073,741,824 bytes.

What counts

  • Every request made with a key counts toward the account's limits, and toward the key's own limits if it has any: proxied requests, git, refused requests (including requests refused for a limit), and calls to KeyContain's own meta and management APIs. Two kinds of refusal don't count: KEY_INVALID, since no key is known, and IP_NOT_ALLOWED, so someone holding a leaked key at another address can't use up the account's requests or traffic.
  • Traffic is the bytes KeyContain receives from the caller plus the bytes it sends back, headers and bodies included.
  • A request counts when it arrives. A request held for confirmation counts when it arrives, and its resend after approval counts again.
  • A request already in progress when a limit is reached finishes. The next one is refused.
  • Request limits are over the last 60 seconds. Traffic is counted per calendar month in UTC and resets on the 1st at 00:00 UTC, for the account and for every key.
  • Pairing needs no key, so it has its own limits per IP address instead (§4).

Capacity units

The account's limits come from the number of units it has right now, and its traffic limit is measured against all its keys' traffic so far this month. A person adds or removes units in the dashboard's Settings, under Billing, at any time. An agent does it with capacity.update, whose rule in the key's ACL can have access allow or confirm like any other permission (§14).

  • Adding units raises the limits at once and takes today's charge for the added units from the balance. If the balance can't pay it, the change is refused with 402 PAYMENT_REQUIRED and nothing changes.
  • Removing units lowers the limits at once. Traffic already used this month still counts, and today's charge is not refunded. Removing units always works.
  • An account can have at most 1,000 units: 50,000 requests a minute and 1,000 GB a month, for $500 a month.
Example. An account has no units, and its keys have used 60 MB this month. On October 5 the person adds 1 unit: from then on its keys share 50 requests a minute and 1 GB for October, of which 60 MB is used, and $0.0164 is taken from the balance at once.
Example. An account has 5 units, so its keys share 250 requests a minute and 5 GB a month, for $2.50 a month. They have used 4.5 GB this month. The person removes one unit: the limit is now 4 GB, already used up, so every key's requests get TRAFFIC_LIMIT_REACHED until November 1, unless a unit is added again.

Limits of one key

  • A key's ACL can give it limits of its own, requestsPerMinute and trafficPerMonth in limits next to ips (§9). A key without them can use as much of the account's limits as the other keys leave.
  • A request is refused when it would go over the account's limit or the key's own one. The message starts with "This key's limit" or "Your account's limit", and limit in the error is key or account (§15). When both are reached, the error names the account's limit, and retryAfter is when both allow a request again.
Example. An account has 5 units: 250 requests a minute for all keys. The Ops agent has its own limit of 50 requests a minute and 500 MB a month. Its 51st request in a minute gets RATE_LIMITED with "This key's limit of 50 requests a minute is used up", while the other keys keep working. If all keys together make 250 requests in a minute, every key's next request gets "Your account's limit of 250 requests a minute, shared by all its keys, is used up".

Requests waiting for confirmation

  • Each account can keep at most 1 GB of requests waiting for confirmation, for all its keys together: the bodies of its requests that wait for a person or for their resend after approval, files of waiting uploads included. Each request counts with the size of its body. A git push keeps only its ref updates, so pushes don't count.
  • A request that needs confirmation and would take the account over 1 GB is refused with 503 TOO_MANY_WAITING before anyone is notified (§15). The space comes back as soon as waiting requests are declined, used or expire.
  • Capacity units don't change it. A single request that waits can still be at most 10 MiB (§10).

The balance

  • The account has a balance in US dollars. A person adds funds in the dashboard with a card ($10, $25, $50, $100, or another amount of at least $5), or turns on automatic top-up (§14). Funds can't be added through the API.
  • Each unit costs $0.50 a month, taken as $6/365 for every day (UTC) the account has it. The charge for added units is taken when they are added, and at 00:00 UTC the day's charge is taken for every unit the account has then. Each day is charged for the most units the account had that day, so removing a unit and adding it back on the same day costs nothing more. The free allowance costs nothing.
  • If the balance can't pay a day's charge for all the units, the account drops to the free allowance: its keys keep working within 10 requests a minute and 100 MB a month, and no request is refused for payment apart from priced QuickBooks reads (below). Traffic already used this month counts against the free allowance too, so an account that has used more than 100 MB this month gets TRAFFIC_LIMIT_REACHED until it is paid again. The units stay set and cost nothing while unpaid. When a person adds funds, that day's charge is taken and the units count again. The person is warned by push notification and in the dashboard when the balance has 30 days, 7 days and 1 day left, and when the account drops to the free allowance.

Priced QuickBooks reads

A QuickBooks Online service connects through KeyContain's own Intuit app, the default, or through the customer's own Intuit app (§8). Reads through KeyContain's Intuit app have a price.

  • $0.01 for each successful read: a GET or HEAD, including queries and reports, that QuickBooks answers with a 2xx status. It is taken from the account's balance as soon as the read is answered, whichever rule allowed it.
  • Free: writes, requests that fail or are refused, and every request to a service connected through the customer's own Intuit app. Every request still counts toward the limits (above).
  • When the balance can't pay for a read, reads through KeyContain's Intuit app are refused with 402 PAYMENT_REQUIRED before they reach QuickBooks, until a person adds funds in the dashboard (§15). Automatic top-up, when it is on, is tried first. Writes, services on the customer's own Intuit app, every other service and KeyContain's own APIs keep working, and capacity is charged as before.
  • An agent finds a service's Intuit app and price in GET /v1/key, as intuitApp and pricePerRead (§13), and the account's priced reads this month in GET /v1/billing, as readsThisMonth (§14).
{
  "svc_qbo_acme": { "type": "quickbooks", "label": "Acme Holdings",
                    "baseUrl": "https://quickbooks.api.intuit.com/v3/company/9130354",
                    "intuitApp": "keycontain", "pricePerRead": "0.01",
                    "available": true },
  "svc_qbo_northwind": { "type": "quickbooks", "label": "Northwind Traders",
                         "baseUrl": "https://quickbooks.api.intuit.com/v3/company/4620816",
                         "intuitApp": "own", "available": true }
}

Seeing usage

KeyContain adds no limit headers to responses, so upstream responses stay unchanged. A key reads the account's limits and usage and its own from GET /v1/key (§13), and a key with billing.read reads the units, the balance and this month's priced reads from GET /v1/billing (§14). Over a limit, the response is 429 with a Retry-After header in seconds (§15).

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 18
KeyContain-Request-Id: req_01J9ZQ7B2C

{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Your account's limit of 10 requests a minute, shared by all its keys, is used up. Try again in 18 seconds, or add capacity in the KeyContain dashboard.",
    "limit": "account",
    "retryAfter": 18,
    "requestId": "req_01J9ZQ7B2C"
  }
}

12Permission catalog

The v1 catalog, grouped by object, the way the dashboard's + Add permission picker shows it. Each permission is about one object and covers the endpoints listed. Paths are relative to the service's base URL. On Google Cloud, Firebase and Play, each endpoint is written with the Google host it is on, and its path is relative to that host.

  • Scoped permissions are about one project, repository, zone, domain, app, bank account, file or folder, or key, and follow the scope of their rule. Service-level ones are about the whole service, and their rules can't have a scope (§9).
  • Fields are the path., git., qbo. and upload. fields a condition on that permission can use. Every endpoint of the permission has them. body. and query. fields can be used on any permission (§9). Some useful body fields are named for convenience.
  • Confirm only marks a permission whose rules need access confirm.

QuickBooks Online

No scope: one service is one company, so every permission is service-level. On a service connected through KeyContain's Intuit app, every successful GET or HEAD costs $0.01, whichever permission or rule allows it (§11).

ObjectPermissionOperationEndpointsFields
Invoices
Estimates
Customers
Vendors
Bills
Payments
Items
Accounts
Expenses
Journal entries
Deposits
Transfers
Sales receipts
Credit memos
Bill payments
Vendor credits
Read{entity}.readGET/{entity}/{id}
GET/query where qbo.entity = {Entity}
Create{entity}.createPOST/{entity} where qbo.action = create
Update{entity}.updatePOST/{entity} where qbo.action = update
Delete{entity}.deletePOST/{entity} where qbo.action = delete
AttachmentsReadattachable.readGET/attachable/{id}
GET/query where qbo.entity = Attachable
GET/download/{id}
Createattachable.createPOST/attachable where qbo.action = create
Uploadattachable.uploadPOST/uploadupload.fileCount
upload.contentType
upload.attachTo.type
upload.includeOnSend
Updateattachable.updatePOST/attachable where qbo.action = update
Deleteattachable.deletePOST/attachable where qbo.action = delete
EmployeesReademployee.readGET/employee/{id}
GET/query where qbo.entity = Employee
Createemployee.createPOST/employee where qbo.action = create
Updateemployee.updatePOST/employee where qbo.action = updateuseful: body.Active
ReportsRunreports.runGET/reports/{report}path.report
Company infoReadcompanyinfo.readGET/companyinfo/{id}

{entity} is one of invoice, estimate, customer, vendor, bill, payment, item, account, purchase (Expenses), journalentry, deposit, transfer, salesreceipt, creditmemo, billpayment, vendorcredit, and {Entity} its QuickBooks name (Invoice, Estimate, Purchase, JournalEntry and so on). So invoice.read covers reading one invoice and queries of invoices. Conditions on the body of a write use body. fields, for example body.Active on customer.update.

  • Attachment downloads. attachable.read also covers GET/download/{id}, which QuickBooks answers with a temporary link to the file at Intuit. Fetch that link directly, without KeyContain. The file comes from Intuit, outside KeyContain's rules and activity: KeyContain records only the request for the link, which costs $0.01 like any read on a service connected through KeyContain's Intuit app (§11).
  • Attachment uploads. attachable.create adds a note or a link. A file goes through attachable.upload: a multipart POST/upload with each file's metadata (its name, content type and what it is attached to) and its content. A new row for it starts as Ask me, and the person sees the files on the confirm screen before approving. An upload that waits for confirmation can be at most 10 MiB, and a larger one is refused with 413 REQUEST_TOO_LARGE (§10). After approval, send the identical bytes again before expiresAt: build the multipart body once and keep it, with the same boundary, since a new boundary makes a different request (§10).
    • Conditions. A rule for attachable.upload can be limited with the upload. fields, which must hold for every file and every record (§9, example).
    • What KeyContain reads. Standard multipart/form-data: each file's metadata as JSON or XML in file_metadata_N, and its bytes in file_content_N, also base64 with Content-Transfer-Encoding: base64, with CRLF or LF line endings, and N from 0 to 99, leading zeros allowed. A file_content_N without metadata is a file attached to no record, and the person sees "Not attached to any record".
    • What it refuses. When KeyContain has to read an upload, to check its conditions or to hold it for confirmation, it refuses one it can't read unambiguously with 400 UPLOAD_UNREADABLE, before anyone is asked: the boundary inside a part, a part name used twice, a file_metadata_N without its file_content_N, no file at all, folded part headers or header parameters in the encoded form (filename*=), part headers that aren't UTF-8, any Content-Encoding on the request, anything but blank lines before the first boundary or after the last, or a missing final boundary (§15). An upload a rule allows without upload. conditions is forwarded without being read.
  • Employees have no Delete: QuickBooks can't delete an employee, only make one inactive, which is an update with body.Active = false. Employee records hold personal details such as addresses and birth dates, and Every Read includes employee.read.
  • Voids (?operation=void, or include=void on an update) have no qbo.action, so no permission covers them. They are refused unless a method and path rule allows them.

GitLab

Scope field projects. In REST paths {project} is one segment, a numeric ID or a URL-encoded path. In git paths it is the full project path. Either way KeyContain resolves it to the project's path to check the scope (§9).

ObjectPermissionOperationEndpointsFields
Project permissions (scoped)
Project detailsReadproject.readGET/api/v4/projects/{project}
RepositoryReadrepository.readgit fetch/{project}.git
GET/api/v4/projects/{project}/repository/**
Pushrepository.pushgit push/{project}.gitgit.ref, git.force, git.delete
IssuesReadissues.readGET/api/v4/projects/{project}/issues
GET/api/v4/projects/{project}/issues/{issue}
Createissues.createPOST/api/v4/projects/{project}/issuesuseful: body.title, body.labels
Updateissues.updatePUT/api/v4/projects/{project}/issues/{issue}useful: body.state_event, body.labels
Merge requestsReadmerge_requests.readGET/api/v4/projects/{project}/merge_requests
GET/api/v4/projects/{project}/merge_requests/{mr}
Createmerge_requests.createPOST/api/v4/projects/{project}/merge_requestsuseful: body.target_branch, body.source_branch
Mergemerge_requests.mergePUT/api/v4/projects/{project}/merge_requests/{mr}/merge
CommentsReadcomments.readGET/api/v4/projects/{project}/issues/{issue}/notes
GET/api/v4/projects/{project}/merge_requests/{mr}/notes
Createcomments.createPOST/api/v4/projects/{project}/issues/{issue}/notes
POST/api/v4/projects/{project}/merge_requests/{mr}/notes
PipelinesReadpipelines.readGET/api/v4/projects/{project}/pipelines
GET/api/v4/projects/{project}/pipelines/{pipeline}
Runpipelines.runPOST/api/v4/projects/{project}/pipelineuseful: body.ref
Instance permissions (service-level)
ProjectsListprojects.listGET/api/v4/projects
UsersReadusers.readGET/api/v4/users
GET/api/v4/users/{user}

Listing projects shows every project the upstream token can see, whatever the scopes, so it is a separate, service-level permission.

GitHub

Scope field repositories, entries owner/repo, taken from {owner} and {repo}. REST paths are relative to the API base URL, git paths to the git base URL.

ObjectPermissionOperationEndpointsFields
Repository permissions (scoped)
ContentsReadcontents.readgit fetch/{owner}/{repo}.git
GET/repos/{owner}/{repo}/contents/**
Pushcontents.pushgit push/{owner}/{repo}.gitgit.ref, git.force, git.delete
IssuesReadissues.readGET/repos/{owner}/{repo}/issues
GET/repos/{owner}/{repo}/issues/{number}
Createissues.createPOST/repos/{owner}/{repo}/issuesuseful: body.title, body.labels
CommentsReadcomments.readGET/repos/{owner}/{repo}/issues/{number}/comments
Createcomments.createPOST/repos/{owner}/{repo}/issues/{number}/comments
Pull requestsReadpulls.readGET/repos/{owner}/{repo}/pulls
GET/repos/{owner}/{repo}/pulls/{number}
Createpulls.createPOST/repos/{owner}/{repo}/pullsuseful: body.base, body.head
Mergepulls.mergePUT/repos/{owner}/{repo}/pulls/{number}/merge
ActionsReadactions.readGET/repos/{owner}/{repo}/actions/runs
Runactions.runPOST/repos/{owner}/{repo}/actions/workflows/{workflow}/dispatchespath.workflow, useful: body.ref
Account permissions (service-level)
RepositoriesListrepositories.listGET/user/repos
GET/orgs/{org}/repos
OrganizationsReadorgs.readGET/orgs/{org}path.org

Cloudflare

Scope field zones, entries zone names such as acme.dev, taken from {zone} after KeyContain resolves the zone ID to its name (§9). Paths are relative to https://api.cloudflare.com/client/v4. Pages projects belong to the Cloudflare account, so their permissions are account permissions that name the account and project in the path.

ObjectPermissionOperationEndpointsFields
Zone permissions (scoped)
ZoneReadzone.readGET/zones/{zone}
Check activationzone.activation_checkPUT/zones/{zone}/activation_check
DNS recordsReaddns_records.readGET/zones/{zone}/dns_records
GET/zones/{zone}/dns_records/{record}
GET/zones/{zone}/dns_records/export
Createdns_records.createPOST/zones/{zone}/dns_recordsuseful: body.type, body.name, body.content, body.proxied
Updatedns_records.updatePUT/zones/{zone}/dns_records/{record}
PATCH/zones/{zone}/dns_records/{record}
useful: body.type, body.name, body.content, body.proxied
Deletedns_records.deleteDELETE/zones/{zone}/dns_records/{record}
Change manydns_records.batchPOST/zones/{zone}/dns_records/batch
POST/zones/{zone}/dns_records/import
CachePurgecache.purgePOST/zones/{zone}/purge_cacheuseful: body.purge_everything, body.hosts, body.prefixes
Zone settingsReadsettings.readGET/zones/{zone}/settings
GET/zones/{zone}/settings/{setting}
Changesettings.updatePATCH/zones/{zone}/settings
PATCH/zones/{zone}/settings/{setting}
Account permissions (service-level)
ZonesListzones.listGET/zones
Createzones.createPOST/zonesuseful: body.name
Pages projectsListpages.listGET/accounts/{account}/pages/projectspath.account
Readpages.readGET/accounts/{account}/pages/projects/{project}
GET/accounts/{account}/pages/projects/{project}/deployments
GET/accounts/{account}/pages/projects/{project}/deployments/{deployment}
GET/accounts/{account}/pages/projects/{project}/deployments/{deployment}/history/logs
GET/accounts/{account}/pages/projects/{project}/domains
GET/accounts/{account}/pages/projects/{project}/domains/{domain}
path.account, path.project
Createpages.createPOST/accounts/{account}/pages/projectspath.account, useful: body.name
Changepages.updatePATCH/accounts/{account}/pages/projects/{project}
POST/accounts/{account}/pages/projects/{project}/purge_build_cache
POST/accounts/{account}/pages/projects/{project}/domains
PATCH/accounts/{account}/pages/projects/{project}/domains/{domain}
DELETE/accounts/{account}/pages/projects/{project}/domains/{domain}
path.account, path.project
Deletepages.deleteDELETE/accounts/{account}/pages/projects/{project}path.account, path.project
Pages deploymentsDeploydeployments.createPOST/accounts/{account}/pages/projects/{project}/deploymentspath.account, path.project, useful: body.branch
Retrydeployments.retryPOST/accounts/{account}/pages/projects/{project}/deployments/{deployment}/retrypath.account, path.project
Roll backdeployments.rollbackPOST/accounts/{account}/pages/projects/{project}/deployments/{deployment}/rollbackpath.account, path.project
Deletedeployments.deleteDELETE/accounts/{account}/pages/projects/{project}/deployments/{deployment}path.account, path.project
  • Change many covers Cloudflare's batch endpoint, which creates, updates and deletes several records in one request, and imports of a BIND file. Its rules apply to the whole request, so a new row for it starts as Ask me, and its summary counts the creates, updates and deletes.
  • Zones · List returns every zone the API token can see. Rules can't narrow the list to some zones.
  • Pages direct uploads aren't covered: they need Cloudflare's upload token (GET /accounts/{account}/pages/projects/{project}/upload-token), a Cloudflare credential that KeyContain never hands to an agent. Deploy covers deployments of projects connected to a git repository, which Cloudflare builds itself.

Porkbun

Scope field domains, entries domain names such as acme.dev, taken from {domain}. Paths are relative to https://api.porkbun.com/api/json/v3. Porkbun also needs API access turned on for each domain (or for all) in its account settings.

ObjectPermissionOperationEndpointsFields
Domain permissions (scoped)
NameserversReadnameservers.readGET/domain/getNs/{domain}
Changenameservers.updatePOST/domain/updateNs/{domain}useful: body.ns
DNS recordsReaddns.readGET/dns/retrieve/{domain}
GET/dns/retrieve/{domain}/{id}
GET/dns/retrieveByNameType/{domain}/{type}
GET/dns/retrieveByNameType/{domain}/{type}/{subdomain}
Createdns.createPOST/dns/create/{domain}useful: body.type, body.name, body.content
Updatedns.updatePOST/dns/edit/{domain}/{id}
POST/dns/editByNameType/{domain}/{type}
POST/dns/editByNameType/{domain}/{type}/{subdomain}
useful: body.type, body.name, body.content
Deletedns.deletePOST/dns/delete/{domain}/{id}
POST/dns/deleteByNameType/{domain}/{type}
POST/dns/deleteByNameType/{domain}/{type}/{subdomain}
DomainRegisterdomain.registerPOST/domain/create/{domain}useful: body.dryRun, body.cost
Renewdomain.renewPOST/domain/renew/{domain}
Account permissions (service-level)
DomainsListdomains.listGET/domain/listAll
Check availabilitydomains.checkPOST/domain/checkDomain/{name}
POST/domain/checkDomain
PricingReadpricing.readGET/pricing/get
  • Register and Renew spend money from the Porkbun account's credit. A new row for either starts as Ask me, and it can be allowed, for example only with "body.dryRun": true. Rules can't compare prices: Porkbun's own limits (at most $100 an order and, by default, $100 a month) cap what an allowed row can spend.
  • Check availability only reads, though Porkbun takes it as a POST. It counts as a read: a new row for it starts as Allowed and Every Read adds it.
  • Porkbun reads also accept POST. The catalog covers them as GET, so an agent reads with GET.
  • A request with a body must send a JSON object, so KeyContain can remove any apikey or secretapikey from it. Another body is refused with 400 REQUEST_INVALID.
  • A method and path rule such as POST /domain/** also covers purchases. Prefer the catalog's permissions.

Namecheap

Scope field domains, entries domain names such as acme.dev, taken from namecheap.domain (§9). Every command goes to https://api.namecheap.com/xml.response, so each permission matches its commands through namecheap.command. Namecheap answers in XML.

ObjectPermissionOperationEndpointsFields
Domain permissions (scoped)
NameserversReadnameservers.readGET/xml.responsewhere namecheap.command = namecheap.domains.dns.getlist
Changenameservers.updateGET/xml.responsewhere namecheap.command = namecheap.domains.dns.setcustom
POST/xml.responsewhere namecheap.command = namecheap.domains.dns.setcustom
GET/xml.responsewhere namecheap.command = namecheap.domains.dns.setdefault
POST/xml.responsewhere namecheap.command = namecheap.domains.dns.setdefault
useful: query.NameServers
DNS recordsReaddns.readGET/xml.responsewhere namecheap.command = namecheap.domains.dns.gethosts
Replace alldns.replaceGET/xml.responsewhere namecheap.command = namecheap.domains.dns.sethosts
POST/xml.responsewhere namecheap.command = namecheap.domains.dns.sethosts
DomainReaddomain.readGET/xml.responsewhere namecheap.command = namecheap.domains.getinfo
Registerdomain.registerGET/xml.responsewhere namecheap.command = namecheap.domains.create
POST/xml.responsewhere namecheap.command = namecheap.domains.create
useful: query.Years
Renewdomain.renewGET/xml.responsewhere namecheap.command = namecheap.domains.renew
POST/xml.responsewhere namecheap.command = namecheap.domains.renew
Account permissions (service-level)
DomainsListdomains.listGET/xml.responsewhere namecheap.command = namecheap.domains.getlist
Check availabilitydomains.checkGET/xml.responsewhere namecheap.command = namecheap.domains.check
PricingReadpricing.readGET/xml.responsewhere namecheap.command = namecheap.users.getpricing
  • DNS records · Replace all sends the domain's whole record set: Namecheap deletes every record the request leaves out, and has no command to change one record. Rules can't allow one record and not another, so a new row for it starts as Ask me. An agent reads the records first and sends them back with its change.
  • Register and Renew are charged to the Namecheap account's balance. A new row for either starts as Ask me, and it can be allowed. Rules can't compare prices.
  • Namecheap only answers IPv4 addresses whitelisted in the account, so the customer adds KeyContain's outgoing address, which the dashboard shows when the service is added. Namecheap gives API access to accounts with at least 20 domains, a $50 balance or $50 spent in the last two years.
  • Conditions on body. fields need a JSON body, which Namecheap requests don't have. Conditions on Namecheap requests use query. fields, and rules are limited to domains by their scope.
  • A request must send its parameters in the query or a form body, name its domain once (by DomainName, or by SLD and TLD), and give each parameter once, in any letter case, across the query and the body. Anything else is refused with 400 REQUEST_INVALID, so a rule's scope always sees the domain Namecheap acts on.
  • A method and path rule for /xml.response covers every Namecheap command, purchases included. Prefer the catalog's permissions.

Google Cloud, Firebase and Play

Two scope fields (§9): projects, entries Google Cloud project IDs such as acme-app, for the project permissions, taken from {project}, and apps, entries Play package names such as com.acme.app, for the app permissions, taken from {app}. A Firebase project is a Google Cloud project. {kind} is iosApps, androidApps or webApps.

ObjectPermissionOperationEndpointsFields
Project permissions (scoped by projects)
ProjectsReadprojects.readGETfirebase.googleapis.com/v1beta1/projects/{project}
GETfirebase.googleapis.com/v1beta1/projects/{project}/adminSdkConfig
GETcloudresourcemanager.googleapis.com/v3/projects/{project}
Add Firebaseprojects.addFirebasePOSTfirebase.googleapis.com/v1beta1/projects/{project}:addFirebase
APIsReadapis.readGETserviceusage.googleapis.com/v1/projects/{project}/services
GETserviceusage.googleapis.com/v1/projects/{project}/services/{api}
Turn onapis.enablePOSTserviceusage.googleapis.com/v1/projects/{project}/services/{api}:enable
POSTserviceusage.googleapis.com/v1/projects/{project}/services:batchEnable
useful: body.serviceIds
Turn offapis.disablePOSTserviceusage.googleapis.com/v1/projects/{project}/services/{api}:disablepath.api
AppsReadapps.readGETfirebase.googleapis.com/v1beta1/projects/{project}/{kind}
GETfirebase.googleapis.com/v1beta1/projects/{project}/{kind}/{app}
Createapps.createPOSTfirebase.googleapis.com/v1beta1/projects/{project}/{kind}path.kind, useful: body.bundleId, body.packageName
Updateapps.updatePATCHfirebase.googleapis.com/v1beta1/projects/{project}/{kind}/{app}path.kind
Removeapps.removePOSTfirebase.googleapis.com/v1beta1/projects/{project}/{kind}/{app}:remove
POSTfirebase.googleapis.com/v1beta1/projects/{project}/{kind}/{app}:undelete
path.kind
Config filesReadconfigs.readGETfirebase.googleapis.com/v1beta1/projects/{project}/{kind}/{app}/configpath.kind
NotificationsSend to a devicemessages.sendPOSTfcm.googleapis.com/v1/projects/{project}/messages:sendwhere fcm.target = token
Send to a topicmessages.broadcastPOSTfcm.googleapis.com/v1/projects/{project}/messages:sendwhere fcm.target is topic or condition, useful: body.message.topic
RolesReadroles.readPOSTcloudresourcemanager.googleapis.com/v3/projects/{project}:getIamPolicy
GETiam.googleapis.com/v1/projects/{project}/serviceAccounts
Changeroles.updatePOSTcloudresourcemanager.googleapis.com/v3/projects/{project}:setIamPolicyuseful: body.policy
Service accountsCreateserviceAccounts.createPOSTiam.googleapis.com/v1/projects/{project}/serviceAccountsuseful: body.accountId
Create keysserviceAccounts.keysPOSTiam.googleapis.com/v1/projects/{project}/serviceAccounts/{account}/keys
POSTiam.googleapis.com/v1/projects/{project}/serviceAccounts/{account}/keys:upload
path.account
Sign asserviceAccounts.signPOSTiam.googleapis.com/v1/projects/{project}/serviceAccounts/{account}:signJwt
POSTiam.googleapis.com/v1/projects/{project}/serviceAccounts/{account}:signBlob
path.account
Change accessserviceAccounts.accessPOSTiam.googleapis.com/v1/projects/{project}/serviceAccounts/{account}:setIamPolicypath.account
App permissions (scoped by apps)
EditsReadedits.readGET/androidpublisher/v3/applications/{app}/edits/{edit}
Open and discardedits.openPOST/androidpublisher/v3/applications/{app}/edits
POST/androidpublisher/v3/applications/{app}/edits/{edit}:validate
DELETE/androidpublisher/v3/applications/{app}/edits/{edit}
Commitedits.commitPOST/androidpublisher/v3/applications/{app}/edits/{edit}:commit
BundlesReadbundles.readGET/androidpublisher/v3/applications/{app}/edits/{edit}/bundles
Uploadbundles.uploadPOST/upload/androidpublisher/v3/applications/{app}/edits/{edit}/bundles
POST/upload/androidpublisher/v3/applications/{app}/edits/{edit}/apks/{versionCode}/deobfuscationFiles/{type}
TracksReadtracks.readGET/androidpublisher/v3/applications/{app}/edits/{edit}/tracks
GET/androidpublisher/v3/applications/{app}/edits/{edit}/tracks/{track}
GET/androidpublisher/v3/applications/{app}/tracks/{track}/releases
Updatetracks.updatePUT/androidpublisher/v3/applications/{app}/edits/{edit}/tracks/{track}
PATCH/androidpublisher/v3/applications/{app}/edits/{edit}/tracks/{track}
path.track, useful: body.releases
Store listingReadlisting.readGET/androidpublisher/v3/applications/{app}/edits/{edit}/listings/**
GET/androidpublisher/v3/applications/{app}/edits/{edit}/details
Updatelisting.updatePUT/androidpublisher/v3/applications/{app}/edits/{edit}/listings/{language}
PATCH/androidpublisher/v3/applications/{app}/edits/{edit}/listings/{language}
PATCH/androidpublisher/v3/applications/{app}/edits/{edit}/details
POST/upload/androidpublisher/v3/applications/{app}/edits/{edit}/listings/{language}/{imageType}
DELETE/androidpublisher/v3/applications/{app}/edits/{edit}/listings/{language}/{imageType}/{image}
TestersReadtesters.readGET/androidpublisher/v3/applications/{app}/edits/{edit}/testers/{track}path.track
Updatetesters.updatePUT/androidpublisher/v3/applications/{app}/edits/{edit}/testers/{track}
PATCH/androidpublisher/v3/applications/{app}/edits/{edit}/testers/{track}
path.track
ReviewsReadreviews.readGET/androidpublisher/v3/applications/{app}/reviews
GET/androidpublisher/v3/applications/{app}/reviews/{review}
Replyreviews.replyPOST/androidpublisher/v3/applications/{app}/reviews/{review}:replyuseful: body.replyText
Account permissions (service-level)
ProjectsListprojects.listGETfirebase.googleapis.com/v1beta1/projects
GETfirebase.googleapis.com/v1beta1/availableProjects
GETcloudresourcemanager.googleapis.com/v3/projects:search
Createprojects.createPOSTcloudresourcemanager.googleapis.com/v3/projectsuseful: body.projectId, body.parent
OperationsReadoperations.readGETfirebase.googleapis.com/v1beta1/operations/**
GETserviceusage.googleapis.com/v1/operations/**
GETcloudresourcemanager.googleapis.com/v3/operations/**
Play usersReadusers.readGET/androidpublisher/v3/developers/{developer}/users
Invite and changeusers.updatePOST/androidpublisher/v3/developers/{developer}/users
PATCH/androidpublisher/v3/developers/{developer}/users/{user}
DELETE/androidpublisher/v3/developers/{developer}/users/{user}
POST/androidpublisher/v3/developers/{developer}/users/{user}/grants
PATCH/androidpublisher/v3/developers/{developer}/users/{user}/grants/{grant}
DELETE/androidpublisher/v3/developers/{developer}/users/{user}/grants/{grant}
useful: body.email
  • New rows start as Ask me for every write, and can be changed to Allowed like any other permission. Reads start as Allowed. Bundles · Upload, Edits · Open and discard, Tracks · Update and Store listing · Update start as Allowed, because uploading a bundle can't wait for a person (below) and the release only happens at Edits · Commit, which starts as Ask me.
  • Roles · Change, Service accounts and Play users hand out access. Roles · Change can give any account a role in the project, Create keys and Sign as give the agent a Google credential that works without KeyContain until someone deletes it, and Play users · Invite and change adds people to the Play developer account. Their summaries say exactly what is granted.
  • Bundle uploads can't wait for a person. A request over 10 MiB can't be held (§10), and signed bundles are usually larger, so a rule for Bundles · Upload needs access allow. Uploads count toward the account's traffic (§11). Play's resumable uploads aren't covered: an agent sends the bundle in one request.
  • Projects · Create makes a new project under the organization or folder named in body.parent, which a condition can fix. The service account creating it gets access to it, so the agent can then add Firebase and create its apps through the same service.
  • Done in Google's and Apple's consoles, whatever the rules allow: each app's first Play Console record, the APNs key, and uploading the APNs key to Firebase. Neither Google nor Apple has an API for these.
  • The key reaches only what its service account was given. Granted on a Google organization or folder, the service account reaches every project there, including new ones. In Play Console, account permissions cover every app, including ones added later.
  • Left out: Play purchases, orders, subscriptions, prices, refunds and app signing, and Firebase signing fingerprints. A method and path rule can still reach them.

App Store Connect

Scope field apps, entries bundle IDs such as com.acme.app, optional on every app permission. KeyContain finds each request's app as described in §9. Paths are relative to https://api.appstoreconnect.apple.com.

ObjectPermissionOperationEndpointsFields
App permissions (scoped by apps)
AppsReadapps.readGET/v1/apps
GET/v1/apps/{app}
GET/v1/apps/{app}/**
Updateapps.updatePATCH/v1/apps/{app}
PATCH/v1/appInfos/{info}
POST/v1/appInfoLocalizations
PATCH/v1/appInfoLocalizations/{localization}
BuildsReadbuilds.readGET/v1/builds
GET/v1/builds/**
VersionsReadversions.readGET/v1/appStoreVersions/**
Create and editversions.updatePOST/v1/appStoreVersions
PATCH/v1/appStoreVersions/{version}
PATCH/v1/appStoreVersions/{version}/relationships/build
POST/v1/appStoreVersionLocalizations
PATCH/v1/appStoreVersionLocalizations/{localization}
useful: body.data.attributes.versionString
Deleteversions.deleteDELETE/v1/appStoreVersions/{version}
TestFlightReadtestflight.readGET/v1/betaGroups/**
Manage groups and buildstestflight.groupsPOST/v1/betaGroups
PATCH/v1/betaGroups/{group}
POST/v1/builds/{build}/relationships/betaGroups
Submit for beta reviewtestflight.submitPOST/v1/betaAppReviewSubmissions
App ReviewSubmitreview.submitPOST/v1/reviewSubmissions
POST/v1/reviewSubmissionItems
PATCH/v1/reviewSubmissions/{submission}
useful: body.data.attributes.submitted
Releasereview.releasePOST/v1/appStoreVersionReleaseRequests
Team permissions (service-level)
BuildsUploadbuilds.uploadPOST/v1/buildUploads
POST/v1/buildUploadFiles
PATCH/v1/buildUploadFiles/{file}
GET/v1/buildUploads/{upload}
ScreenshotsUpload and placescreenshots.updatePOST/v1/appAssetLibraryImages
PATCH/v1/appAssetLibraryImages/{image}
POST/v1/appAssetLibraryPlacements
DELETE/v1/appAssetLibraryPlacements/{placement}
TestersReadtesters.readGET/v1/betaTesters
GET/v1/betaTesters/**
Invite and removetesters.updatePOST/v1/betaTesters
DELETE/v1/betaTesters/{tester}
useful: body.data.attributes.email
Bundle IDsReadbundleIds.readGET/v1/bundleIds
GET/v1/bundleIds/**
Turn on capabilitiesbundleIds.capabilitiesPOST/v1/bundleIdCapabilities
PATCH/v1/bundleIdCapabilities/{capability}
useful: body.data.attributes.capabilityType
Certificates and profilesReadsigning.readGET/v1/certificates/**
GET/v1/profiles/**
GET/v1/devices/**
Create and revokesigning.updatePOST/v1/certificates
DELETE/v1/certificates/{certificate}
POST/v1/profiles
DELETE/v1/profiles/{profile}
POST/v1/devices
Team usersReadusers.readGET/v1/users/**
GET/v1/userInvitations/**
Invite and changeusers.updatePOST/v1/userInvitations
PATCH/v1/users/{user}
DELETE/v1/users/{user}
useful: body.data.attributes.roles
  • New rows start as Ask me for every write, and can be changed to Allowed. Reads start as Allowed. Builds · Upload, Versions · Create and edit, Screenshots · Upload and place and TestFlight · Manage groups and builds start as Allowed. App Review · Submit and Release start as Ask me.
  • Build uploads and screenshots are team permissions. Apple's build uploads and asset library images name no app, so rules for Builds · Upload and Screenshots · Upload and place can't be limited by app (user decision 2026-10-10). Attaching a build to a version, TestFlight and App Review stay limited by app, so nothing an upload brings in ships without an app rule.
  • Build and screenshot files go straight to Apple. Builds · Upload reserves the upload through KeyContain, and Apple answers with upload addresses (uploadOperations). The agent sends the file's parts to those addresses directly, without a KeyContain key or any credential, then marks the upload done through KeyContain. Screenshots work the same way.
  • Certificates and profiles · Create and revoke and Team users · Invite and change hand out access: a new certificate's private key stays with the agent, and an invitation adds a person to the team. Their summaries say so.
  • An App Store Connect team key reaches every app in the team. Each app's first App Store Connect record is made in App Store Connect. Xcode, Transporter, altool and fastlane sign their own tokens, so agents call the API directly.
  • Left out: pricing, in-app purchases, sales and finance reports. A method and path rule can still reach them.

Plaid

Scope field accounts, entries Plaid account IDs of the service's bank connection, for the account permissions. KeyContain finds each request's accounts as described in §9. Paths are relative to https://production.plaid.com. Every endpoint is a POST with a JSON body that names the service in access_token (§7). Plaid takes reads as POST too, and they count as reads.

ObjectPermissionOperationEndpointsFields
Account permissions (scoped)
AccountsReadaccounts.readPOST/accounts/get
POST/accounts/balance/get
useful: body.options.account_ids
TransactionsReadtransactions.readPOST/transactions/sync
POST/transactions/get
useful: body.options.account_ids, body.account_id
LiabilitiesReadliabilities.readPOST/liabilities/getuseful: body.options.account_ids
StatementsDownloadstatements.downloadPOST/statements/downloaduseful: body.statement_id
Connection permissions (service-level)
StatementsListstatements.listPOST/statements/list
Refreshstatements.refreshPOST/statements/refreshuseful: body.start_date, body.end_date
TransactionsRefreshtransactions.refreshPOST/transactions/refresh
Bank connectionReaditem.readPOST/item/get
  • Only the catalog. KeyContain forwards only these endpoints, whatever the key's rules say. Method and path rules can't be written for a Plaid service (§15), and the Add permission picker has no Custom rule for it. A request to any other Plaid endpoint is refused with 403 OPERATION_NOT_ALLOWED and its own message (§15). This keeps an agent from creating, sharing or rotating access to a bank: public tokens, processor tokens, asset report sharing, token rotation and transfers.
  • New rows start as Allowed for the reads and as Ask me for Statements · Refresh and Transactions · Refresh, which are writes. Every Read adds the six reads: accounts.read, transactions.read, liabilities.read, statements.download, statements.list and item.read.
  • Statements · Refresh asks the bank for the statements of a date range. Plaid's paid plans charge for each statement in the range, also for one fetched before, so a rule can put conditions on body.start_date. Connecting a bank asks for as few statements as Plaid allows, and new and older months come from Statements · Refresh. Statements · List then shows which months exist for each account.
  • PDF statements exist only for US checking and savings accounts, the ones with "statements": true in GET /v1/key (§13).
  • Statements and Transactions are each two objects, one for an account and one for the whole connection, as App Store Connect's Builds are.

Google Drive

Scope field folders, entries My Drive paths such as Bank statements/Chase, path patterns, or exact folder IDs as folder://1SharedA/**, for the folder permissions. KeyContain finds each request's path as described in §9. Paths are relative to https://www.googleapis.com, and {fileId} is a Drive file or folder ID. The connection has full Drive read and write access. Each key is limited by its rules.

ObjectPermissionOperationEndpointsFields
Folder permissions (scoped)
FoldersCreatefolders.createPOST/drive/v3/files where body.mimeType = application/vnd.google-apps.folderuseful: body.name
FilesUploadfiles.uploadPOST/upload/drive/v3/files where query.uploadType is multipart or media
Readfiles.readGET/drive/v3/files/{fileId} without alt=media
Downloadfiles.downloadGET/drive/v3/files/{fileId} where query.alt = media
GET/drive/v3/files/{fileId}/export
Updatefiles.updatePATCH/drive/v3/files/{fileId}useful: body.name, query.addParents
Deletefiles.deleteDELETE/drive/v3/files/{fileId}
Listfiles.listGET/drive/v3/filesuseful: query.q, query.fields
  • New rows start as Allowed for the reads and as Ask me for the writes. Every Read adds files.read, files.download and files.list.
  • Files · List takes optional folder scopes. Scoped rules require a native parent query and coverage of every child as described in §9. Unscoped rules allow Drive-wide listings. Responses are never filtered by KeyContain.
  • Uploads. Files · Upload covers multipart uploads, a metadata part with the file's name and parents followed by its content, and media uploads, which have no metadata. Resumable uploads aren't in the catalog in v1. An upload that waits for confirmation can be at most 10 MiB (§10). Its body isn't JSON, so body. conditions on files.upload are always false.
  • Files · Delete deletes a file for good, without the trash, as Drive's DELETE does.

Generic HTTP

ObjectPermissionOperationEndpointsFields
EverythingReadall.readGET/**
HEAD/**

No scope. Everything else on a Generic HTTP service is written as method and path rules.

KeyContain

ObjectPermissionOperationEndpointsFields
EverythingReadall.readGET/**
HEAD/**
git fetch/**
RepositoriesPushrepository.pushgit push/**git.repo, git.ref, git.force, git.delete

No scope. The inner KeyContain key's own rules decide which repositories it reaches. git.repo narrows a push further.

KeyContain account

Scope field keys, taken from {key}. Paths are relative to https://api.keycontain.com/v1.

ObjectPermissionOperationEndpointsFields
Key permissions (scoped)
KeysReadkeys.readGET/keys/{key}path.key
Rotate, confirm onlykeys.rotatePOST/keys/{key}/rotatepath.key
Revokekeys.revokePOST/keys/{key}/revokepath.key
Deletekeys.deleteDELETE/keys/{key}path.key
ACLsReadacl.readGET/keys/{key}/aclpath.key
Change, confirm onlyacl.updatePUT/keys/{key}/aclpath.key
Account permissions (service-level)
KeysListkeys.listGET/keys
Create, confirm onlykeys.createPOST/keys
ServicesReadservices.readGET/services
GET/services/{service}
Create, confirm onlyservices.createPOST/services
Change, confirm onlyservices.updatePATCH/services/{service}path.service
Deleteservices.deleteDELETE/services/{service}path.service
IP groupsReadipGroups.readGET/ip-groups
GET/ip-groups/{group}
Create, confirm onlyipGroups.createPOST/ip-groups
Change, confirm onlyipGroups.updatePUT/ip-groups/{group}path.group
DeleteipGroups.deleteDELETE/ip-groups/{group}path.group
ActivityReadactivity.readGET/activityuseful: query.key, query.service, query.agent
BalanceReadbilling.readGET/billing
CapacityChangecapacity.updatePUT/billing/capacityuseful: body.units

Confirm-only permissions are the ones that can hand over a key's value, or widen what keys can reach or where they can be used from: keys.create, keys.rotate, services.create, services.update, ipGroups.create, ipGroups.update and acl.update. Their rules need access confirm. The other writes may have either access. path.key still narrows a rule for a key permission, for example to every key except the calling one (§14). Method and path rules may also be written for this service, without a scope, but one that could match a confirm-only write's method and path with access allow is refused in the same way (§15). POST /changes has no permission of its own: each change in it is checked against its own permission (§14).

13Meta API

An agent can ask KeyContain what its own key may do, and check the confirmations its requests are waiting for. Both are read-only.

GET https://api.keycontain.com/v1/key

Authenticated like proxied requests: the key in Authorization (§5). No parameters.

curl https://api.keycontain.com/v1/key \
  -H "Authorization: Bearer kc_live_7Hq2mV9xPz4Lw8NcT3bR6yKd1Fs5c21e"

200 response

FieldTypeMeaning
keyobjectid, name, and last4 of the calling key. The full key is never returned.
servicesobjectThe services attached to the key, by id: type, label (or null), baseUrl, for GitHub gitBaseUrl, for Google Cloud, Firebase and Play otherBaseUrls (its five other base URLs), for QuickBooks Online intuitApp and pricePerRead, for Plaid institution and accounts, for Google Drive googleAccount, and available. institution is the bank's name. accounts lists the accounts the person shared, each with id (Plaid's account ID, as rules' accounts and Plaid's account_ids take it), name, mask (its last 4 digits), type and subtype as Plaid names them, and statements, true when Plaid returns PDF statements for it. googleAccount is the connected Google account's email. intuitApp is keycontain when the service is connected through KeyContain's Intuit app, or own through the customer's own. pricePerRead is what each successful read costs, in US dollars as a decimal string, "0.01", taken from the account's balance (§11). It is there only on a service with priced reads, so a service without it has free reads. available is false while the service's type is turned off, when its requests get 503 SERVICE_TYPE_UNAVAILABLE (§15). Upstream credentials are never returned.
aclobjectThe ACL as written (§9): for every service its rules, in their order, each with its access and its scope if it has one.
effectiveobjectThe ACL with every catalog permission rewritten from the current catalog (§9): one flat rules list per service, in the order of the written rules, one rewritten rule per endpoint, each with its rule's access, scope, conditions and expiresAfter. Rewritten rules carry "from", the permission, and on Google Cloud, Firebase and Play "baseUrl", the host their endpoint is on, and every rule carries "rule", the position in acl of the written rule it came from, which tells apart several rules for one permission. Method and path rules and git rules appear as written. IP group IDs in ips are replaced by the groups' current addresses and ranges. limits is as written.
limitsobjectaccount: the limits all keys in the account share now (§11), requestsPerMinute and trafficPerMonth in bytes, from its capacity units, or the free allowance (10 and 104857600) when it has none or its balance can't pay for them. key: the key's own limits from its ACL (§9), the same two fields, each null when the key has no limit of its own of that kind.
usageobjectaccount: requestsLastMinute and trafficThisMonth (bytes) of all keys together. key: the same two for this key alone. trafficResetsAt: when both traffic counts reset, the next 1st at 00:00 UTC.
{
  "key": { "id": "key_8KQ2W1", "name": "Ops agent", "last4": "c21e" },
  "limits": {
    "account": { "requestsPerMinute": 250, "trafficPerMonth": 5368709120 },
    "key": { "requestsPerMinute": 50, "trafficPerMonth": 524288000 }
  },
  "usage": {
    "account": { "requestsLastMinute": 212, "trafficThisMonth": 3328599654 },
    "key": { "requestsLastMinute": 31, "trafficThisMonth": 432013312 },
    "trafficResetsAt": "2026-11-01T00:00:00Z"
  },
  "services": {
    "svc_qbo_acme": { "type": "quickbooks", "label": "Acme Holdings",
                      "baseUrl": "https://quickbooks.api.intuit.com/v3/company/9130354",
                      "intuitApp": "keycontain", "pricePerRead": "0.01",
                      "available": true },
    "svc_gitlab_acme": { "type": "gitlab", "label": null,
                         "baseUrl": "https://code.acme.dev", "available": true }
  },
  "acl": {
    "ips": ["ipg_office", "ipg_ci", "203.0.113.7"],
    "limits": { "requestsPerMinute": 50, "trafficPerMonth": 524288000 },
    "services": {
      "svc_qbo_acme": {
        "rules": [
          { "operation": "invoice.read", "access": "allow" },
          { "operation": "customer.read", "access": "allow" },
          { "operation": "reports.run", "access": "allow",
            "where": { "path.report": { "in": ["ProfitAndLoss", "AgedReceivables"] } } }
        ]
      },
      "svc_gitlab_acme": {
        "rules": [
          { "operation": "project.read", "access": "allow",
            "projects": ["acme/api", "acme/web"] },
          { "operation": "repository.read", "access": "allow",
            "projects": ["acme/api", "acme/web"] },
          { "operation": "repository.read", "access": "allow",
            "projects": ["acme/docs"] },
          { "operation": "repository.push", "access": "confirm",
            "projects": ["acme/api", "acme/web"],
            "where": { "git.ref": { "startsWith": "refs/heads/agent/" },
                       "git.force": false, "git.delete": false },
            "expiresAfter": "1h" },
          { "operation": "issues.read", "access": "allow",
            "projects": ["acme/api", "acme/web"] },
          { "operation": "issues.read", "access": "allow",
            "projects": ["acme/docs"] },
          { "operation": "issues.create", "access": "allow",
            "projects": ["acme/docs"] },
          { "operation": "merge_requests.read", "access": "allow",
            "projects": ["acme/api", "acme/web"] },
          { "operation": "merge_requests.create", "access": "allow",
            "projects": ["acme/api", "acme/web"],
            "where": { "body.target_branch": "main" } },
          { "operation": "comments.read", "access": "allow",
            "projects": ["acme/api", "acme/web"] },
          { "operation": "comments.create", "access": "allow",
            "projects": ["acme/api", "acme/web"] },
          { "operation": "pipelines.read", "access": "allow",
            "projects": ["acme/api", "acme/web"] },
          { "method": "POST", "path": "/api/v4/projects/{project}/statuses/{sha}",
            "access": "allow",
            "where": { "body.context": "ops-agent" } }
        ]
      }
    }
  },
  "effective": {
    "ips": ["198.51.100.0/24", "2001:db8:10::/48", "203.0.113.0/26", "203.0.113.7"],
    "limits": { "requestsPerMinute": 50, "trafficPerMonth": 524288000 },
    "services": {
      "svc_qbo_acme": {
        "rules": [
          { "method": "GET", "path": "/invoice/{id}", "access": "allow",
            "from": "invoice.read", "rule": 0 },
          { "method": "GET", "path": "/query", "access": "allow",
            "where": { "qbo.entity": "Invoice" }, "from": "invoice.read", "rule": 0 },
          { "method": "GET", "path": "/customer/{id}", "access": "allow",
            "from": "customer.read", "rule": 1 },
          { "method": "GET", "path": "/query", "access": "allow",
            "where": { "qbo.entity": "Customer" }, "from": "customer.read", "rule": 1 },
          { "method": "GET", "path": "/reports/{report}", "access": "allow",
            "where": { "path.report": { "in": ["ProfitAndLoss", "AgedReceivables"] } },
            "from": "reports.run", "rule": 2 }
        ]
      },
      "svc_gitlab_acme": {
        "rules": [
          { "method": "GET", "path": "/api/v4/projects/{project}", "access": "allow",
            "projects": ["acme/api", "acme/web"], "from": "project.read", "rule": 0 },
          { "git": "fetch", "path": "/{project}.git", "access": "allow",
            "projects": ["acme/api", "acme/web"], "from": "repository.read", "rule": 1 },
          { "method": "GET", "path": "/api/v4/projects/{project}/repository/**", "access": "allow",
            "projects": ["acme/api", "acme/web"], "from": "repository.read", "rule": 1 },
          { "git": "fetch", "path": "/{project}.git", "access": "allow",
            "projects": ["acme/docs"], "from": "repository.read", "rule": 2 },
          { "method": "GET", "path": "/api/v4/projects/{project}/repository/**", "access": "allow",
            "projects": ["acme/docs"], "from": "repository.read", "rule": 2 },
          { "git": "push", "path": "/{project}.git", "access": "confirm",
            "projects": ["acme/api", "acme/web"],
            "where": { "git.ref": { "startsWith": "refs/heads/agent/" },
                       "git.force": false, "git.delete": false },
            "expiresAfter": "1h", "from": "repository.push", "rule": 3 },
          { "method": "POST", "path": "/api/v4/projects/{project}/merge_requests", "access": "allow",
            "projects": ["acme/api", "acme/web"],
            "where": { "body.target_branch": "main" },
            "from": "merge_requests.create", "rule": 8 },
          { "method": "POST", "path": "/api/v4/projects/{project}/statuses/{sha}",
            "access": "allow",
            "where": { "body.context": "ops-agent" }, "rule": 12 }
        ]
      }
    }
  }
}

The Statements bot (§9) finds its banks, their accounts and its Drive here. Each Plaid service is one bank, and the agent sends its ID as access_token (§7):

{
  "svc_plaid_fifththird": { "type": "plaid", "label": "Fifth Third Bank",
    "baseUrl": "https://production.plaid.com", "institution": "Fifth Third Bank",
    "accounts": [
      { "id": "BxBXxLj1m4HMXBm9WZZmCWVbPjX16EHwv99vp", "name": "Momentum Checking", "mask": "4410",
        "type": "depository", "subtype": "checking", "statements": true },
      { "id": "dVzbVMLjrxTnLjX4G66XUp5GLklm4oiZy88yK", "name": "Relationship Savings", "mask": "9032",
        "type": "depository", "subtype": "savings", "statements": true }
    ],
    "available": true },
  "svc_plaid_chase": { "type": "plaid", "label": "Chase",
    "baseUrl": "https://production.plaid.com", "institution": "Chase",
    "accounts": [
      { "id": "Pp1Vpkl9w8sajvK6oEEKtr7vZxBnGpf7LxxLE", "name": "Total Checking", "mask": "7781",
        "type": "depository", "subtype": "checking", "statements": true },
      { "id": "XA96y1wW3xS7wKyEdbRzFkpZov6x1ohxMXwep", "name": "Business Complete Checking", "mask": "2206",
        "type": "depository", "subtype": "checking", "statements": true },
      { "id": "rz99ex9ZQotvnjXdgQLEsR81e3ArPgulVWjGj", "name": "Sapphire Preferred", "mask": "3349",
        "type": "credit", "subtype": "credit card", "statements": false }
    ],
    "available": true },
  "svc_plaid_discover": { "type": "plaid", "label": "Discover",
    "baseUrl": "https://production.plaid.com", "institution": "Discover",
    "accounts": [
      { "id": "Ld8WVVnmzXcVXbPg9qKPHKyQo1R6ZWcVA6vMn", "name": "Discover it Cash Back", "mask": "6012",
        "type": "credit", "subtype": "credit card", "statements": false }
    ],
    "available": true },
  "svc_drive_dana": { "type": "googledrive", "label": "Dana's Drive",
    "baseUrl": "https://www.googleapis.com", "googleAccount": "dana@acme.dev",
    "available": true }
}

Errors

401 KEY_INVALID if the key is missing, unknown, revoked or deleted (§15). 403 IP_NOT_ALLOWED, 429 RATE_LIMITED or TRAFFIC_LIMIT_REACHED, since this call counts like any other (§11). 500 INTERNAL on an unexpected failure.

GET https://api.keycontain.com/v1/confirmations/{id}

The status of one confirmation (§10). Authenticated with the key whose request created it. The ID comes from the CONFIRMATION_REQUIRED or CONFIRMATION_DENIED response, the KeyContain-Confirmation header, or git's remote: lines. There is no list endpoint, and nothing here can approve or decline.

curl https://api.keycontain.com/v1/confirmations/cnf_7Q2MV9XB \
  -H "Authorization: Bearer kc_live_5Tb1Vq8Nz3Lw6Rc9Xd2Kp4Hm7Ys07f3a"

200 response

FieldTypeMeaning
idstringThe confirmation ID, cnf_….
statusstringpending, approved, used, denied or expired (§10).
servicestringThe matched service's id.
operationstring or nullThe catalog permission of the matching rule with access confirm, or null for a method and path or git rule written directly.
method, urlstringThe request's method (GIT PUSH for a push) and full upstream URL.
agent, purpose, reasonstringThe decoded KeyContain-Agent, KeyContain-Purpose and KeyContain-Reason.
createdAt, expiresAtstringISO 8601 UTC. expiresAt is the creation time plus the confirmation's timeout (§10). Approving does not change it.
decidedAtstring or nullWhen a person approved or declined it. null while pending or if it expired undecided.
{
  "id": "cnf_7Q2MV9XB",
  "status": "pending",
  "service": "svc_qbo_acme",
  "operation": "invoice.create",
  "method": "POST",
  "url": "https://quickbooks.api.intuit.com/v3/company/9130354/invoice",
  "agent": "invoice-runner-2",
  "purpose": "Invoice Northwind Traders for October consulting",
  "reason": "The engagement letter bills monthly, and 10 hours were logged in October",
  "createdAt": "2026-10-05T14:20:11Z",
  "expiresAt": "2026-10-06T14:20:11Z",
  "decidedAt": null
}
{
  "id": "cnf_7Q2MV9XB",
  "status": "approved",
  "service": "svc_qbo_acme",
  "operation": "invoice.create",
  "method": "POST",
  "url": "https://quickbooks.api.intuit.com/v3/company/9130354/invoice",
  "agent": "invoice-runner-2",
  "purpose": "Invoice Northwind Traders for October consulting",
  "reason": "The engagement letter bills monthly, and 10 hours were logged in October",
  "createdAt": "2026-10-05T14:20:11Z",
  "expiresAt": "2026-10-06T14:20:11Z",
  "decidedAt": "2026-10-05T14:23:02Z"
}

Errors

401 KEY_INVALID. 403 IP_NOT_ALLOWED, 429 RATE_LIMITED or TRAFFIC_LIMIT_REACHED (§11). 404 CONFIRMATION_NOT_FOUND if no confirmation with this ID exists for the calling key, including one created by another key (§15). 500 INTERNAL.

14Management API

Agents read and manage the account through KeyContain itself, the built-in KeyContain account service (§8): keys, services, IP groups, ACLs, activity, capacity and the balance. A key can use the management API only if svc_account is attached to it, and only as far as its rules for that service allow.

Anything that could widen access needs a person. The confirm-only operations, keys.create, keys.rotate, services.create, services.update, ipGroups.create, ipGroups.update and acl.update, need access confirm in every rule, because they can hand over a key or widen what keys reach or where they work from. An ACL with a rule that gives one access allow is refused when it is saved, in the dashboard or through this API, with 400 ACL_INVALID (§15). The other writes (revoking and deleting keys, deleting services and IP groups, and changing the account's capacity) can have either access, as the person chooses.
Some approvals ask for the person's password. Approving an acl.update or change set that gives an existing service to a key, or a rotation of another key that has services, needs the account's private half, which only the person's password unlocks in their browser (§6). For the agent nothing changes, except that such an approval may take a moment longer. Creating services, attaching services created in the same change set, removing services and an agent rotating its own key need no password.

Making calls

  • Calls go straight to https://api.keycontain.com/v1/…, not through /p/. The calling key goes in Authorization as for any request (§5).
  • The service is always svc_account, with base URL https://api.keycontain.com/v1. Rules match the path after it, for example /keys/key_5TB1V8/acl. If svc_account is not attached to the calling key, the call is refused with 403 SERVICE_NOT_ATTACHED, where {origin} is https://api.keycontain.com/v1.
  • The key's rules for svc_account are evaluated exactly as for any service (§9), with the account catalog (§12). A rule's keys scope limits it to those keys. Conditions can use path.key on key permissions, path.service and path.group where the catalog lists them, and for activity query.key, query.service and query.agent.
  • A write matched by a rule with access allow is applied at once. A write matched only by rules with access confirm is a request needing confirmation (§10): it needs KeyContain-Agent, KeyContain-Purpose and KeyContain-Reason, is held up to 90 seconds, and otherwise follows CONFIRMATION_REQUIRED and a resend of the identical request. GET /v1/confirmations/{id} works for it too, with the change's operation (§13).
  • Checks run in this order: KEY_INVALID, the IP and limit checks (§11), SERVICE_NOT_ATTACHED, OPERATION_NOT_ALLOWED, NOT_FOUND, then for a write KEY_REVOKED, SERVICE_BUILT_IN or IP_GROUP_IN_USE, then REQUEST_TOO_LARGE for a body over 1 MiB, then REQUEST_INVALID, ACL_INVALID or CHANGES_INVALID and the description headers, all before the request is held, then the confirmation outcome, and finally, when the change is applied, ACL_CHANGED, SERVICE_CHANGED or for a capacity change PAYMENT_REQUIRED (§15).
  • Management calls count toward the account's limits and the calling key's own limits like any request (§11).

Example: an Admin agent

Dana's Admin agent key (key_AD91MN) has only svc_account attached. It may read every key, ACL, service, IP group, the activity and the balance, and ask to change any ACL except its own. Every change waits for Dana.

{
  "services": {
    "svc_account": {
      "rules": [
        { "operation": "keys.read", "access": "allow" },
        { "operation": "acl.read", "access": "allow" },
        { "operation": "acl.update", "access": "confirm",
          "where": { "path.key": { "notIn": ["key_AD91MN"] } } },
        { "operation": "keys.list", "access": "allow" },
        { "operation": "services.read", "access": "allow" },
        { "operation": "ipGroups.read", "access": "allow" },
        { "operation": "activity.read", "access": "allow" },
        { "operation": "billing.read", "access": "allow" }
      ]
    }
  }
}

Its rules have no keys scope, so they cover keys created later too. path.key leaves out the Admin agent itself. To manage only certain keys, a rule would name them instead, for example "keys": ["key_5TB1V8"].

GET https://api.keycontain.com/v1/keys

Permission keys.list (service-level). Every key in the account, including revoked ones. GET /v1/keys/{key} returns one item and is the scoped permission keys.read. Key secrets are never returned. status is active or revoked. A key's own limits are in its ACL (§9). Deleted keys are not listed.

{
  "items": [
    { "id": "key_8KQ2W1", "name": "Ops agent", "last4": "c21e",
      "status": "active",
      "services": ["svc_qbo_acme", "svc_gitlab_acme"] },
    { "id": "key_3MZ7Q0", "name": "Release bot", "last4": "9a04",
      "status": "active",
      "services": ["svc_github_acme"] },
    { "id": "key_5TB1V8", "name": "Bookkeeping agent", "last4": "7f3a",
      "status": "active",
      "services": ["svc_qbo_acme"] },
    { "id": "key_9HD4E2", "name": "Status page writer", "last4": "e4a0",
      "status": "active",
      "services": ["svc_statuspage"] },
    { "id": "key_AD91MN", "name": "Admin agent", "last4": "admn",
      "status": "active",
      "services": ["svc_account"] },
    { "id": "key_MK7Q2V", "name": "KeyContain master key", "last4": "0Z4m",
      "status": "active",
      "services": ["svc_account"] }
  ]
}

GET https://api.keycontain.com/v1/keys/{key}/acl

Permission acl.read. The key's ACL as written and as currently rewritten, in the same shapes as GET /v1/key (§13).

{
  "key": "key_5TB1V8",
  "acl": {
    "ips": ["ipg_office"],
    "services": {
      "svc_qbo_acme": {
        "rules": [
          { "operation": "invoice.read", "access": "allow" },
          { "operation": "invoice.create", "access": "allow",
            "where": { "body.CustomerRef.value": { "in": ["61", "74"] } } },
          { "operation": "invoice.create", "access": "confirm" },
          { "operation": "invoice.update", "access": "confirm" },
          { "operation": "estimate.read", "access": "allow" },
          { "operation": "customer.read", "access": "allow" },
          { "operation": "customer.update", "access": "confirm",
            "where": { "body.Active": true } },
          { "operation": "vendor.read", "access": "allow" },
          { "operation": "bill.read", "access": "allow" },
          { "operation": "payment.read", "access": "allow" },
          { "operation": "item.read", "access": "allow" },
          { "operation": "account.read", "access": "allow" },
          { "operation": "reports.run", "access": "allow" },
          { "operation": "companyinfo.read", "access": "allow" }
        ]
      }
    }
  },
  "effective": {
    "ips": ["198.51.100.0/24", "2001:db8:10::/48"],
    "services": {
      "svc_qbo_acme": {
        "rules": [
          { "method": "GET", "path": "/invoice/{id}", "access": "allow",
            "from": "invoice.read", "rule": 0 },
          { "method": "GET", "path": "/query", "access": "allow",
            "where": { "qbo.entity": "Invoice" }, "from": "invoice.read", "rule": 0 },
          { "method": "POST", "path": "/invoice", "access": "allow",
            "where": { "qbo.action": "create", "body.CustomerRef.value": { "in": ["61", "74"] } },
            "from": "invoice.create", "rule": 1 },
          { "method": "POST", "path": "/invoice", "access": "confirm",
            "where": { "qbo.action": "create" }, "from": "invoice.create", "rule": 2 },
          { "method": "POST", "path": "/invoice", "access": "confirm",
            "where": { "qbo.action": "update" }, "from": "invoice.update", "rule": 3 },
          { "method": "POST", "path": "/customer", "access": "confirm",
            "where": { "qbo.action": "update", "body.Active": true },
            "from": "customer.update", "rule": 6 }
        ]
      }
    }
  }
}

GET https://api.keycontain.com/v1/services

Permission services.read. Every service in the account, including svc_account. GET /v1/services/{service} returns one item. A QuickBooks Online service also has intuitApp, and pricePerRead when its reads are priced, a Plaid service institution and accounts, and a Google Drive service googleAccount, as in GET /v1/key (§13). Upstream credentials are never returned.

{
  "items": [
    { "id": "svc_qbo_acme", "type": "quickbooks", "label": "Acme Holdings",
      "baseUrl": "https://quickbooks.api.intuit.com/v3/company/9130354",
      "intuitApp": "keycontain", "pricePerRead": "0.01" },
    { "id": "svc_gitlab_acme", "type": "gitlab", "label": null,
      "baseUrl": "https://code.acme.dev" },
    { "id": "svc_github_acme", "type": "github", "label": "acme-inc",
      "baseUrl": "https://api.github.com", "gitBaseUrl": "https://github.com" },
    { "id": "svc_statuspage", "type": "http", "label": "Status page",
      "baseUrl": "https://api.statuspage.example" },
    { "id": "svc_account", "type": "account", "label": null,
      "baseUrl": "https://api.keycontain.com/v1" }
  ]
}

PUT https://api.keycontain.com/v1/keys/{key}/acl

Permission acl.update, whose rules need access confirm. The body is the complete new ACL in the format of §9, with every service's rules. It replaces the key's ACL when a person approves, and the services in it become the key's attached services: a service left out is detached, and a new one is attached. Every service must exist in the account. KeyContain validates the body when the request arrives, before holding it, and again when applying it. An invalid ACL is refused with 400 ACL_INVALID and nothing changes. KeyContain records the version of the key's ACL when the request arrives. If the ACL changed before the approved request is applied, for example because the person edited it in the dashboard, applying it is refused with 409 ACL_CHANGED and nothing changes, so an approval never undoes a newer edit (§15).

admin-claude, using the Admin agent key, asks to let the Bookkeeping agent delete invoices after Dana confirms each one:

PUT https://api.keycontain.com/v1/keys/key_5TB1V8/acl
Authorization: Bearer kc_live_Ad91Mn4Tq8Rz2Xc6Wb3Ky7Ph5Ld1Vg0admn
KeyContain-Agent: admin-claude
KeyContain-Purpose: Let the Bookkeeping agent delete invoices, with your confirmation for each one.
KeyContain-Reason: Northwind asked for invoice 1031 to be deleted, and the agent cannot do it today.
Content-Type: application/json

{
  "ips": ["ipg_office"],
  "services": {
    "svc_qbo_acme": {
      "rules": [
        { "operation": "invoice.read", "access": "allow" },
        { "operation": "invoice.create", "access": "allow",
          "where": { "body.CustomerRef.value": { "in": ["61", "74"] } } },
        { "operation": "invoice.create", "access": "confirm" },
        { "operation": "invoice.update", "access": "confirm" },
        { "operation": "invoice.delete", "access": "confirm" },
        { "operation": "estimate.read", "access": "allow" },
        { "operation": "customer.read", "access": "allow" },
        { "operation": "customer.update", "access": "confirm",
          "where": { "body.Active": true } },
        { "operation": "vendor.read", "access": "allow" },
        { "operation": "bill.read", "access": "allow" },
        { "operation": "payment.read", "access": "allow" },
        { "operation": "item.read", "access": "allow" },
        { "operation": "account.read", "access": "allow" },
        { "operation": "reports.run", "access": "allow" },
        { "operation": "companyinfo.read", "access": "allow" }
      ]
    }
  }
}

Dana gets a push notification. The confirmation shows the agent's description and, above the full request, the change as a diff of the Bookkeeping agent's ACL:

  "svc_qbo_acme": {
    "rules": [
      { "operation": "invoice.read", "access": "allow" },
      { "operation": "invoice.create", "access": "allow", "where": { … } },
      { "operation": "invoice.create", "access": "confirm" },
      { "operation": "invoice.update", "access": "confirm" },
+     { "operation": "invoice.delete", "access": "confirm" },
      { "operation": "estimate.read", "access": "allow" },
      … 9 more rules, unchanged …
    ]
  }

In the dashboard the same change is one new row on the Bookkeeping agent's QuickBooks service: Invoices · Delete, Ask me, right below Invoices · Update.

If she approves within 90 seconds, the ACL is replaced and the response is:

HTTP/1.1 200 OK
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZN4A8K
KeyContain-Confirmation: cnf_6WQ3HB8T; approved

{
  "key": "key_5TB1V8",
  "acl": {
    "ips": ["ipg_office"],
    "services": {
      "svc_qbo_acme": {
        "rules": [
          { "operation": "invoice.read", "access": "allow" },
          { "operation": "invoice.create", "access": "allow",
            "where": { "body.CustomerRef.value": { "in": ["61", "74"] } } },
          { "operation": "invoice.create", "access": "confirm" },
          { "operation": "invoice.update", "access": "confirm" },
          { "operation": "invoice.delete", "access": "confirm" },
          { "operation": "estimate.read", "access": "allow" },
          { "operation": "customer.read", "access": "allow" },
          { "operation": "customer.update", "access": "confirm",
            "where": { "body.Active": true } },
          { "operation": "vendor.read", "access": "allow" },
          { "operation": "bill.read", "access": "allow" },
          { "operation": "payment.read", "access": "allow" },
          { "operation": "item.read", "access": "allow" },
          { "operation": "account.read", "access": "allow" },
          { "operation": "reports.run", "access": "allow" },
          { "operation": "companyinfo.read", "access": "allow" }
        ]
      }
    }
  }
}

Otherwise the agent gets 403 CONFIRMATION_DENIED or 403 CONFIRMATION_REQUIRED, as for any confirmation (§10). The same request for key_AD91MN itself matches no rule and is refused with 403 OPERATION_NOT_ALLOWED.

POST https://api.keycontain.com/v1/keys

Permission keys.create, whose rules need access confirm. Creates a key with no services attached and no limits of its own. Keys are free (§11). The body is { "name": "…" }. After the person approves, the response delivers the new key's value once. It is never shown in the dashboard. KeyContain sees it only while creating and delivering it, and never stores it (§6).

{
  "id": "key_QX4R8D",
  "name": "Invoice drafter",
  "key": "kc_live_Qx4R8dT2vL9Wn6Yc3Kb7Mh1Ps5Gz0f8Ju"
}

POST https://api.keycontain.com/v1/services

Permission services.create, whose rules need access confirm. The body gives the service's type, an optional label, and its non-secret fields. The agent never sends upstream secrets. The person types them into the confirmation screen before approving, and KeyContain stores them. After approval the response is the new service, in the shape of GET /v1/services.

TypeFields the agent sendsSecrets the person enters
quickbooksNone besides labelNone to type for KeyContain's Intuit app. The person connects the company with Intuit, through KeyContain's Intuit app or their own, whose client ID and client secret they type.
githubapiBaseUrl (default https://api.github.com), gitBaseUrl (default https://github.com)Access token
gitlabbaseUrl (default https://gitlab.com)Access token
cloudflareNone besides labelAPI token
porkbunNone besides labelAPI key, Secret API key
namecheapapiUserAPI key
googlecloudNone besides labelService account key file
appstoreconnectissuerIdKey ID and API key file (.p8), since the key ID belongs to the key the person creates
googledriveNone besides labelNone to type. The person connects a Google account with Google's sign-in.
plaidRefused with 400 REQUEST_INVALID. A person adds Plaid services in the dashboard, where they sign in to their bank (below).
httpbaseUrl, header (default Authorization), format (default Bearer {secret})Secret
keycontainbaseUrlKeyContain key
{
  "type": "gitlab",
  "label": null,
  "baseUrl": "https://code.acme.dev"
}

QuickBooks Online

The agent sends only the type and an optional label. There is no company ID and no environment: KeyContain connects production companies only, and the company is the one the person picks at Intuit.

{
  "type": "quickbooks",
  "label": "Northwind Traders"
}

On the confirmation screen the person chooses the Intuit app, KeyContain's or their own, connects with Intuit's sign-in and picks the company. The confirmation then shows the connected company, and Approve stays disabled until a company is connected. A company already connected in the account is refused there, and the person can connect another one. If the company is connected by another request after this one was made, applying it is refused with 409 SERVICE_CHANGED (§15). After approval the response is the new service. Its base URL ends with the ID of the connected company, and intuitApp says which Intuit app the person chose. Here they chose their own, so reads are free and there is no pricePerRead:

{
  "id": "svc_qbo_northwind",
  "type": "quickbooks",
  "label": "Northwind Traders",
  "baseUrl": "https://quickbooks.api.intuit.com/v3/company/4620816",
  "intuitApp": "own"
}

Google Drive

As for QuickBooks Online, the agent sends only the type and an optional label, and the person connects on the confirmation screen.

{
  "type": "googledrive",
  "label": "Dana's Drive"
}

On the confirmation screen the person connects a Google account with Google's sign-in, through KeyContain's Google app, and grants full Drive read and write access. Each key is limited by its permission rules. The confirmation then shows the account's email, and Approve stays disabled until an account is connected. A Google account already connected in the account is refused there, and the person can connect another one. If the Google account is connected by another request after this one was made, applying it is refused with 409 SERVICE_CHANGED (§15). After approval the response is the new service:

{
  "id": "svc_drive_dana",
  "type": "googledrive",
  "label": "Dana's Drive",
  "baseUrl": "https://www.googleapis.com",
  "googleAccount": "dana@acme.dev"
}

Plaid

Agents can't add Plaid services. A person adds each bank in the dashboard, where they sign in to it in Plaid's window. services.create with "type": "plaid" is refused with 400 REQUEST_INVALID, or in a change set CHANGES_INVALID, before the request is held, with a person adds and reconnects Plaid services in the KeyContain dashboard, where they sign in to their bank (§15).

A body with an unknown type, a missing field or a field that does not fit its type is refused with 400 REQUEST_INVALID before the request is held (§15). For quickbooks that includes companyId and environment, for example companyId isn't a field of this request. The same applies to POST /v1/keys without a name.

PATCH https://api.keycontain.com/v1/services/{service}

Permission services.update, whose rules need access confirm, since a new base URL could send the upstream key somewhere else. The body has any of label and the type's non-secret fields from the table above. "askForSecrets": true asks the person for a new upstream credential on the confirmation screen, for example after a token expired. What the person does depends on the type:

  • GitHub, GitLab, Cloudflare, Porkbun, Namecheap, Generic HTTP and KeyContain: they type new secrets, the ones in the table above.
  • Google Cloud, Firebase and Play and App Store Connect: they drop or choose a new key file, and for App Store Connect type its Key ID.
  • Google Drive: they reconnect the service with Google's sign-in and must pick the same Google account. A different account is refused there and nothing changes. Approve stays disabled until the same account is connected. If the service changed after the request was made, applying it is refused with 409 SERVICE_CHANGED, as for QuickBooks Online.
  • Plaid: refused. "askForSecrets": true on a Plaid service gets 400 REQUEST_INVALID, or in a change set CHANGES_INVALID, before the request is held, with a person adds and reconnects Plaid services in the KeyContain dashboard, where they sign in to their bank. The person reconnects it on the service's page (§8). A Plaid service's label can be changed like any other.
  • QuickBooks Online: they reconnect the service with Intuit, through the Intuit app it already uses, and must pick the same company. A different company is refused there and nothing changes. Approve stays disabled until the same company is connected. The service keeps its company, base URL, label and rules. Only a person can switch the service to another Intuit app, in the dashboard. If the service changed after the request was made (it was reconnected or deleted meanwhile, or a key it reaches was rotated), applying it is refused with 409 SERVICE_CHANGED (§15).

The type can't be changed. After approval the response is the service, in the shape of GET /v1/services. svc_account is built in and can't be changed: 409 SERVICE_BUILT_IN.

{
  "label": "Acme code",
  "askForSecrets": true
}

setup-claude, using the KeyContain master key, asks Dana to reconnect QuickBooks Online after its connection stopped working:

PATCH https://api.keycontain.com/v1/services/svc_qbo_acme
Authorization: Bearer kc_live_Mk7Q2vR9xT4Lw8Nc3Bd6Yk1Hs5Fp0Z4m
KeyContain-Agent: setup-claude
KeyContain-Purpose: Reconnect QuickBooks Online (Acme Holdings) so invoice reads work again.
KeyContain-Reason: QuickBooks stopped accepting KeyContain's connection at 13:20, and every invoice read since has failed with UPSTREAM_CREDENTIAL_FAILED.
Content-Type: application/json

{ "askForSecrets": true }
HTTP/1.1 200 OK
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZS1R4C
KeyContain-Confirmation: cnf_3RQB8C0N; approved

{
  "id": "svc_qbo_acme",
  "type": "quickbooks",
  "label": "Acme Holdings",
  "baseUrl": "https://quickbooks.api.intuit.com/v3/company/9130354",
  "intuitApp": "keycontain",
  "pricePerRead": "0.01"
}

DELETE https://api.keycontain.com/v1/services/{service}

Permission services.delete, with access allow or confirm. Deletes the service and its upstream key. Every key it was attached to loses it, and their rules for it are deleted. The response is 204 with no body. svc_account can't be deleted: 409 SERVICE_BUILT_IN.

POST https://api.keycontain.com/v1/keys/{key}/rotate

Permission keys.rotate, whose rules need access confirm: receiving a key's new value is the same as being given that key. No body. When the rotation goes through, the old value stops working and the response delivers the new value once. While the confirmation is pending, the old value keeps working, so an agent rotating its own key can still resend the identical request after approval. KeyContain sees the new value only while creating and delivering it, and never stores it. When an agent rotates its own key, the request carries the old value, so KeyContain seals the key's services to the new one itself. When it rotates another key that has services, the person's approval asks for their password, because their browser seals those services to the new value (§6).

{
  "id": "key_8KQ2W1",
  "name": "Ops agent",
  "key": "kc_live_8Kq2W1Lm5Tz9Rv3Xc7Nb2Yd4Hp6Fs0e91a"
}

POST https://api.keycontain.com/v1/keys/{key}/revoke

Permission keys.revoke, with access allow or confirm. With allow, an agent that thinks a key has leaked can stop it at once. No body. From then on the key's requests get 401 KEY_INVALID. It stays in the account with its ACL and activity, marked revoked, and can't be reactivated. Revoking a revoked key returns the same response. A key may revoke itself, and that response is the last one it gets.

{
  "id": "key_3MZ7Q0",
  "name": "Release bot",
  "status": "revoked",
  "revokedAt": "2026-10-05T15:02:44Z"
}

DELETE https://api.keycontain.com/v1/keys/{key}

Permission keys.delete, with access allow or confirm. The key stops working and is removed with its ACL. Activity keeps its past requests under its name. The response is 204 with no body, and later calls with the key's ID get 404 NOT_FOUND.

IP groups

An IP group is a named list of 1 to 100 IPv4 or IPv6 addresses and CIDR ranges, which keys' ACLs name in ips (§9). Changing a group changes where every key that names it works from, so creating and changing groups is confirm-only.

CallOperationBodyResponse
GET/v1/ip-groups, /v1/ip-groups/{group}ipGroups.read200, the list in items, or one group
POST/v1/ip-groupsipGroups.create, confirm only{ "name", "addresses" }200 after approval, the new group
PUT/v1/ip-groups/{group}ipGroups.update, confirm only{ "name", "addresses" }, replacing both200 after approval, the group. The person sees the addresses as a diff
DELETE/v1/ip-groups/{group}ipGroups.delete204, or 409 IP_GROUP_IN_USE while any key's ips names it
{
  "items": [
    { "id": "ipg_office", "name": "Office",
      "addresses": ["198.51.100.0/24", "2001:db8:10::/48"],
      "keys": ["key_8KQ2W1", "key_3MZ7Q0", "key_5TB1V8"] },
    { "id": "ipg_ci", "name": "CI runners",
      "addresses": ["203.0.113.0/26"],
      "keys": ["key_8KQ2W1", "key_3MZ7Q0"] },
    { "id": "ipg_dana_home", "name": "Dana home",
      "addresses": ["192.0.2.44"],
      "keys": [] }
  ]
}

keys lists the keys whose ips name the group. An invalid name or address is refused with 400 REQUEST_INVALID.

GET https://api.keycontain.com/v1/activity

Permission activity.read, service-level. Every request made with the account's keys, newest first, for export to other tools. Activity is kept for 90 days. Request and response bodies are not kept. A rule can narrow what a key may read with conditions on query.key, query.service or query.agent.

Query parameterMeaning
key, service, agentOnly requests made with this key, matched to this service, or sent with this KeyContain-Agent.
outcomeforwarded, refused or waiting.
since, untilISO 8601 UTC times.
limitItems per page, 1 to 1,000, default 100.
cursorFrom the previous page's next URL. Only follow next, don't build it.
curl "https://api.keycontain.com/v1/activity?key=key_5TB1V8&since=2026-10-01T00:00:00Z&limit=100" \
  -H "Authorization: Bearer kc_live_Ad91Mn4Tq8Rz2Xc6Wb3Ky7Ph5Ld1Vg0admn"
{
  "items": [
    { "id": "req_01J9ZM2K7Q", "time": "2026-10-05T14:20:11Z",
      "key": "key_5TB1V8", "agent": "invoice-runner-2", "ip": "198.51.100.23",
      "service": "svc_qbo_acme", "operation": "invoice.create",
      "method": "POST",
      "url": "https://quickbooks.api.intuit.com/v3/company/9130354/invoice",
      "outcome": "forwarded", "status": 200,
      "confirmation": "cnf_7Q2MV9XB",
      "purpose": "Invoice Northwind Traders for October consulting",
      "reason": "The engagement letter bills monthly, and 10 hours were logged in October",
      "bytesIn": 1834, "bytesOut": 2210 },
    { "id": "req_01J9ZK9Q1R", "time": "2026-10-05T13:02:40Z",
      "key": "key_5TB1V8", "agent": "invoice-runner-2", "ip": "198.51.100.23",
      "service": "svc_qbo_acme", "operation": "invoice.delete",
      "method": "POST",
      "url": "https://quickbooks.api.intuit.com/v3/company/9130354/invoice?operation=delete",
      "outcome": "refused", "error": "OPERATION_NOT_ALLOWED",
      "confirmation": null, "purpose": null, "reason": null,
      "bytesIn": 912, "bytesOut": 486 }
  ],
  "next": "https://api.keycontain.com/v1/activity?cursor=act_9QX2LM4T"
}

A refused item has error, its error code, instead of status. next is null on the last page. A bad parameter is refused with 400 REQUEST_INVALID.

GET https://api.keycontain.com/v1/billing

Permission billing.read, service-level. The account's capacity, its balance and this month's priced QuickBooks reads (§11). Amounts are US dollars as decimal strings. Nothing here can add funds.

FieldTypeMeaning
unitsnumberThe account's capacity units, 0 for the free allowance only.
limitsobjectThe limits all keys in the account share now: requestsPerMinute and trafficPerMonth in bytes. With units, 50 a minute and 1 GB a month per unit. With no units, or while paused, the free allowance, 10 and 104857600.
dailyCoststringWhat capacity costs a day: units times $6/365. Priced reads are not included, since each is taken as it is answered.
balance, currencystringThe balance, and usd.
lastsUntilstring or nullThe last day (UTC) the balance pays for at the recent spend, as the dashboard shows it: dailyCost plus what priced reads have cost a day on average over the last 7 full days (UTC). It is today plus the number of whole days the balance covers at that spend. null while nothing is spent (no units and no priced reads in those 7 days), or while paused.
readsThisMonthobjectQuickBooks reads through KeyContain's Intuit app charged this calendar month (UTC), for all services together (§11): count, the number of successful reads, and amount, what they cost. With none, { "count": 0, "amount": "0.00" }.
pausedbooleantrue when the balance couldn't pay a day's charge, so the account has dropped to the free allowance until funds are added (§11). Its units stay set and cost nothing meanwhile.
autoTopUpobject or nullThe account's automatic top-up, below and amount, or null when it is off.
{
  "units": 5,
  "limits": { "requestsPerMinute": 250, "trafficPerMonth": 5368709120 },
  "dailyCost": "0.0822",
  "balance": "18.42",
  "currency": "usd",
  "lastsUntil": "2026-11-12",
  "paused": false,
  "autoTopUp": { "below": "5.00", "amount": "25.00" },
  "readsThisMonth": { "count": 185, "amount": "1.85" }
}
{
  "units": 5,
  "limits": { "requestsPerMinute": 10, "trafficPerMonth": 104857600 },
  "dailyCost": "0.0822",
  "balance": "0.03",
  "currency": "usd",
  "lastsUntil": null,
  "paused": true,
  "autoTopUp": null,
  "readsThisMonth": { "count": 2316, "amount": "23.16" }
}

When a charge leaves the balance below autoTopUp.below, KeyContain adds autoTopUp.amount from the saved card, at most once a day. The person sets it in the dashboard, and each automatic top-up appears as a deposit in the account's history.

PUT https://api.keycontain.com/v1/billing/capacity

Permission capacity.update, service-level, with access allow or confirm. It spends the account's money, so the KeyContain master key's rule for it has access confirm (§4). The body is { "units": n }, the number of capacity units the account should have, a whole number from 0 to 1,000 (§11).

  • Adding units raises the limits at once and takes today's charge for the added units from the balance. Removing units lowers the limits at once and refunds nothing. 0 leaves the free allowance only.
  • While the account is paused, adding units needs a balance that pays today's charge for all of them, and taking it ends the pause. Removing units always works: if the balance then pays today's charge for the units left, it is taken and the pause ends, otherwise the account stays paused. A change to 0 units leaves the free allowance and ends the pause.
  • Sending the number of units the account already has changes nothing and returns the same response.
  • A body without units, or with a value that is not a whole number from 0 to 1,000, is refused with 400 REQUEST_INVALID before the request is held, with units is required or units must be a whole number from 0 to 1,000 (§15). An account can have at most 1,000 units.

setup-claude, using the KeyContain master key, asks for one more unit:

PUT https://api.keycontain.com/v1/billing/capacity
Authorization: Bearer kc_live_Mk7Q2vR9xT4Lw8Nc3Bd6Yk1Hs5Fp0Z4m
KeyContain-Agent: setup-claude
KeyContain-Purpose: Go from 5 to 6 capacity units, so your keys share 300 requests a minute and 6 GB a month.
KeyContain-Reason: Your keys have used 4.5 GB of this month's 5 GB, and the nightly exports need about 1 GB more.
Content-Type: application/json

{ "units": 6 }

The person gets a push notification. Besides the agent's description, the confirmation screen shows the current and the new number of units with the limits each gives, the new cost a month and a day ($3 a month, about 9.9 cents a day), the charge taken today for the added unit, and the balance with how long it lasts at the new cost. If the balance can't pay today's charge, it asks the person for a deposit right there in a browser, with a card when the account has none yet, and Approve stays disabled until the card details are complete. Nothing can be bought in the KeyContain app, so there a capacity change that could take a charge can't be approved, and the person approves it in a browser.

If the person approves within 90 seconds, the capacity changes and the response is the account's billing, in the shape of GET /v1/billing:

HTTP/1.1 200 OK
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZP7N3Q
KeyContain-Confirmation: cnf_8VT5KC2R; approved

{
  "units": 6,
  "limits": { "requestsPerMinute": 300, "trafficPerMonth": 6442450944 },
  "dailyCost": "0.0986",
  "balance": "18.40",
  "currency": "usd",
  "lastsUntil": "2026-11-10",
  "paused": false,
  "autoTopUp": { "below": "5.00", "amount": "25.00" },
  "readsThisMonth": { "count": 185, "amount": "1.85" }
}

Otherwise the agent gets 403 CONFIRMATION_DENIED or 403 CONFIRMATION_REQUIRED, as for any confirmation (§10). If the balance can't pay today's charge when the change is applied, at once under a rule with access allow or after approval, it is refused with 402 PAYMENT_REQUIRED and nothing changes (§15).

POST https://api.keycontain.com/v1/changes

A change set: several changes in one request, which the person reviews and approves once. This is how an agent sets up an account after pairing.

  • changes is an ordered list of at most 100 changes. Each change has an op, one of the account's write operations:
    • services.create (with ref and service, as for POST /v1/services), services.update (with service and changes, as for PATCH), services.delete (with service)
    • keys.create (with ref and key, as for POST /v1/keys), and keys.rotate, keys.revoke, keys.delete (each with key)
    • ipGroups.create (with ref and ipGroup), ipGroups.update (with ipGroup, its ID, and name and addresses), ipGroups.delete (with ipGroup)
    • acl.update (with key and acl, as for PUT /v1/keys/{key}/acl)
    • capacity.update (with units, as for PUT /v1/billing/capacity), at most once in a change set
  • A ref is a short name the agent chooses (letters, digits, - and _), unique in the change set. A later change refers to it as @ref: a key, a service in an acl, or an IP group in an acl's ips. Existing ids such as key_5TB1V8, svc_qbo_acme or ipg_office can be used too.
  • Every change must be allowed by the calling key's rules for svc_account, each by its own operation. For an acl.update on a key created in the same change set, path.key is that new key's ID. A new key's ID can't be in a keys scope yet, so only rules without a scope cover changes to it. A change no rule covers makes the whole change set fail with 403 OPERATION_NOT_ALLOWED, naming that change's operation.
  • If any change is matched only by rules with access confirm, the whole change set waits for one confirmation covering all its changes, with the three description headers (§10). If every change is matched by a rule with access allow, it is applied at once. The confirmation screen shows the cost of any capacity change.
  • KeyContain checks the change set when it arrives, before holding it, and refuses an invalid one with 400 CHANGES_INVALID (§15). It checks again when applying. The changes are applied together after approval, all or none. If the ACL of a key named by an acl.update changed after the change set arrived, applying it is refused with 409 ACL_CHANGED, if a QuickBooks Online company or Google account it connects was connected by another request meanwhile or a service it reconnects changed, with 409 SERVICE_CHANGED, and if the balance can't pay today's charge for a capacity.update, with 402 PAYMENT_REQUIRED. Either way nothing is applied.

setup-claude, using the KeyContain master key it got by pairing, sets up QuickBooks Online and a Bookkeeping agent key, and adds the account's first capacity unit:

POST https://api.keycontain.com/v1/changes
Authorization: Bearer kc_live_Mk7Q2vR9xT4Lw8Nc3Bd6Yk1Hs5Fp0Z4m
KeyContain-Agent: setup-claude
KeyContain-Purpose: Set up QuickBooks Online and a Bookkeeping agent key that reads everything and asks you before creating or changing invoices, and add one capacity unit.
KeyContain-Reason: You asked me to automate bookkeeping for Acme Holdings, these rules keep every change under your review, and the free 10 requests a minute are too few for the monthly close.
Content-Type: application/json

{
  "changes": [
    { "op": "services.create", "ref": "qbo",
      "service": { "type": "quickbooks", "label": "Acme Holdings" } },
    { "op": "keys.create", "ref": "book",
      "key": { "name": "Bookkeeping agent" } },
    { "op": "acl.update", "key": "@book",
      "acl": { "services": { "@qbo": { "rules": [
        { "operation": "invoice.read", "access": "allow" },
        { "operation": "invoice.create", "access": "confirm" },
        { "operation": "invoice.update", "access": "confirm" },
        { "operation": "estimate.read", "access": "allow" },
        { "operation": "customer.read", "access": "allow" },
        { "operation": "vendor.read", "access": "allow" },
        { "operation": "bill.read", "access": "allow" },
        { "operation": "payment.read", "access": "allow" },
        { "operation": "item.read", "access": "allow" },
        { "operation": "account.read", "access": "allow" },
        { "operation": "purchase.read", "access": "allow" },
        { "operation": "journalentry.read", "access": "allow" },
        { "operation": "deposit.read", "access": "allow" },
        { "operation": "transfer.read", "access": "allow" },
        { "operation": "salesreceipt.read", "access": "allow" },
        { "operation": "creditmemo.read", "access": "allow" },
        { "operation": "billpayment.read", "access": "allow" },
        { "operation": "vendorcredit.read", "access": "allow" },
        { "operation": "attachable.read", "access": "allow" },
        { "operation": "employee.read", "access": "allow" },
        { "operation": "reports.run", "access": "allow" },
        { "operation": "companyinfo.read", "access": "allow" }
      ] } } } },
    { "op": "capacity.update", "units": 1 }
  ]
}

The person gets a push notification. The confirmation lists every change, labeled as new or changed. For each new service it asks for that service's secrets, for a Google Drive service the person connects a Google account, and for a QuickBooks Online service, as here, the person connects the company instead: they choose KeyContain's Intuit app or their own, sign in to Intuit and pick the company, and the confirmation shows the connected company before they approve. A company already connected in the account is refused there, and the person can connect another one. Approve stays disabled until every new service has its secrets, its company or its Google account. The ACL changes are shown as a diff, as for acl.update.

A change set may also change the account's capacity. For a capacity.update the confirmation shows the current and the new number of units with the limits each gives, the cost, $0.50 a month per unit taken from the balance at about 1.6 cents a day each, and the balance. If the balance can't pay today's charge for the added units, it asks the person for a deposit right there in a browser, with a card when the account has none yet, and Approve stays disabled until the card details are complete. The capacity change is applied with the other changes, so the new key works within the larger limits from the start.

If the person approves within 90 seconds, the response maps each ref to what was created and delivers every new key's value once. With a capacity.update, capacity gives the account's units and limits after the change. Rotated keys appear in keys under their IDs, with their new values, and created IP groups in ipGroups:

HTTP/1.1 200 OK
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZP4F6H
KeyContain-Confirmation: cnf_2SR8LK4N; approved

{
  "services": { "qbo": "svc_qbo_acme" },
  "keys": {
    "book": { "id": "key_5TB1V8", "key": "kc_live_5Tb1Vq8Nz3Lw6Rc9Xd2Kp4Hm7Ys07f3a" }
  },
  "capacity": {
    "units": 1,
    "limits": { "requestsPerMinute": 50, "trafficPerMonth": 1073741824 }
  }
}

Dana connected Acme Holdings (company 9130354), through KeyContain's Intuit app, so GET /v1/services/svc_qbo_acme now gives its base URL, https://quickbooks.api.intuit.com/v3/company/9130354, with "intuitApp": "keycontain" and "pricePerRead": "0.01". The change set's response names created services by ID only.

Otherwise the agent gets 403 CONFIRMATION_DENIED or 403 CONFIRMATION_REQUIRED. After approval, sending the identical change set again before expiresAt returns this same response, once (§10).

Errors

For every management call: 401 KEY_INVALID. 403 IP_NOT_ALLOWED, 429 RATE_LIMITED or TRAFFIC_LIMIT_REACHED (§11). 403 SERVICE_NOT_ATTACHED or OPERATION_NOT_ALLOWED. 404 NOT_FOUND for an unknown key, service or IP group ID (§15). For a write, 409 KEY_REVOKED, SERVICE_BUILT_IN or IP_GROUP_IN_USE, 413 REQUEST_TOO_LARGE for a body over 1 MiB (§10), 400 REQUEST_INVALID, ACL_INVALID (§15) or CHANGES_INVALID (§15), DESCRIPTION_REQUIRED or DESCRIPTION_INVALID, the confirmation outcomes, and 402 PAYMENT_REQUIRED for a capacity change the balance can't pay when it is applied. 500 INTERNAL.

15Errors

When KeyContain itself refuses a request or fails, it answers with this envelope. Responses from the upstream service, including its errors, are passed through unchanged.

{
  "error": {
    "code": "OPERATION_NOT_ALLOWED",   // machine-readable, branch on this
    "message": "…",                    // fixed wording below, safe to show to people and agents
    "service": "svc_gitlab_acme",      // when a service was matched
    "operation": "merge_requests.merge", // when the request maps to a catalog permission, otherwise null
    "permissionsUrl": "https://api.keycontain.com/v1/key", // on SERVICE_NOT_ATTACHED and OPERATION_NOT_ALLOWED
    "confirmation": "cnf_7Q2MV9XB",    // on CONFIRMATION_REQUIRED and CONFIRMATION_DENIED
    "statusUrl": "https://api.keycontain.com/v1/confirmations/cnf_7Q2MV9XB", // on CONFIRMATION_REQUIRED
    "expiresAt": "2026-10-06T14:20:11Z", // on CONFIRMATION_REQUIRED: resend after approval, before this time
    "limit": "key",                    // on RATE_LIMITED and TRAFFIC_LIMIT_REACHED, except for pairing: "key" or "account"
    "retryAfter": 18,                  // on RATE_LIMITED and TRAFFIC_LIMIT_REACHED, seconds, also in Retry-After
    "resetsAt": "2026-11-01T00:00:00Z", // on TRAFFIC_LIMIT_REACHED
    "ip": "192.0.2.81",                // on IP_NOT_ALLOWED
    "keys": ["key_8KQ2W1"],            // on IP_GROUP_IN_USE
    "at": "/services/svc_qbo_acme/rules/4/operation", // on ACL_INVALID and CHANGES_INVALID: where in the body
    "requestId": "req_01J9ZK4W3B"      // also in the KeyContain-Request-Id header
  }
}

Every KeyContain response, success or error, has a KeyContain-Request-Id header. Messages are fixed copy. {…} parts are filled from the request.

StatusCodeMessage
401KEY_INVALIDThis request has no valid KeyContain key. Send your key in the Authorization header, as a Bearer token or as the password.
403IP_NOT_ALLOWEDThis key can't be used from {ip}. Its ACL allows only certain IP addresses. Ask the person who manages it in the KeyContain dashboard to allow this address.
402PAYMENT_REQUIREDA capacity change: The account's balance can't pay today's charge for this capacity. Add funds in the KeyContain dashboard.
A QuickBooks read through KeyContain's Intuit app: The account's balance can't pay {price} for this read on {service type} ({service id}). Reads through KeyContain's Intuit app are refused until funds are added. Ask the person who manages the account to add funds in the KeyContain dashboard. Writes still work.
429RATE_LIMITEDThe key's own limit: This key's limit of {n} requests a minute is used up. Try again in {seconds} seconds. Check usage with GET https://api.keycontain.com/v1/key.
The account's limit: Your account's limit of {n} requests a minute, shared by all its keys, is used up. Try again in {seconds} seconds, or add capacity in the KeyContain dashboard.
Pairing: Too many pairing requests from this IP address. Try again in {seconds} seconds.
429TRAFFIC_LIMIT_REACHEDThe key's own limit: This key's limit of {size} of traffic for {month} is used up. Its requests are refused until {resetsAt}, unless its limit is raised in the KeyContain dashboard.
The account's limit: Your account's limit of {size} of traffic for {month} is used up. Requests with all its keys are refused until {resetsAt}, unless capacity is added in the KeyContain dashboard.
400UPSTREAM_URL_INVALIDThe URL after /p/ must be a complete https:// URL, for example https://api.keycontain.com/p/https://gitlab.com/api/v4/projects.
Too long: The URL after /p/ is longer than 8 KiB.
Dot segment: The URL after /p/ can't contain . or .. path segments.
User name or password: The URL after /p/ can't contain a user name or password.
403SERVICE_NOT_ATTACHEDThis KeyContain key has no service attached for {origin}. To see which services this key can reach, call GET https://api.keycontain.com/v1/key with the same key.
403OPERATION_NOT_ALLOWEDThis KeyContain key does not allow {METHOD} {path} on {service type} ({service id}). To see what this key may do, call GET https://api.keycontain.com/v1/key with the same key.
A Plaid endpoint outside the catalog: KeyContain forwards only the Plaid endpoints in its catalog, and {METHOD} {path} isn't one of them. To see what this key may do, call GET https://api.keycontain.com/v1/key with the same key.
Intuit's sign-in or token endpoint: KeyContain doesn't forward requests to Intuit's sign-in and token endpoints. It connects QuickBooks Online and refreshes access itself.
Google's sign-in or token endpoint: KeyContain doesn't forward requests to Google's sign-in and token endpoints. It connects Google Drive and refreshes access itself.
400PLAID_SERVICE_REQUIREDSend the ID of a Plaid service attached to this key as access_token, such as {service id}. GET https://api.keycontain.com/v1/key lists them.
400DESCRIPTION_REQUIREDThis request needs a person's confirmation. Send KeyContain-Agent, KeyContain-Purpose and KeyContain-Reason headers saying which agent is asking, what the request is for and why it is necessary.
400DESCRIPTION_INVALIDKeyContain-Agent must be at most 200 characters, and KeyContain-Purpose and KeyContain-Reason at most 1,000 characters each, of text (non-ASCII as percent-encoded UTF-8).
400UPLOAD_UNREADABLEKeyContain can't read this upload unambiguously. Send it as standard multipart/form-data without Content-Encoding, with at least one file. For each file, send one file_metadata_N part with its JSON metadata and one file_content_N part with its bytes. Use each part name once, keep each part header on one line in UTF-8 without encoded parameters such as filename*=, put nothing but blank lines before the first boundary or after the closing one, end with the closing boundary, and choose a boundary that appears only at the delimiters.
403CONFIRMATION_DENIEDA person declined this request in the KeyContain dashboard.
403CONFIRMATION_REQUIREDThis request needs a person's confirmation in the KeyContain dashboard. After it is approved, send the identical request again before {expiresAt}. Check its status with GET https://api.keycontain.com/v1/confirmations/{id}.
404CONFIRMATION_NOT_FOUNDNo confirmation with this ID exists for this KeyContain key.
413REQUEST_TOO_LARGEThis request needs a person's confirmation, and a request waiting for confirmation can have a body of at most 10 MiB.
Management API call: A management API call can have a body of at most 1 MiB.
Form or JSON body over 10 MiB: A form or JSON body sent through KeyContain can be at most 10 MiB.
404NOT_FOUNDNo key, service or IP group with this ID exists in this account.
404NOT_FOUNDOnly for a method and path that aren't an endpoint: KeyContain has no {METHOD} {path} endpoint. See https://api.keycontain.com/docs. (The link is the environment's own API host.)
409KEY_REVOKEDThis key is revoked. A revoked key can only be read or deleted.
409SERVICE_BUILT_INsvc_account is built in and can't be changed or deleted.
409ACL_CHANGEDThis key's ACL changed after the request was made. Ask again with the current ACL.
409SERVICE_CHANGEDA QuickBooks Online company connected meanwhile: {company name} (company {company ID}) was connected by another request after this one was made. Ask again to connect a different company.
A Google account connected meanwhile: {email} was connected by another request after this one was made. Ask again to connect a different Google account.
A service to reconnect changed: This service changed after the request was made. Ask again to reconnect it.
409IP_GROUP_IN_USEThis IP group is named in the ACLs of {n} keys. Remove it from them before deleting it.
400REQUEST_INVALIDThis request isn't valid: {detail}.
400ACL_INVALIDThis ACL isn't valid: {detail}.
400CHANGES_INVALIDThis change set isn't valid: {detail}.
400AGENT_REQUIREDSend a KeyContain-Agent header naming the agent that is pairing.
404PAIRING_NOT_FOUNDNo pairing with this ID exists.
503TOO_MANY_WAITINGAcross KeyContain: Too many requests are waiting for confirmation right now. Try again in a minute.
The account's 1 GB: Your account's requests waiting for confirmation hold 1 GB, the most KeyContain keeps for one account. Ask the person who manages the account to decide on the requests waiting in the KeyContain dashboard, then send this request again.
503SERVICE_TYPE_UNAVAILABLEKeyContain can't handle {service type} requests right now. Requests to its services are refused until it is fixed. Other services are not affected.
502UPSTREAM_CREDENTIAL_FAILEDKeyContain could not get a working credential for {service type} ({service id}). Its upstream key may have expired or been revoked. Update or reconnect it in the KeyContain dashboard.
502UPSTREAM_UNREACHABLEKeyContain could not reach {origin}.
500INTERNALSomething went wrong in KeyContain. Retry later, and contact support with the request ID if it keeps happening.

{origin} is the upstream URL's scheme and host, e.g. https://api.github.com. {path} is the path relative to the service's base URL, the same path rules match. {id} is the confirmation ID and {expiresAt} its expiry time, ISO 8601 UTC. {ip} is the address the request came from. {n} is a number, and {seconds} the number of seconds until a retry can succeed (the retryAfter value). {size} is the traffic limit in MB or GB, such as 5 GB or 500 MB, {month} the month's English name and {resetsAt} the next 1st at 00:00 UTC, ISO 8601. {price} is the read's price in dollars, $0.01, the service's pricePerRead. {email} is a Google account's email. For the management API (§14), {origin} is https://api.keycontain.com/v1. ACL_INVALID and CHANGES_INVALID also carry "at", a JSON Pointer (RFC 6901) to the part of the request body that is wrong, such as /services/svc_qbo_acme/rules/4/operation for the operation of a service's fifth rule (positions count from 0), or /changes/2/acl/services/svc_qbo_acme/rules/0/access inside a change set. For an ACL change the dashboard makes, it points into the ACL as the JSON view shows it. ACL_INVALID's {detail} is one of these fixed texts:

  • {operation} can hand over a key or widen access, so its rules need access confirm, also for a method and path rule that could match one, naming its method and path
  • "{entry}" isn't an IP address, a CIDR range or an IP group in this account
  • {service id} isn't a service in this account
  • "{operation}" isn't an operation for {service type}
  • each service needs a rules list
  • access must be allow or confirm
  • operation names one operation, so write one rule for each
  • {operation} isn't about one {noun}, so its rules can't have {scope field}, for example projects.list isn't about one project, so its rules can't have projects
  • method and path rules and git rules can't have {scope field}
  • KeyContain forwards only the Plaid endpoints in its catalog, so Plaid rules need an operation, for a method and path rule or a git rule on a Plaid service (§12)
  • {operation} is about one {noun}, so limit its rules with {scope field}, for a rule with the other scope field of a type that has two, for example bundles.upload is about one app, so limit its rules with apps
  • {service type} rules have no {field} scope, for example QuickBooks Online rules have no projects scope
  • {scope field} must list at least one {noun}, or be left out
  • "{entry}" isn't an account of {service id}, for an accounts entry that isn't one of the Plaid service's account IDs
  • "{entry}" isn't a {noun} path or pattern, or for keys, "{entry}" isn't a key in this account
  • {field} isn't a condition field for {operation}
  • each rule needs an operation, a method and path, or a git action and path
  • a rule can have only one of an operation, a method and path, or a git action and path
  • "{field}" isn't a rule field, for example "project" isn't a rule field with "at" /services/svc_gitlab_acme/rules/3/project, so a misspelled field is refused instead of being ignored
  • "{field}" isn't a field of a service's entry, which has only rules
  • "{field}" isn't an ACL field, for a top-level field other than ips, limits and services
  • "{method}" isn't an HTTP method
  • "{action}" isn't a git action (fetch or push)
  • "{pattern}" isn't a path pattern
  • "{operator}" isn't a condition operator
  • in and notIn need a list of at least one value
  • expiresAfter is only allowed on rules with access confirm
  • expiresAfter must be from 15m to 7d
  • limits can only have requestsPerMinute and trafficPerMonth
  • limits.{field} must be a whole number of at least 1, for example limits.trafficPerMonth must be a whole number of at least 1

CHANGES_INVALID's {detail} is one of these fixed texts, or any ACL_INVALID detail above for one of its acl.update changes:

  • unknown reference {ref}
  • duplicate reference {ref}
  • {op} isn't a change set operation
  • a change set can have at most 100 changes
  • a change set can have at most one capacity.update
  • units is required, for a capacity.update without units
  • units must be a whole number from 0 to 1,000, for its capacity.update
  • any REQUEST_INVALID detail below for one of its other changes, with "this change" in place of "this request", for example companyId isn't a field of this change with "at" /changes/0/service/companyId for a services.create of type quickbooks

REQUEST_INVALID's {detail} names the field or query parameter and the problem, in one of these fixed texts:

  • {field} is required
  • {field} must be {expected}, for example type must be one of quickbooks, github, gitlab, cloudflare, porkbun, namecheap, googlecloud, appstoreconnect, googledrive, http, keycontain
  • a person adds and reconnects Plaid services in the KeyContain dashboard, where they sign in to their bank, for services.create with "type": "plaid" and for "askForSecrets": true on a Plaid service (§14)
  • "{entry}" isn't an IP address or CIDR range
  • an IP group needs 1 to 100 addresses
  • {field} isn't a field of this request, for a body field the endpoint doesn't have
  • Porkbun requests must send a JSON object body, for a Porkbun request whose body isn't a JSON object (§12)
  • Namecheap requests must send their parameters in the query or a form body, for a Namecheap request with any other body (§12)
  • name the domain once, with DomainName or with SLD and TLD, for a Namecheap request that names its domain both ways
  • "{parameter}" is given more than once, for a Namecheap request that repeats a parameter, in any letter case, across the query and the body
  • {host} is the address of a built-in service, so add it as that service, for a GitLab, GitHub, Generic HTTP or KeyContain service whose base URL is on api.cloudflare.com, api.porkbun.com, api.namecheap.com, api.sandbox.namecheap.com or production.plaid.com (§8)
  • "{header}" can't be a credential header, for a Generic HTTP service whose credential headers include Host, Content-Length, Transfer-Encoding, a hop-by-hop header or a KeyContain- header
  • the data key of {service} changed after this request was approved, when an approved change gives a service to a key and the service's upstream key was replaced after the approval
  • method override headers and parameters are not allowed, for a proxied request with a method override header, query parameter or body field (§7)
  • the push commands are malformed, for a git push whose ref update commands KeyContain cannot read (shown as a remote: message, below)

Which error comes first

A request that fails several checks gets the error of the first check it fails, in this order (§7):

  1. 401 KEY_INVALID. A caller without a valid key learns nothing about URLs or services.
  2. 403 IP_NOT_ALLOWED
  3. 429 RATE_LIMITED, for the account's limit or the key's own (§11)
  4. 429 TRAFFIC_LIMIT_REACHED, for the account's limit or the key's own
  5. 404 NOT_FOUND, management API only: the method and path are not a KeyContain endpoint
  6. 400 UPSTREAM_URL_INVALID
  7. 400 REQUEST_INVALID for a method override header or query parameter
  8. 403 SERVICE_NOT_ATTACHED
  9. 503 SERVICE_TYPE_UNAVAILABLE, when the selected service's type is turned off (§8). For Plaid, whose services share a base URL, it is decided by the type before the service is chosen
  10. 403 OPERATION_NOT_ALLOWED for a Plaid endpoint outside KeyContain's catalog, with its own message (§7)
  11. 400 PLAID_SERVICE_REQUIRED, when a Plaid request's access_token isn't the ID of a Plaid service attached to the key
  12. 403 OPERATION_NOT_ALLOWED
  13. 404 NOT_FOUND, management API only: the key, service or IP group ID in the path does not exist
  14. 409 KEY_REVOKED, SERVICE_BUILT_IN or IP_GROUP_IN_USE, management API writes only
  15. 413 REQUEST_TOO_LARGE, for a request that needs confirmation and for a management API call over 1 MiB (§10). It is decided before the body is read, so a body over the limit is never validated
  16. 503 TOO_MANY_WAITING, for a request that needs confirmation when KeyContain is already keeping as much waiting as it can (§10), or when the request would take the account's waiting requests over 1 GB (§11)
  17. 400 REQUEST_INVALID, ACL_INVALID or CHANGES_INVALID, management API only. They are checked when the request is received, before it is held for confirmation (and again when an approved change is applied, with the same details)
  18. 400 DESCRIPTION_REQUIRED or DESCRIPTION_INVALID, only for a request that needs confirmation (on other requests an over-long or badly encoded optional header is removed and not recorded)
  19. 400 REQUEST_INVALID for a _method field in a form-encoded, multipart or JSON body, for a Porkbun request whose body isn't a JSON object, or a Namecheap request whose body isn't form-encoded, that names its domain both by DomainName and by SLD and TLD, or that gives a parameter more than once in any letter case across its query and body (§12), or 413 REQUEST_TOO_LARGE for a form-encoded or JSON body over 10 MiB, found when the body is read, before the request is held or forwarded
  20. 400 UPLOAD_UNREADABLE for a QuickBooks upload KeyContain has to read, to check a rule's upload. conditions or to hold it for confirmation, and can't read unambiguously (§12). It is found when the upload is read, before the request is held or forwarded, so nobody is notified. OPERATION_NOT_ALLOWED comes first only when no rule could allow or hold the upload, whatever it contains
  21. 402 PAYMENT_REQUIRED for a read on a QuickBooks Online service connected through KeyContain's Intuit app while the account's balance can't pay for it (§11), checked before the read is held for confirmation and again before it is forwarded
  22. The confirmation outcome: forwarded after approval, 403 CONFIRMATION_DENIED, or 403 CONFIRMATION_REQUIRED
  23. 409 ACL_CHANGED when an approved acl.update, or a change set with one, is applied and the target key's ACL changed after the request arrived
  24. 409 SERVICE_CHANGED when an approved QuickBooks Online or Google Drive services.create or reconnect, alone or in a change set, is applied and its company or Google account was connected by another request, or the service to reconnect changed, after the request arrived
  25. 402 PAYMENT_REQUIRED when a capacity.update, or a change set with one, is applied and the balance can't pay today's charge (§14)
  26. 502 UPSTREAM_CREDENTIAL_FAILED or UPSTREAM_UNREACHABLE when forwarding

500 INTERNAL can happen at any step. Management API calls skip UPSTREAM_URL_INVALID and the upstream errors, since KeyContain answers them itself. The pairing endpoints need no key: POST /v1/pairings can only fail with 429 RATE_LIMITED or 400 AGENT_REQUIRED, and GET /v1/pairings/{pairing} with 429 RATE_LIMITED or 404 PAIRING_NOT_FOUND (or INTERNAL).

Examples

401 KEY_INVALID

HTTP/1.1 401 Unauthorized
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZK6B2E

{
  "error": {
    "code": "KEY_INVALID",
    "message": "This request has no valid KeyContain key. Send your key in the Authorization header, as a Bearer token or as the password.",
    "requestId": "req_01J9ZK6B2E"
  }
}

A 401 does not point to the meta API, because there is no valid key to inspect.

403 IP_NOT_ALLOWED

HTTP/1.1 403 Forbidden
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZQ8D4E

{
  "error": {
    "code": "IP_NOT_ALLOWED",
    "message": "This key can't be used from 192.0.2.81. Its ACL allows only certain IP addresses. Ask the person who manages it in the KeyContain dashboard to allow this address.",
    "ip": "192.0.2.81",
    "requestId": "req_01J9ZQ8D4E"
  }
}

402 PAYMENT_REQUIRED

HTTP/1.1 402 Payment Required
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZQ9B2C

{
  "error": {
    "code": "PAYMENT_REQUIRED",
    "message": "The account's balance can't pay today's charge for this capacity. Add funds in the KeyContain dashboard.",
    "service": "svc_account",
    "operation": "capacity.update",
    "requestId": "req_01J9ZQ9B2C"
  }
}
HTTP/1.1 402 Payment Required
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZQ9D4F

{
  "error": {
    "code": "PAYMENT_REQUIRED",
    "message": "The account's balance can't pay $0.01 for this read on QuickBooks Online (svc_qbo_acme). Reads through KeyContain's Intuit app are refused until funds are added. Ask the person who manages the account to add funds in the KeyContain dashboard. Writes still work.",
    "service": "svc_qbo_acme",
    "operation": "invoice.read",
    "requestId": "req_01J9ZQ9D4F"
  }
}

Only a capacity change and a QuickBooks read through KeyContain's Intuit app get PAYMENT_REQUIRED. While such reads are refused, writes, services on the customer's own Intuit app and every other service keep working (§11). When the balance can't pay for the account's units, its keys keep working within the free allowance (§11).

429 RATE_LIMITED

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 18
KeyContain-Request-Id: req_01J9ZQ7B2D

{
  "error": {
    "code": "RATE_LIMITED",
    "message": "This key's limit of 50 requests a minute is used up. Try again in 18 seconds. Check usage with GET https://api.keycontain.com/v1/key.",
    "limit": "key",
    "retryAfter": 18,
    "requestId": "req_01J9ZQ7B2D"
  }
}
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 4
KeyContain-Request-Id: req_01J9ZQ7C3E

{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Your account's limit of 250 requests a minute, shared by all its keys, is used up. Try again in 4 seconds, or add capacity in the KeyContain dashboard.",
    "limit": "account",
    "retryAfter": 4,
    "requestId": "req_01J9ZQ7C3E"
  }
}

An account with no units gets the same message with its free 10 requests a minute (§11). Pairing from an address that started 10 pairings in the last hour gets Too many pairing requests from this IP address. Try again in {seconds} seconds., without limit.

429 TRAFFIC_LIMIT_REACHED

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 2246400
KeyContain-Request-Id: req_01J9ZQ9F6G

{
  "error": {
    "code": "TRAFFIC_LIMIT_REACHED",
    "message": "Your account's limit of 5 GB of traffic for October is used up. Requests with all its keys are refused until 2026-11-01T00:00:00Z, unless capacity is added in the KeyContain dashboard.",
    "limit": "account",
    "retryAfter": 2246400,
    "resetsAt": "2026-11-01T00:00:00Z",
    "requestId": "req_01J9ZQ9F6G"
  }
}
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 2246400
KeyContain-Request-Id: req_01J9ZQ9G7H

{
  "error": {
    "code": "TRAFFIC_LIMIT_REACHED",
    "message": "This key's limit of 500 MB of traffic for October is used up. Its requests are refused until 2026-11-01T00:00:00Z, unless its limit is raised in the KeyContain dashboard.",
    "limit": "key",
    "retryAfter": 2246400,
    "resetsAt": "2026-11-01T00:00:00Z",
    "requestId": "req_01J9ZQ9G7H"
  }
}

400 UPSTREAM_URL_INVALID

HTTP/1.1 400 Bad Request
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZK7A1D

{
  "error": {
    "code": "UPSTREAM_URL_INVALID",
    "message": "The URL after /p/ must be a complete https:// URL, for example https://api.keycontain.com/p/https://gitlab.com/api/v4/projects.",
    "requestId": "req_01J9ZK7A1D"
  }
}
HTTP/1.1 400 Bad Request
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZK7B3E

{
  "error": {
    "code": "UPSTREAM_URL_INVALID",
    "message": "The URL after /p/ can't contain . or .. path segments.",
    "requestId": "req_01J9ZK7B3E"
  }
}

403 SERVICE_NOT_ATTACHED

HTTP/1.1 403 Forbidden
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZK5Q7M

{
  "error": {
    "code": "SERVICE_NOT_ATTACHED",
    "message": "This KeyContain key has no service attached for https://api.github.com. To see which services this key can reach, call GET https://api.keycontain.com/v1/key with the same key.",
    "permissionsUrl": "https://api.keycontain.com/v1/key",
    "requestId": "req_01J9ZK5Q7M"
  }
}

403 OPERATION_NOT_ALLOWED

HTTP/1.1 403 Forbidden
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZK4W3B

{
  "error": {
    "code": "OPERATION_NOT_ALLOWED",
    "message": "This KeyContain key does not allow PUT /api/v4/projects/42/merge_requests/7/merge on GitLab (svc_gitlab_acme). To see what this key may do, call GET https://api.keycontain.com/v1/key with the same key.",
    "service": "svc_gitlab_acme",
    "operation": "merge_requests.merge",
    "permissionsUrl": "https://api.keycontain.com/v1/key",
    "requestId": "req_01J9ZK4W3B"
  }
}
HTTP/1.1 403 Forbidden
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZK4Z6C

{
  "error": {
    "code": "OPERATION_NOT_ALLOWED",
    "message": "This KeyContain key does not allow POST /api/v4/projects/42/merge_requests/7/approve on GitLab (svc_gitlab_acme). To see what this key may do, call GET https://api.keycontain.com/v1/key with the same key.",
    "service": "svc_gitlab_acme",
    "operation": null,
    "permissionsUrl": "https://api.keycontain.com/v1/key",
    "requestId": "req_01J9ZK4Z6C"
  }
}

403 OPERATION_NOT_ALLOWED (a Plaid endpoint outside the catalog)

HTTP/1.1 403 Forbidden
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZT1A2P

{
  "error": {
    "code": "OPERATION_NOT_ALLOWED",
    "message": "KeyContain forwards only the Plaid endpoints in its catalog, and POST /item/public_token/create isn't one of them. To see what this key may do, call GET https://api.keycontain.com/v1/key with the same key.",
    "operation": null,
    "permissionsUrl": "https://api.keycontain.com/v1/key",
    "requestId": "req_01J9ZT1A2P"
  }
}

This applies whatever the key's rules say, before KeyContain reads access_token, so no rule can reach the Plaid endpoints that create, share or rotate access to a bank (§12). Nothing is sent to Plaid.

400 PLAID_SERVICE_REQUIRED

HTTP/1.1 400 Bad Request
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZT2B3Q

{
  "error": {
    "code": "PLAID_SERVICE_REQUIRED",
    "message": "Send the ID of a Plaid service attached to this key as access_token, such as svc_plaid_chase. GET https://api.keycontain.com/v1/key lists them.",
    "requestId": "req_01J9ZT2B3Q"
  }
}

An access_token that is one of Plaid's own access tokens, or the ID of a Plaid service that isn't attached to this key, gets the same answer. Nothing is sent to Plaid. The agent picks the bank's service from GET /v1/key (§13) and sends its ID, for example "access_token": "svc_plaid_fifththird".

400 DESCRIPTION_REQUIRED

HTTP/1.1 400 Bad Request
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZM1F3S

{
  "error": {
    "code": "DESCRIPTION_REQUIRED",
    "message": "This request needs a person's confirmation. Send KeyContain-Agent, KeyContain-Purpose and KeyContain-Reason headers saying which agent is asking, what the request is for and why it is necessary.",
    "service": "svc_qbo_acme",
    "operation": "invoice.create",
    "requestId": "req_01J9ZM1F3S"
  }
}

400 DESCRIPTION_INVALID

HTTP/1.1 400 Bad Request
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZM1H8U

{
  "error": {
    "code": "DESCRIPTION_INVALID",
    "message": "KeyContain-Agent must be at most 200 characters, and KeyContain-Purpose and KeyContain-Reason at most 1,000 characters each, of text (non-ASCII as percent-encoded UTF-8).",
    "service": "svc_qbo_acme",
    "operation": "invoice.create",
    "requestId": "req_01J9ZM1H8U"
  }
}

400 UPLOAD_UNREADABLE

HTTP/1.1 400 Bad Request
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZK2Q6U

{
  "error": {
    "code": "UPLOAD_UNREADABLE",
    "message": "KeyContain can't read this upload unambiguously. Send it as standard multipart/form-data without Content-Encoding, with at least one file. For each file, send one file_metadata_N part with its JSON metadata and one file_content_N part with its bytes. Use each part name once, keep each part header on one line in UTF-8 without encoded parameters such as filename*=, put nothing but blank lines before the first boundary or after the closing one, end with the closing boundary, and choose a boundary that appears only at the delimiters.",
    "service": "svc_qbo_acme",
    "operation": "attachable.upload",
    "requestId": "req_01J9ZK2Q6U"
  }
}

Nobody is notified and nothing of the upload is kept. The person sees the refused request in Activity with its code. It has its own code, apart from REQUEST_INVALID, because many of these uploads are valid multipart that QuickBooks may accept: KeyContain refuses them because it can't be sure which files and records it would show the person or check against the rules. Build the body again as the message says and send it.

403 CONFIRMATION_DENIED

HTTP/1.1 403 Forbidden
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZM2K7Q

{
  "error": {
    "code": "CONFIRMATION_DENIED",
    "message": "A person declined this request in the KeyContain dashboard.",
    "service": "svc_qbo_acme",
    "operation": "invoice.create",
    "confirmation": "cnf_7Q2MV9XB",
    "requestId": "req_01J9ZM2K7Q"
  }
}

403 CONFIRMATION_REQUIRED

HTTP/1.1 403 Forbidden
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZM2K7Q

{
  "error": {
    "code": "CONFIRMATION_REQUIRED",
    "message": "This request needs a person's confirmation in the KeyContain dashboard. After it is approved, send the identical request again before 2026-10-06T14:20:11Z. Check its status with GET https://api.keycontain.com/v1/confirmations/cnf_7Q2MV9XB.",
    "service": "svc_qbo_acme",
    "operation": "invoice.create",
    "confirmation": "cnf_7Q2MV9XB",
    "statusUrl": "https://api.keycontain.com/v1/confirmations/cnf_7Q2MV9XB",
    "expiresAt": "2026-10-06T14:20:11Z",
    "requestId": "req_01J9ZM2K7Q"
  }
}

404 CONFIRMATION_NOT_FOUND

HTTP/1.1 404 Not Found
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZM3N2W

{
  "error": {
    "code": "CONFIRMATION_NOT_FOUND",
    "message": "No confirmation with this ID exists for this KeyContain key.",
    "requestId": "req_01J9ZM3N2W"
  }
}

404 NOT_FOUND

HTTP/1.1 404 Not Found
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZN5C3P

{
  "error": {
    "code": "NOT_FOUND",
    "message": "No key, service or IP group with this ID exists in this account.",
    "service": "svc_account",
    "operation": "acl.read",
    "requestId": "req_01J9ZN5C3P"
  }
}

413 REQUEST_TOO_LARGE

HTTP/1.1 413 Content Too Large
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZR1S5J

{
  "error": {
    "code": "REQUEST_TOO_LARGE",
    "message": "This request needs a person's confirmation, and a request waiting for confirmation can have a body of at most 10 MiB.",
    "service": "svc_statuspage",
    "operation": null,
    "requestId": "req_01J9ZR1S5J"
  }
}
HTTP/1.1 413 Content Too Large
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZR1T7K

{
  "error": {
    "code": "REQUEST_TOO_LARGE",
    "message": "A management API call can have a body of at most 1 MiB.",
    "service": "svc_account",
    "operation": "ipGroups.create",
    "requestId": "req_01J9ZR1T7K"
  }
}

git pushes get remote: lines instead (§10).

409 KEY_REVOKED

HTTP/1.1 409 Conflict
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZR3V7L

{
  "error": {
    "code": "KEY_REVOKED",
    "message": "This key is revoked. A revoked key can only be read or deleted.",
    "service": "svc_account",
    "operation": "keys.rotate",
    "requestId": "req_01J9ZR3V7L"
  }
}

409 SERVICE_BUILT_IN

HTTP/1.1 409 Conflict
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZR4W8M

{
  "error": {
    "code": "SERVICE_BUILT_IN",
    "message": "svc_account is built in and can't be changed or deleted.",
    "service": "svc_account",
    "operation": "services.delete",
    "requestId": "req_01J9ZR4W8M"
  }
}

409 IP_GROUP_IN_USE

HTTP/1.1 409 Conflict
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZR5X9N

{
  "error": {
    "code": "IP_GROUP_IN_USE",
    "message": "This IP group is named in the ACLs of 2 keys. Remove it from them before deleting it.",
    "service": "svc_account",
    "operation": "ipGroups.delete",
    "keys": ["key_8KQ2W1", "key_3MZ7Q0"],
    "requestId": "req_01J9ZR5X9N"
  }
}

400 REQUEST_INVALID

HTTP/1.1 400 Bad Request
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZR6Y0P

{
  "error": {
    "code": "REQUEST_INVALID",
    "message": "This request isn't valid: \"198.51.100.300\" isn't an IP address or CIDR range.",
    "service": "svc_account",
    "operation": "ipGroups.create",
    "requestId": "req_01J9ZR6Y0P"
  }
}
HTTP/1.1 400 Bad Request
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZR6Z1Q

{
  "error": {
    "code": "REQUEST_INVALID",
    "message": "This request isn't valid: companyId isn't a field of this request.",
    "service": "svc_account",
    "operation": "services.create",
    "requestId": "req_01J9ZR6Z1Q"
  }
}

The person picks the company at Intuit when approving, so the agent sends only type and label (§14).

HTTP/1.1 400 Bad Request
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZT3C4R

{
  "error": {
    "code": "REQUEST_INVALID",
    "message": "This request isn't valid: a person adds and reconnects Plaid services in the KeyContain dashboard, where they sign in to their bank.",
    "service": "svc_account",
    "operation": "services.create",
    "requestId": "req_01J9ZT3C4R"
  }
}

Nobody is asked to confirm. The agent asks its person to add the bank in the dashboard instead (§14).

400 ACL_INVALID

HTTP/1.1 400 Bad Request
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZN5G5Q

{
  "error": {
    "code": "ACL_INVALID",
    "message": "This ACL isn't valid: \"invoice.void\" isn't an operation for QuickBooks Online.",
    "service": "svc_account",
    "operation": "acl.update",
    "at": "/services/svc_qbo_acme/rules/4/operation",
    "requestId": "req_01J9ZN5G5Q"
  }
}

Here admin-claude's proposal (§14) named invoice.void instead of invoice.delete. at points to the operation of that rule.

HTTP/1.1 400 Bad Request
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZN5H6R

{
  "error": {
    "code": "ACL_INVALID",
    "message": "This ACL isn't valid: acl.update can hand over a key or widen access, so its rules need access confirm.",
    "service": "svc_account",
    "operation": "acl.update",
    "at": "/services/svc_account/rules/0/access",
    "requestId": "req_01J9ZN5H6R"
  }
}

Here the new ACL gave a key { "operation": "acl.update", "access": "allow" } as the first rule of svc_account. The request is refused before anyone is asked to confirm.

HTTP/1.1 400 Bad Request
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZN5K8T

{
  "error": {
    "code": "ACL_INVALID",
    "message": "This ACL isn't valid: projects.list isn't about one project, so its rules can't have projects.",
    "service": "svc_account",
    "operation": "acl.update",
    "at": "/services/svc_gitlab_acme/rules/13/projects",
    "requestId": "req_01J9ZN5K8T"
  }
}

Listing projects is a service-level permission, so its rule leaves out projects and covers the whole GitLab instance (§9).

HTTP/1.1 400 Bad Request
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZT4D5S

{
  "error": {
    "code": "ACL_INVALID",
    "message": "This ACL isn't valid: KeyContain forwards only the Plaid endpoints in its catalog, so Plaid rules need an operation.",
    "service": "svc_account",
    "operation": "acl.update",
    "at": "/services/svc_plaid_chase/rules/5/method",
    "requestId": "req_01J9ZT4D5S"
  }
}

Bank connection · Read (item.read) is the catalog's way to read /item/get (§12).

400 CHANGES_INVALID

HTTP/1.1 400 Bad Request
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZP5J7K

{
  "error": {
    "code": "CHANGES_INVALID",
    "message": "This change set isn't valid: unknown reference @bok.",
    "service": "svc_account",
    "operation": "acl.update",
    "at": "/changes/2/key",
    "requestId": "req_01J9ZP5J7K"
  }
}

409 ACL_CHANGED

HTTP/1.1 409 Conflict
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZP6L8M
KeyContain-Confirmation: cnf_6WQ3HB8T; approved

{
  "error": {
    "code": "ACL_CHANGED",
    "message": "This key's ACL changed after the request was made. Ask again with the current ACL.",
    "service": "svc_account",
    "operation": "acl.update",
    "confirmation": "cnf_6WQ3HB8T",
    "requestId": "req_01J9ZP6L8M"
  }
}

The approval is used up. The agent reads the current ACL with GET /v1/keys/{key}/acl and sends a new request.

409 SERVICE_CHANGED

HTTP/1.1 409 Conflict
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZS2C7V
KeyContain-Confirmation: cnf_8NQB2D5H; approved

{
  "error": {
    "code": "SERVICE_CHANGED",
    "message": "Acme Holdings (company 9130354) was connected by another request after this one was made. Ask again to connect a different company.",
    "service": "svc_account",
    "operation": "services.create",
    "confirmation": "cnf_8NQB2D5H",
    "requestId": "req_01J9ZS2C7V"
  }
}

Nothing is created and the approval is used up. The agent checks GET /v1/services for the company already connected, or asks again for a different one. A reconnect that can't be applied gets the same code with the message "This service changed after the request was made. Ask again to reconnect it." A new Google Drive service whose Google account was connected by another request gets "dana@acme.dev was connected by another request after this one was made. Ask again to connect a different Google account."

403 OPERATION_NOT_ALLOWED (a provider's sign-in or token endpoint)

HTTP/1.1 403 Forbidden
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZS3D8W

{
  "error": {
    "code": "OPERATION_NOT_ALLOWED",
    "message": "KeyContain doesn't forward requests to Intuit's sign-in and token endpoints. It connects QuickBooks Online and refreshes access itself.",
    "requestId": "req_01J9ZS3D8W"
  }
}

Through a Google Drive service, a request to Google's token endpoint on its host, such as POST https://api.keycontain.com/p/https://www.googleapis.com/oauth2/v4/token, gets the same code with "KeyContain doesn't forward requests to Google's sign-in and token endpoints. It connects Google Drive and refreshes access itself."

400 AGENT_REQUIRED

HTTP/1.1 400 Bad Request
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZP0Z9Y

{
  "error": {
    "code": "AGENT_REQUIRED",
    "message": "Send a KeyContain-Agent header naming the agent that is pairing.",
    "requestId": "req_01J9ZP0Z9Y"
  }
}

404 PAIRING_NOT_FOUND

HTTP/1.1 404 Not Found
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZP6L8M

{
  "error": {
    "code": "PAIRING_NOT_FOUND",
    "message": "No pairing with this ID exists.",
    "requestId": "req_01J9ZP6L8M"
  }
}

503 TOO_MANY_WAITING

HTTP/1.1 503 Service Unavailable
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZR2W4N

{
  "error": {
    "code": "TOO_MANY_WAITING",
    "message": "Too many requests are waiting for confirmation right now. Try again in a minute.",
    "service": "svc_qbo_acme",
    "operation": "invoice.create",
    "requestId": "req_01J9ZR2W4N"
  }
}

Nobody is notified and nothing is recorded as waiting. Resend the request a minute later.

HTTP/1.1 503 Service Unavailable
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZR2X5P

{
  "error": {
    "code": "TOO_MANY_WAITING",
    "message": "Your account's requests waiting for confirmation hold 1 GB, the most KeyContain keeps for one account. Ask the person who manages the account to decide on the requests waiting in the KeyContain dashboard, then send this request again.",
    "service": "svc_qbo_acme",
    "operation": "attachable.upload",
    "requestId": "req_01J9ZR2X5P"
  }
}

The account's limit of 1 GB of waiting requests is used (§11). Nobody is notified. Waiting requests free their space when they are declined, used or expire, so try again after the person has decided.

503 SERVICE_TYPE_UNAVAILABLE

HTTP/1.1 503 Service Unavailable
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZKB2F7

{
  "error": {
    "code": "SERVICE_TYPE_UNAVAILABLE",
    "message": "KeyContain can't handle QuickBooks Online requests right now. Requests to its services are refused until it is fixed. Other services are not affected.",
    "service": "svc_qbo_acme",
    "requestId": "req_01J9ZKB2F7"
  }
}

KeyContain sends no Retry-After and does not retry by itself. An agent may try again later. The meta and management APIs keep working, and GET /v1/key shows the service with "available": false (§13).

502 UPSTREAM_CREDENTIAL_FAILED

HTTP/1.1 502 Bad Gateway
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZK8C4F

{
  "error": {
    "code": "UPSTREAM_CREDENTIAL_FAILED",
    "message": "KeyContain could not get a working credential for QuickBooks Online (svc_qbo_acme). Its upstream key may have expired or been revoked. Update or reconnect it in the KeyContain dashboard.",
    "service": "svc_qbo_acme",
    "requestId": "req_01J9ZK8C4F"
  }
}
HTTP/1.1 502 Bad Gateway
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZT5E6U

{
  "error": {
    "code": "UPSTREAM_CREDENTIAL_FAILED",
    "message": "KeyContain could not get a working credential for Plaid (svc_plaid_chase). Its upstream key may have expired or been revoked. Update or reconnect it in the KeyContain dashboard.",
    "service": "svc_plaid_chase",
    "requestId": "req_01J9ZT5E6U"
  }
}

The first request after Chase asked to sign in again got Plaid's own answer, with ITEM_LOGIN_REQUIRED. Later ones get this error without reaching Plaid, until Dana reconnects Chase on its page in the dashboard (§8).

502 UPSTREAM_UNREACHABLE

HTTP/1.1 502 Bad Gateway
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZK9D5G

{
  "error": {
    "code": "UPSTREAM_UNREACHABLE",
    "message": "KeyContain could not reach https://code.acme.dev.",
    "service": "svc_gitlab_acme",
    "requestId": "req_01J9ZK9D5G"
  }
}

500 INTERNAL

HTTP/1.1 500 Internal Server Error
Content-Type: application/json
KeyContain-Request-Id: req_01J9ZKA1E6

{
  "error": {
    "code": "INTERNAL",
    "message": "Something went wrong in KeyContain. Retry later, and contact support with the request ID if it keeps happening.",
    "requestId": "req_01J9ZKA1E6"
  }
}

Refused git pushes

git cannot show a JSON body, so KeyContain answers a refused push in git's own protocol. git prints KeyContain's lines as remote: messages, then its own rejection.

remote: KeyContain: push to refs/heads/main refused (req_01J9ZK4W3B).
remote: This KeyContain key does not allow this push to GitLab (svc_gitlab_acme).
remote: To see what this key may do:
remote:   curl -H "Authorization: Bearer <your key>" https://api.keycontain.com/v1/key
To https://api.keycontain.com/p/https://code.acme.dev/acme/api.git
 ! [remote rejected] HEAD -> main (refused by KeyContain)
error: failed to push some refs to 'https://api.keycontain.com/p/https://code.acme.dev/acme/api.git'
remote: KeyContain: push to refs/heads/agent/fix-retry refused (req_01J9ZM4P5X).
remote: This request needs a person's confirmation. Send KeyContain-Agent, KeyContain-Purpose and KeyContain-Reason headers saying which agent is asking, what the request is for and why it is necessary.
remote: For example: git -c http.extraHeader="KeyContain-Agent: …" -c http.extraHeader="KeyContain-Purpose: …" -c http.extraHeader="KeyContain-Reason: …" push
To https://api.keycontain.com/p/https://code.acme.dev/acme/api.git
 ! [remote rejected] agent/fix-retry -> agent/fix-retry (description required)
error: failed to push some refs to 'https://api.keycontain.com/p/https://code.acme.dev/acme/api.git'
remote: KeyContain: push refused (req_01J9ZKD5H2).
remote: This request isn't valid: the push commands are malformed.
To https://api.keycontain.com/p/https://code.acme.dev/acme/api.git
 ! [remote rejected] agent/fix-retry -> agent/fix-retry (request invalid)
error: failed to push some refs to 'https://api.keycontain.com/p/https://code.acme.dev/acme/api.git'
remote: KeyContain: push refused (req_01J9ZKC3G8).
remote: KeyContain can't handle GitLab requests right now. Requests to its services are refused until it is fixed. Other services are not affected.
To https://api.keycontain.com/p/https://code.acme.dev/acme/api.git
 ! [remote rejected] agent/fix-retry -> agent/fix-retry (service type unavailable)
error: failed to push some refs to 'https://api.keycontain.com/p/https://code.acme.dev/acme/api.git'

Pushes that wait for a person are shown in §10. When a push updates several refs and any one is refused, the first refused ref is named and the whole push is refused. Refusals that happen before the push itself (an invalid key, a URL with no attached service, a key without any git push rule for the repository) use the HTTP status and envelope above, which git reports as its own HTTP error.

16Endpoint inventory

Every surface KeyContain defines. Upstream endpoints reached through the proxy are the services' own and are not listed.

SurfaceMethodsPurposeKeyContain responsesReference
https://api.keycontain.com/agentsGETInstructions for agents, Markdown. No key200§3
https://api.keycontain.com/docsGETThis reference, HTML. No key200§1
https://api.keycontain.com/v1/pairingsPOSTStart a pairing. No key201, 400, 429, 500§4
https://api.keycontain.com/v1/pairings/{pairing}GETWait for and read a pairing, delivering the key once. No key200, 404, 429, 500§4
https://api.keycontain.com/p/{upstream URL}GETHEADPOSTPUTPATCHDELETEProxied REST request. A Plaid request names its service in access_tokenupstream response (+ KeyContain-Request-Id, and KeyContain-Confirmation after approval), 401, 400 ×6, 402, 403 ×5, 413, 429 ×2, 502 ×2, 503, 500§7
…/p/https://{git host}/{repo}.git/info/refs?service=git-upload-packGETgit fetch discoveryas above§7
…/p/https://{git host}/{repo}.git/git-upload-packPOSTgit fetchas above§7
…/p/https://{git host}/{repo}.git/info/refs?service=git-receive-packGETgit push discoveryas above§7
…/p/https://{git host}/{repo}.git/git-receive-packPOSTgit push, ref updates inspected. May wait for a confirmationas above, plus KeyContain's remote: lines§15
https://api.keycontain.com/v1/keyGETInspect the calling key, its rules, its services' read prices and Plaid accounts, and the account's and its own limits and usage200, 401, 403, 429 ×2, 500§13
https://api.keycontain.com/v1/confirmations/{id}GETCheck one confirmation created by the calling key200, 401, 403, 404, 429 ×2, 500§13
https://api.keycontain.com/v1/keys, …/v1/keys/{key}GETRead keys (keys.read)200, 401, 403 ×3, 404, 429 ×2, 500§14
https://api.keycontain.com/v1/keys/{key}/aclGETRead a key's ACL (acl.read)200, 401, 403 ×3, 404, 429 ×2, 500§14
https://api.keycontain.com/v1/keys/{key}/aclPUTChange a key's ACL, including its ips (acl.update). Always waits for a person200 after approval (+ KeyContain-Confirmation), 401, 400 ×3, 403 ×5, 404, 409 ×2, 429 ×2, 500§14
https://api.keycontain.com/v1/services, …/v1/services/{service}GETRead services (services.read)200, 401, 403 ×3, 404, 429 ×2, 500§14
https://api.keycontain.com/v1/keysPOSTCreate a key (keys.create). Always waits for a person. The new key's value is delivered once200 after approval (+ KeyContain-Confirmation), 401, 400 ×3, 403 ×5, 429 ×2, 500§14
https://api.keycontain.com/v1/servicesPOSTAdd a service (services.create). Always waits for a person, who enters its secrets or, for QuickBooks Online and Google Drive, connects its company or Google account. Not for Plaid200 after approval (+ KeyContain-Confirmation), 401, 400 ×3, 403 ×5, 409, 429 ×2, 500§14
https://api.keycontain.com/v1/changesPOSTApply a change set: any of the account's writes together. One confirmation, or none if every change is allowed200 (after approval, + KeyContain-Confirmation), 401, 402, 400 ×5, 403 ×5, 404, 409 ×3, 429 ×2, 500§14
https://api.keycontain.com/v1/services/{service}PATCHChange a service (services.update). Always waits for a person, who enters any new secrets or reconnects a QuickBooks Online or Google Drive service200 after approval, 401, 400 ×4, 403 ×5, 404, 409 ×2, 429 ×2, 500§14
https://api.keycontain.com/v1/services/{service}DELETEDelete a service (services.delete)204, 401, 400 ×2, 403 ×5, 404, 409, 429 ×2, 500§14
https://api.keycontain.com/v1/keys/{key}/rotatePOSTRotate a key (keys.rotate). Always waits for a person. The new value is delivered once200 after approval, 401, 400 ×2, 403 ×5, 404, 409, 429 ×2, 500§14
https://api.keycontain.com/v1/keys/{key}/revokePOSTRevoke a key (keys.revoke)200, 401, 400 ×2, 403 ×5, 404, 429 ×2, 500§14
https://api.keycontain.com/v1/keys/{key}DELETEDelete a key (keys.delete)204, 401, 400 ×2, 403 ×5, 404, 429 ×2, 500§14
https://api.keycontain.com/v1/ip-groups, …/v1/ip-groups/{group}GETRead IP groups (ipGroups.read)200, 401, 403 ×3, 404, 429 ×2, 500§14
https://api.keycontain.com/v1/ip-groupsPOSTCreate an IP group (ipGroups.create). Always waits for a person200 after approval, 401, 400 ×3, 403 ×5, 429 ×2, 500§14
https://api.keycontain.com/v1/ip-groups/{group}PUTChange an IP group (ipGroups.update). Always waits for a person200 after approval, 401, 400 ×3, 403 ×5, 404, 429 ×2, 500§14
https://api.keycontain.com/v1/ip-groups/{group}DELETEDelete an IP group (ipGroups.delete)204, 401, 400 ×2, 403 ×5, 404, 409, 429 ×2, 500§14
https://api.keycontain.com/v1/activityGETRead activity, for audit export (activity.read)200, 401, 400, 403 ×3, 429 ×2, 500§14
https://api.keycontain.com/v1/billingGETRead the capacity, the balance and this month's priced QuickBooks reads (billing.read)200, 401, 403 ×3, 429 ×2, 500§14
https://api.keycontain.com/v1/billing/capacityPUTSet the account's capacity units (capacity.update)200 (after approval, + KeyContain-Confirmation), 401, 402, 400 ×3, 403 ×5, 429 ×2, 500§14

The dashboard at https://app.keycontain.com uses private endpoints that are not part of this API, including the ones a signed-in person uses to approve or decline confirmations.

17Out of scope

Left out of v1 by decision.

  • Webhooks. Agents learn outcomes from held requests and statusUrl, and people get push notifications. Other tools export history by paging through GET /v1/activity (§14).
  • Several people per account. An account has one person, who receives and decides every confirmation.
  • QuickBooks Online sandbox companies. KeyContain connects production companies only (§8).
  • Plaid's sandbox. KeyContain connects banks in Plaid's production environment only (§8).
  • Resumable Google Drive uploads. An agent sends each file in one multipart or media upload (§12).