Ingram Cloud

Documentation

Auth & tokens

Auth & tokens

Every request carries Authorization: Bearer <token>, and pins the wire with IC-Api-Version: 2026-05-01 (conventions). Tokens are RS256 JWTs; the sub claim encodes identity, so the tenant is implicit in the token and never appears in a URL.

Two token types

Tenant-adminSmith token
sub<tenant><tenant>:<smith id>
Accessevery /v1 operation, in this project onlyonly that one smith's data
Lifetimedays, or non-expiring≤ 24 hours (enforced)
Livesyour server, as a secreta browser or device is fine
Handle prefixtha_live_…thp_live_…

A tenant-admin token carries the tenant:* scope. It grants everything, but is tenant-bounded: every query the API runs filters by the token's own tenant, with no bypass, so it cannot read another project even if leaked across projects you own. Treat it like a database password regardless.

A smith token is cryptographically bound to one smith, so requests for any other smith fail with 403 smith_mismatch, by construction rather than by policy. Hand it to a browser or device so a person can talk to their smith and nothing else.

Minting tokens

Console: tenant-admin keys live at Settings → API keys; a key scoped to one smith is minted from that smith's Tokens tab (Smiths → open a smith → Tokens). Both are shown once. API: minting requires a tenant-admin token:

# Authorization: tenant-admin token (server-side only)
curl https://api.cloud.ingram.tech/v1/tenant/tokens \
  -H "Authorization: Bearer $IC_TOKEN" \
  -H "IC-Api-Version: 2026-05-01" \
  -H "Content-Type: application/json" \
  -d '{ "scope": "smith", "smith_id": "smt_…",
        "permissions": ["runs:read", "runs:write", "memories:read"],
        "ttl_seconds": 3600, "name": "browser session for user_123" }'
# → 201 { "id": "tok_…", "token": "thp_live_<jwt>",
#         "sub": "<tenant>:smt_…", "expires_at": "…" }

For a tenant-admin token: { "scope": "admin", "ttl_seconds": 7776000 } (omit ttl_seconds for non-expiring). ttl_seconds must be positive and at most 315360000 (10 years) — 0 is 422 invalid_request, not "no expiry". GET /v1/tenant/tokens lists minted tokens; DELETE /v1/tenant/tokens/{id} revokes one immediately.

Organization keys & projects

