Vetted platform partners can connect a customer by email. STACK creates a user-owned account or opens a consent flow for an existing account. The partner receives an opaque sk_grant_* token limited to the approved routes, providers, and resources.
The /v1/partner/* endpoints require a partner operator API key. STACK staff mark your account as a partner and set your allowed provider list. Non-partner keys get 403. The grant token itself is documented in the "Using the Grant Token" section below.
Connect a customer to STACK by their real email. Two branches, decided by whether the email already has a STACK account:
curl -X POST https://api.getstack.run/v1/partner/connect \
-H "Authorization: Bearer sk_live_partner_key" \
-H "Content-Type: application/json" \
-d '{
"email": "customer@example.com",
"external_customer_id": "cus_your_id",
"providers": ["agentmail"]
}'Returns 201 when the grant was created, 200 on an idempotent repeat:
{
"grant_id": "pgr_abc123",
"status": "active",
"grant_token": "sk_grant_...",
"user_operator_id": "op_xyz",
"external_customer_id": "cus_your_id",
"scopes": ["agents:read", "agents:write", "passports:read", "passports:write",
"missions:read", "missions:write", "proxy:write", "services:read", "services:connect",
"inbound-webhooks:read", "inbound-webhooks:write"],
"allowed_providers": ["agentmail"],
"created": true
}grant_token is returned exactly once, on the call that created the grant (created: true). Idempotent repeats return created: false without it. There is no retrieval path - if the token is lost, rotate it.
On the existing-email branch the response carries status: "pending_consent"; poll the connection (or wait for the customer) until it transitions to active, declined, or expired. Rate limit: 100 connect calls per hour per partner; a breach fires a partner_connect_burst security event and returns 403 with code TIER_LIMIT_EXCEEDED, not 429.
List every grant your partner operator holds, in all statuses (active, pending_consent,revoked, declined,expired). Tokens are never returned.
curl https://api.getstack.run/v1/partner/connections \
-H "Authorization: Bearer sk_live_partner_key"One grant by id. Includes status, scopes, allowed providers, and lifecycle timestamps (consented_at, revoked_at,last_used_at). Grants belonging to another partner return 404.
curl https://api.getstack.run/v1/partner/connections/pgr_abc123 \
-H "Authorization: Bearer sk_live_partner_key"Revoke the grant. The token fails on its next request. Passports of grant-created agents revoke and fail on their next STACK-verified call, grant-created webhooks stop resolving, and grant-created service connections disconnect.
curl -X DELETE https://api.getstack.run/v1/partner/connections/pgr_abc123 \
-H "Authorization: Bearer sk_live_partner_key"{
"revoked": true,
"agents_passports_revoked": 3,
"webhooks_revoked": 2,
"connections_disconnected": 1
}Rotate a lost or leaked token. The old sk_grant_* stops working. The response returns the new plaintext once with the grant view.
curl -X POST https://api.getstack.run/v1/partner/connections/pgr_abc123/rotate-token \
-H "Authorization: Bearer sk_live_partner_key"The grant token is an opaque credential resolved server-side on every request (peppered hash at rest - not a JWT, nothing to decode). Use it exactly like an API key:
curl -X POST https://api.getstack.run/v1/agents/register \
-H "Authorization: Bearer sk_grant_..." \
-H "Content-Type: application/json" \
-d '{ "name": "inbox-agent", "accountability_mode": "logged" }'A grant has no ambient authority. Three fail-closed layers bound every request:
POST /v1/agents/register agents:write
PATCH /v1/agents/:id agents:write
GET /v1/agents agents:read
GET /v1/agents/:id agents:read
GET /v1/agents/:id/stats agents:read
POST /v1/passports/issue passports:write
POST /v1/passports/verify passports:read
POST /v1/passports/revoke passports:write
GET /v1/passports/active passports:read
POST /v1/missions missions:write
POST /v1/missions/start missions:write + passports:write
POST /v1/missions/:id/complete missions:write
POST /v1/missions/:id/revoke missions:write
GET /v1/missions missions:read
GET /v1/missions/:id missions:read
POST /v1/proxy proxy:write
GET /v1/services services:read
GET /v1/services/connected services:read
GET /v1/services/templates services:read
POST /v1/services/connect services:connect
POST /v1/services/grant services:connect
POST /v1/operator/inbound-webhooks inbound-webhooks:write
GET /v1/operator/inbound-webhooks inbound-webhooks:read
GET /v1/operator/inbound-webhooks/:id inbound-webhooks:read
DELETE /v1/operator/inbound-webhooks/:id inbound-webhooks:writeThe scope vocabulary is deliberately a subset: no admin, no billing:*, no identity:*, no credentials:*. A grant cannot read raw credentials, change account-wide settings, or reach governance routes. Revoke and reconnect when the customer approves a different scope.
Every Passport is issued for exactly one Mission, so a grant opens a Mission before it issues. POST /v1/missions/start with agent_id opens the Mission and issues its Passport in one call. It needs both missions:write and passports:write. A grant must pass agent_id on Mission calls, naming an agent it created.
curl -X POST https://api.getstack.run/v1/missions/start \
-H "Authorization: Bearer sk_grant_..." \
-H "Content-Type: application/json" \
-d '{
"agent_id": "agt_created_by_grant",
"mission": { "intent_summary": "Triage inbound email", "intent_services": ["agentmail"] }
}'The response carries mission_id and passport: { token, jti, expires_at }. To issue separately, open the Mission with POST /v1/missions and pass mission_ids: ["ms_..."] to POST /v1/passports/issue. See Missions API.
Agents default to accountability_mode: "enforced", which requires an intent declaration at passport issue - without one the issue call returns 400 ACCOUNTABILITY_REQUIRED. Partners either register agents with accountability_mode: "standard" or"logged", or supply intent at issue time.
As the account owner, list every partner holding a grant on your account - partner named, scopes and providers visible. This drives the Partners tab under Services in the dashboard.
curl https://api.getstack.run/v1/operator/partner-grants \
-H "Authorization: Bearer sk_live_your_key"Revoke a partner's grant on your account. Same cascade as the partner-side revoke; returns the same cascade counts. Grant tokens can never reach these routes - a partner cannot manage its own grant from the user side.
curl -X DELETE https://api.getstack.run/v1/operator/partner-grants/pgr_abc123 \
-H "Authorization: Bearer sk_live_your_key"White-label tenants are a supported alternative to Connect for platforms that cannot supply customer emails, or that must not expose a direct STACK account per customer. Each tenant is an isolated child operator with its own audit chain, signing key, webhooks, and passports. It has no human user; you hold its API key server-side. You see tenant metadata and usage counts, never tenant audit content, intent bodies, or security-event metadata.
Tenants have no plan of their own. Every metered tenant call (actions, including proxy calls and inbound-webhook receives; email; SMS; L3 classifications) counts against your partner account's single allowance and wallet. STACK staff set your deal's limits: monthly actionsPerMonth, emailPerMonth, smsPerMonth, and l3CallsPerMonth for the whole pool, maxTenants, and an overage mode. wallet (default) charges usage past the allowance to your prepaid wallet; block refuses it with 402 ALLOWANCE_EXHAUSTED.
Create a tenant. The body takes an optional external_customer_id, unique per partner (a duplicate returns 409). It takes no tier; sending one returns 400 TENANT_TIER_NOT_ALLOWED. Creating a tenant past the deal's maxTenants returns 409 TENANT_LIMIT_REACHED. The tenant API key is returned once.
curl -X POST https://api.getstack.run/v1/partner/tenants \
-H "Authorization: Bearer sk_live_partner_key" \
-H "Content-Type: application/json" \
-d '{ "external_customer_id": "cus_your_id" }'{
"operator_id": "op_...",
"api_key": "sk_live_...",
"external_customer_id": "cus_your_id",
"tier": "partner",
"billing": "partner",
"created_at": "2026-09-29T12:00:00.000Z"
}List your tenants or read one. Each view reports tier: "partner", billing: "partner", and usage: { period, actions }: the tenant's metered actions this UTC month (period is YYYY-MM), which is its share of your pool.
Soft-revoke a tenant. Its key stops working, and its inbound webhooks and passports are revoked. The cascade runs on every call, so a retried DELETE finishes a cascade that failed part-way. transitioned is false when the tenant was already revoked.
{ "transitioned": true, "webhooks_revoked": 2, "passports_revoked": 5 }Issue a Passport under a tenant. The body is the POST /v1/passports/issue body, so mission_ids must hold exactly one live Mission of that tenant. Open it with the tenant key, then issue with your partner key.
# 1. Open the Mission under the tenant (tenant key)
curl -X POST https://api.getstack.run/v1/missions \
-H "Authorization: Bearer sk_live_tenant_key" \
-H "Content-Type: application/json" \
-d '{ "agent_id": "agt_tenant_agent", "intent_summary": "Triage inbound email", "intent_services": ["agentmail"] }'
# 2. Issue its Passport (partner key)
curl -X POST https://api.getstack.run/v1/partner/tenants/op_tenant/passports/issue \
-H "Authorization: Bearer sk_live_partner_key" \
-H "Content-Type: application/json" \
-d '{ "agent_id": "agt_tenant_agent", "mission_ids": ["ms_..."] }'A tenant key can also call POST /v1/missions/start to open the Mission and issue its Passport in one call.
Guarded changes made with a tenant key, such as activating a fleet policy, return 403 GOVERNANCE_APPROVAL_REQUIRED with an approval id. Tenants have no human user, so your account owner or an admin member approves them in the STACK dashboard, at Governance approvals, where each one is labelled with the tenant. Then retry the same call with the tenant key and the X-Governance-Approval header. API keys, yours or the tenant's, cannot approve. GET /v1/governance/approvals with your partner key lists your tenants' approvals with tenant: { operator_id, name }, where name is the tenant's external_customer_id or its id. Each decision is recorded on the tenant's audit chain and on yours.
For partner-owned tenants, a partner administrator configures webhook signing through a direct authenticated request. Send signing_secret in the JSON body. AgentMail uses its provider signing key; generic webhooks use the shared HMAC key configured at the sender. AgentMail forwarding can also set downstream_signing_secret for the receiving application. The response contains only {"configured":true}. OAuth, grant tokens, and agent sessions cannot configure these keys. Keep keys in the administrator's local configuration; do not paste them into an assistant conversation.
SDK methods: partner.tenants.setInboundWebhookSigningSecret in TypeScript and partner_tenants.set_inbound_webhook_signing_secret in both Python clients. Both take the tenant ID, webhook ID, signing key, and an optional downstream key.
POST /v1/partner/connect - connect a customer by email
GET /v1/partner/connections - list your grants
GET /v1/partner/connections/:id - get one grant
DELETE /v1/partner/connections/:id - revoke a grant (cascades)
POST /v1/partner/connections/:id/rotate-token - rotate the grant token
GET /v1/operator/partner-grants - (user) list partner grants on your account
DELETE /v1/operator/partner-grants/:id - (user) revoke a partner grant
POST /v1/partner/tenants - create a white-label tenant
GET /v1/partner/tenants - list tenants
GET /v1/partner/tenants/:id - get one tenant
DELETE /v1/partner/tenants/:id - revoke a tenant (cascades)
POST /v1/partner/tenants/:tenant_id/inbound-webhooks - provision a tenant webhook
GET /v1/partner/tenants/:tenant_id/inbound-webhooks - list tenant webhooks
DELETE /v1/partner/tenants/:tenant_id/inbound-webhooks/:wh_id - revoke a tenant webhook
POST /v1/partner/tenants/:tenant_id/passports/issue - issue a passport under a tenant