Invite partners
An invitation is a one-time, time-limited offer of specific routes on one service, bound to one intended organization. Accepting it creates the grant: the only edge between two organizations.
Create an invitation
Requires service_owner or trust_admin in the sponsor organization, and an Idempotency-Key header. The scope must fit within the service’s route templates.
POST https://api.mutualtrust.io/v1/services/svc_01jcqa7m5r7s9t1v3w5x7y9z1a/invitations
Authorization: Bearer mtk_…
Idempotency-Key: 2f6d0c1a-invite-harbor-orders
Content-Type: application/json
{
"consumer": { "new_org_name": "Harbor Logistics" },
"scope": {
"v": 1,
"routes": [
{ "methods": ["GET"], "path": "/orders/*" },
{ "methods": ["POST"], "path": "/orders" }
]
},
"expires_in_hours": 72,
"grant_expires_at": "2027-06-30T00:00:00Z"
}| Field | Rules |
|---|---|
consumer.org_handle, consumer.new_org_name or consumer.org_id | Exactly one. org_handle invites an existing partner organization by the handle it shared with you (see below). new_org_name (1 to 80 characters) creates a new, unclaimed organization for a partner that is not on Mutual Trust yet. org_id must be an organization you are a member of (for example your own, or a sample partner). |
scope | {"v":1,"routes":[…]}, 1 to 50 rules, each with 1 to 7 methods and a normalized path. Methods are de-duplicated and sorted. |
expires_in_hours | Invitation lifetime, 1 to 720 hours, default 168 (7 days). |
grant_expires_at | Optional RFC 3339 time at which the resulting grant expires. Certificates are never valid beyond it. |
{
"id": "inv_01jcqb2n8c4d6e8f0g2h4j6k8m",
"service_id": "svc_01jcqa7m5r7s9t1v3w5x7y9z1a",
"consumer_org_id": "org_01jcqa7m3a2b7c9d4e6f8g1h3j",
"consumer_binding": "new_org",
"state": "pending",
"expires_at": "2026-11-17T15:40:00Z",
"scope": {
"v": 1,
"routes": [
{
"methods": [
"GET"
],
"path": "/orders/*"
},
{
"methods": [
"POST"
],
"path": "/orders"
}
]
},
"token": "mtinv_V9n2…",
"accept_url": "https://app.mutualtrust.io/#/invitations/inv_01jcqb2n8c4d6e8f0g2h4j6k8m?token=mtinv_V9n2…"
}The secret is shown once
Replaying the same request with the same Idempotency-Key and body returns the original response (with Idempotent-Replayed: true); the same key with a different body returns 409 idempotency_key_reused. List a service’s invitations with GET /v1/services/{id}/invitations, and cancel one with POST /v1/invitations/{id}/cancel.
Invite an existing partner by handle
Every organization has a partner handle, such as harbor-logistics-7kq2m9xd. A partner finds it under Organization > Partner handle and shares it with the API owners who want to invite them. Handles are not guessable, and resolving one reveals only the organization’s display name: no members, services, grants or identifiers.
GET https://api.mutualtrust.io/v1/partner-handles/harbor-logistics-7kq2m9xd
Authorization: Bearer mtk_…{
"handle": "harbor-logistics-7kq2m9xd",
"organization_name": "Harbor Logistics"
}Then invite with {"consumer": {"org_handle": "harbor-logistics-7kq2m9xd"}, …}. The invitation is bound to that organization (consumer_binding: "existing_org") and can be accepted only by its partner administrators or owners. Nobody can claim it, even with the secret. An unknown handle, or one that was replaced, returns 404 not_found.
Owners replace the handle with POST /v1/organizations/{id}/partner-handle/rotate. The old handle stops resolving at once; invitations already sent stay bound to the organization. Members read the current handle with GET /v1/organizations/{id}/partner-handle.
Preview as the recipient
The accept page calls preview with the secret. It reveals only the recipient’s view: who is offering what, not the sponsor’s other partners.
POST https://app.mutualtrust.io/v1/invitations/inv_01jcqb2n8c4d6e8f0g2h4j6k8m/preview
Content-Type: application/json
{ "token": "mtinv_V9n2…" }{
"id": "inv_01jcqb2n8c4d6e8f0g2h4j6k8m",
"state": "pending",
"expires_at": "2026-11-17T15:40:00Z",
"sponsor": "Northstar",
"service": "Orders API",
"environment": "sandbox",
"contact": "api-platform@northstar.example",
"scope": {
"v": 1,
"routes": [
{
"methods": [
"GET"
],
"path": "/orders/*"
},
{
"methods": [
"POST"
],
"path": "/orders"
}
]
},
"consumer_org": {
"id": "org_01jcqa7m3a2b7c9d4e6f8g1h3j",
"name": "Harbor Logistics",
"unclaimed": true,
"you_are_member": false,
"binding": "new_org"
},
"grant_approval": "automatic"
}Accept
Requires an Idempotency-Key. Name the workload and bind its attestation (see OIDC workload identity). In production you must be signed in with a passkey or your organization’s identity provider and bind oidc_jwt.
POST https://app.mutualtrust.io/v1/invitations/inv_01jcqb2n8c4d6e8f0g2h4j6k8m/accept
Idempotency-Key: 9a41-accept-orders
X-CSRF-Token: <csrf_token>
Content-Type: application/json
{
"token": "mtinv_V9n2…",
"consumer_org_id": "org_01jcqa7m3a2b7c9d4e6f8g1h3j",
"workload": { "name": "harbor-order-sync", "attestation_kind": "sandbox_token" }
}{
"grant_id": "gr_01jcqb3p1n3p5q7r9s1t3v5w7x",
"state": "active",
"service": "Orders API",
"environment": "sandbox",
"workload": {
"id": "wl_01jcqb3p1y2z4a6b8c0d2e4f6g",
"name": "harbor-order-sync",
"principal": "urn:mutualtrust:workload:sandbox:org_01jcqa7m3a2b7c9d4e6f8g1h3j:wl_01jcqb3p1y2z4a6b8c0d2e4f6g"
},
"scope": {
"v": 1,
"routes": [
{
"methods": [
"GET"
],
"path": "/orders/*"
},
{
"methods": [
"POST"
],
"path": "/orders"
}
]
}
}Acceptance consumes the token, creates the workload and the grant, and writes an audit entry on both sides in one atomic batch. Two concurrent accepts produce one grant; the loser gets 409 invitation_not_pending. When the service uses sponsor review, the grant is created with "state": "pending" and "awaiting_approval": true, and enrollment opens once the API owner approves it.
| Status | Code | Cause |
|---|---|---|
| 403 | forbidden | consumer_org_id is not the intended organization, you lack partner_admin/owner in it, a sandbox guest tried a production invitation, or an API credential tried to claim a new organization. |
| 404 | not_found | Unknown invitation or wrong token, or you are not a member of the existing organization the invitation is bound to. |
| 409 | concurrent_change | Someone else claimed the new organization first. |
| 409 | invitation_not_pending | Already accepted, declined, canceled, or lost a race. |
| 409 | invitation_expired | Past expires_at. Ask the API owner for a new invitation. |
| 409 | capacity_exceeded | Production only: the sponsor has no remaining connections. See Billing. |
| 422 | validation_failed | Sandbox attestation in production; OIDC binding missing issuer, audience or subject; issuer not approved for your organization. |
| 428 | idempotency_key_required | Missing Idempotency-Key. |
Claiming a new organization
When an invitation targets an organization created with new_org_name, that organization has no members until someone accepts. The first user to accept with the valid secret is bootstrapped explicitly as its owner and partner_admin; the bootstrap is recorded in the audit log with reason invitation_claim. Billing privileges are never implied.
- The claim is bound to the first accepter. It commits in the same batch as the acceptance and only while the organization still has no members, so a concurrent claimer gets
409 concurrent_change. Afterwards every invitation for the organization needspartner_adminorowner. - Claims need a signed-in browser session. API credentials cannot claim. For production services the session must come from a passkey or your organization single sign-on; sandbox guests can claim only sandbox invitations.
- Only invitations that created the organization can claim it. Invitations sent to an existing organization by handle never can.
Preview shows consumer_org.unclaimed: true so the recipient knows they are about to become the owner.
Sponsor review
By default an accepted invitation activates its grant (grant_approval: "automatic"). Set a service to sponsor_review to approve each partner yourself: acceptance then creates the grant in pending. A pending grant consumes no production capacity, appears in no policy revision and cannot enroll.
PATCH https://api.mutualtrust.io/v1/services/svc_01jcqa7m5r7s9t1v3w5x7y9z1a
Authorization: Bearer mtk_…
Content-Type: application/json
{ "grant_approval": "sponsor_review" }The portal lists pending grants on the service page under Awaiting your approval; the API lists them in GET /v1/grants?service_id=… with state: "pending". Approve or reject with the grant revision:
POST https://api.mutualtrust.io/v1/grants/gr_01jcqb3p1n3p5q7r9s1t3v5w7x/approve
If-Match: "1"
Content-Type: application/json
{ "note": "Contract signed 2026-11-14" }POST https://api.mutualtrust.io/v1/grants/gr_01jcqb3p1n3p5q7r9s1t3v5w7x/reject
If-Match: "1"
Content-Type: application/json
{ "reason_code": "owner_request", "note": "No agreement in place" }Both return a GrantTransition. Approval moves the grant to active, emits grant.activated and publishes a new policy revision. In production it checks connection capacity atomically and returns 409 capacity_exceeded when the plan is full; the grant stays pending so you can approve it after upgrading. Rejection moves the grant to revoked with the reason, which the partner sees on their side. Approving or rejecting a grant that is not pending returns 409 invalid_transition. The partner sees Awaiting approval until you decide. Switching back to automatic affects new acceptances only; grants already pending still need a decision.
Decline or cancel
POST /v1/invitations/inv_01jcqb2n8c4d6e8f0g2h4j6k8m/decline
Content-Type: application/json
{ "token": "mtinv_V9n2…" }{
"id": "inv_01jcqb2n8c4d6e8f0g2h4j6k8m",
"state": "declined"
}The sponsor cancels with POST /v1/invitations/{id}/cancel (no body) → {"id":"…","state":"canceled"}. Both only work while the invitation is pending; otherwise 409 invitation_not_pending. Unanswered invitations become expired automatically.
See your relationships
- Sponsor view:
GET /v1/grants?service_id=svc_…lists every grant on a service with consumer, workload, state, revision, scope and the latest certificate expiry. - Partner view:
GET /v1/grants?org_id=org_…lists only your side: service name and contact, sponsor name, workload, scope, certificate expiry andrenew_after. - Detail:
GET /v1/grants/{id}returnsside(sponsororconsumer), the workload principal, recent certificates andlast_successful_call.
Change scope with a preview
Preview first. No If-Match is needed and nothing changes:
PATCH https://api.mutualtrust.io/v1/grants/gr_01jcqb3p1n3p5q7r9s1t3v5w7x/scope
Content-Type: application/json
{
"scope": { "v": 1, "routes": [ { "methods": ["GET"], "path": "/orders/*" } ] },
"preview": true
}{
"preview": true,
"diff": {
"added": [],
"removed": [
"POST /orders"
],
"unchanged": [
"GET /orders/*"
]
},
"current_revision": 2
}Then publish the same scope with the revision you previewed against:
PATCH https://api.mutualtrust.io/v1/grants/gr_01jcqb3p1n3p5q7r9s1t3v5w7x/scope
If-Match: "2"
Content-Type: application/json
{ "scope": { "v": 1, "routes": [ { "methods": ["GET"], "path": "/orders/*" } ] }, "note": "Read-only until Q1 integration review" }{
"grant_id": "gr_01jcqb3p1n3p5q7r9s1t3v5w7x",
"revision": 3,
"diff": {
"added": [],
"removed": [
"POST /orders"
],
"unchanged": [
"GET /orders/*"
]
},
"operation_id": "op_01jcqm4x7f9g1h3j5k7m9n1p3q"
}Scope can change only on active or suspended grants and must stay within the service’s route templates (422 otherwise). Follow the operation_id to see each gateway apply it.
Resellers and managed service providers
Partners on this page are the organizations whose workloads call your APIs. A reseller or managed service provider that administers customer organizations on their behalf works differently: through explicit, customer-accepted delegations, with its own console of delegated tenants. See Resellers and MSPs.