A project is a tenant — the isolation boundary a tenant:* token is bound to. Every /v1/smiths/{id}/… route answers 404 not_found for a smith outside your project, whether it names a smith that belongs to someone else or one that never existed — ids are not secrets, and the two cases are indistinguishable on purpose. One tier up is the organization (your account), which owns many projects. An organization key carries the organization:* scope with sub = your organization id. It is the master key you hand to infrastructure-as-code, and it reads no run, memory, or smith itself — it only:

  • manages projects — POST/GET /v1/organization/projects, GET/DELETE /v1/organization/projects/{pid}
  • mints a project's tenant-admin token — POST /v1/organization/projects/{pid}/tokens → a tenant:* token whose sub is that project id.
  • manages account creditsGET /v1/organization/billing/balance, …/billing/ledger, and POST …/billing/checkout (the balance is org-level, pooled across the account's projects).
# Authorization: organization key (server-side / IaC only)
curl https://api.cloud.ingram.tech/v1/organization/projects \
  -H "Authorization: Bearer $IC_ORG_TOKEN" \
  -H "IC-Api-Version: 2026-05-01" -H "Content-Type: application/json" \
  -d '{ "name": "acme" }'
# → 201 { "id": "proj_…", "organization": "<org>", "name": "acme" }

# Authorization: organization key (server-side / IaC only)
curl https://api.cloud.ingram.tech/v1/organization/projects/proj_…/tokens \
  -H "Authorization: Bearer $IC_ORG_TOKEN" \
  -H "IC-Api-Version: 2026-05-01" -H "Content-Type: application/json" \
  -d '{ "name": "acme prod" }'
# → 201 { "token": "tha_live_<jwt>", "sub": "proj_…", "scopes": ["tenant:*"] }

You mint the organization key once for your account (it is bootstrapped with your org id, like your first admin token), hand it to your Pulumi stack, and let the IcProject and IcProjectToken resources provision a project and drop its tenant:* token straight into each app's env. A leaked org key can reshape your project list and mint project tokens, but cannot read a single run — only the project tokens it mints can. Creating a project by a name that already exists returns the existing one, so re-running your IaC reconciles rather than errors.

In the console: Settings → Organization is where you rename your organization and mint organization keys. The secret is shown once at mint time and never stored — but each key you mint is listed there afterward (its id and when it was minted/expires), so you can see which keys exist. Rotation today is mint-a-new-one; there is no console revocation yet.

The browser pattern

Never ship a tenant-admin token to a client. Instead, on session start your backend mints a short-lived smith token and hands it to the browser:

browser ──login──▶ your backend ──POST /v1/tenant/tokens──▶ Ingram Cloud
browser ◀──thp_live_… (1h)──┘
browser ──POST /v1/smiths/{their id}/runs──▶ Ingram Cloud   # direct, scoped

That last hop is cross-origin, and /v1 allows it — see Browser calls.

Re-mint when it expires (the API returns 401); smith tokens are cheap and stateless.

Connector OAuth

For mcp deployments the API is also an OAuth 2.1 authorization server, so an MCP host (Claude custom connectors, ChatGPT developer-mode connectors) can obtain tokens on its own instead of being handed one. Discovery is standard: the MCP endpoint's 401 names the Protected Resource Metadata (GET /.well-known/oauth-protected-resource/v1/deployments/{depid}/mcp, RFC 9728); GET /.well-known/oauth-authorization-server (RFC 8414) lists POST /oauth/register (Dynamic Client Registration, RFC 7591), GET /oauth/authorize (the consent page), and POST /oauth/token (authorization code + PKCE S256, rotating refresh tokens; private_key_jwt client authentication for clients whose metadata document declares it).

The consent page authenticates the person with a pasted IC token, and the access tokens the flow mints are ordinary smith tokens scoped to runs:write — everything on this page (binding, revocation, expiry) applies to them unchanged. The verification key is published at GET /.well-known/jwks.json; every minted JWT carries its kid. Walkthrough: Connectors.

The same authorization server issues project tokens to third-party apps, so a customer links their Ingram Cloud organization to your product in one click instead of minting and pasting a key. Your app identifies itself with a Client ID Metadata Document: client_id is an HTTPS URL you serve, listing your redirect_uris, and — recommended for a server-side app — token_endpoint_auth_method: "private_key_jwt" with a jwks_uri, so a code can only be redeemed with your key.

Send the person to GET /oauth/authorize with no resource and the scope you want — tenant:* for full access, or a space-separated subset of the vocabulary, plus openid if you also want to sign them in. Ask for the narrowest set that works: managing agents and smiths needs only runs:read runs:write, so most apps never need tenant:*.

https://api.cloud.ingram.tech/oauth/authorize
  ?response_type=code&client_id=https://app.example.com/oauth/client.json
  &redirect_uri=https://app.example.com/callback&scope=tenant:*
  &code_challenge=…&code_challenge_method=S256&state=…

The console asks them to sign in (or up), pick or create the organization, and consent: "Acme wants to: create a project in your organization; have full access to that project; run its agents there and bill your organization's credits." On Allow the browser returns to your redirect_uri with the code; POST /oauth/token (PKCE, plus client_assertion if you declared private_key_jwt) answers

{ "access_token": "tha_live_…", "token_type": "Bearer", "scope": "tenant:*" }

That is an ordinary project token: sub is a project the platform created in the customer's organization, named after your app, adopted again on a re-link. It carries the consented scopes, never expires, and has no refresh token — store it as you would a pasted key. The customer revokes it from Settings → Connected apps (DELETE /v1/organization/apps/{id}), which revokes every token your app holds for that organization at once. Your app never sees an organization key.

Signing the person in at the same time

Add openid to the scope and the token response carries an id_token beside the project token, so linking an organization also signs the person into your app — they never make a second account:

&scope=openid%20email%20runs:read%20runs:write
{ "access_token": "tha_live_…", "token_type": "Bearer",
  "scope": "openid email runs:read runs:write", "id_token": "<jwt>" }

The id_token is an RS256 JWT signed with the same key as every other token: verify it against /.well-known/jwks.json, with iss the API's base URL and aud your client_id. It lives five minutes — long enough to verify and make a session of your own, and useless as an access token. sub is stable for that person across links; email adds email and email_verified, and profile adds name and picture. Ask for email or profile without openid and the request is refused with invalid_scope.

The identity scopes never reach the project token's permissions: scope above mints a token carrying runs:read runs:write and nothing more.

Runs draw on the organization's credits. If the project can't run yet, run creation answers 402 card_required or 402 insufficient_credits; send the person to https://cloud.ingram.tech/console/settings/billing?client_id=<your client_id>&return_url=<a URL on your client_id's origin> and they come back once funded — see When runs are refused.

Permission scopes

Smith tokens carry a subset of the closed vocabulary, and permissions is required: name the scopes the token may use. There is no "grant everything" default — omitting it is 422 permissions_required, because the widest possible token is not a sane thing to get from the shortest possible request. Pass only *:read scopes for a read-only token; the console's token form has a checkbox for exactly that.

Scopes gate operations; the smith binding still confines every call to that one smith's data. Scope is never the only thing standing between a smith token and your tenant's configuration. Every endpoint also declares who may call it at all, independently of scope:

PostureWho may callRefusal
Tenant configuration — agents, skills, the tools registry, webhooks, budgets, customers, provider credentials, messaging setup, smith creationa tenant-admin token403 tenant_token_required
Credentials — model keys, token minting/listing/revocationa tenant-admin token (the tenant:* marker itself)403 admin_required
Account — projects and creditsan organization key403 organization_required
Everything elseany token; a smith token sees only its own smith's data

So a smith token minted with every scope in the vocabulary still cannot read your webhooks, change a budget, or overwrite an OAuth client. The refusal comes before the request body is read.

Scope pairGates
runs:read / runs:writeruns and the smith resource
conversations:read / conversations:writethe conversations resource (threads of runs)
memories:read / memories:writeworking memory + recall
connections:read / connections:writeper-smith OAuth connections
deployments:read / deployments:writemessaging (deployment) endpoints
schedules:read / schedules:writecron schedules
approvals:read / approvals:writeread the approvals queue; write decides one, on every surface that can
traces:read / traces:writespan waterfalls; write gates span ingestion
usage:read / usage:writeusage rollups & budgets; write gates custom meter events and budget changes
customers:read / customers:writeyour customer objects
files:read / files:writefile metadata + bytes; write gates upload and delete
vector_stores:read / vector_stores:writevector stores and search; write gates store/file changes
model_keys:read / model_keys:writea smith's own end-user model keys
skills:read / skills:writeskill bundles and their versions
embeddings:writethe POST /v1/embeddings compute endpoint (write-only)

Missing scope → 403 insufficient_scope with the required scope in details.

Security rules

  • Tenant-admin tokens never leave your server. No exceptions, not even "temporarily" in a mobile build.
  • The API verifies RS256 signatures on every authenticated request; there is no API-key fallback.
  • Revocation is immediate (DELETE /v1/tenant/tokens/{id}), including for non-expiring admin tokens. Name your tokens so you can find the right one. Revoking an id we hold no record of is 404 not_found — never a 204 that revoked nothing.
  • Webhook payloads are authenticated separately, by HMAC signature; see Events & webhooks.
  • A few surfaces are public by design and take no token: inbound provider webhooks (authenticated by the provider's signature) and a hosted page's GET /v1/hosted/{id} + POST /v1/hosted/{id}/chat — gated by the unguessable deployment id and an optional page password, and bounded by your project budget.