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 /v1/services/{id}/invitations
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" }
CreateInvitation fields
FieldRules
consumer.org_handle, consumer.new_org_name or consumer.org_idExactly 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_hoursInvitation lifetime, 1 to 720 hours, default 168 (7 days).
grant_expires_atOptional RFC 3339 time at which the resulting grant expires. Certificates are never valid beyond it.
201 Created
{ "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

The token is stored only as a hash. Send the accept_url to your partner contact over a channel you trust; anyone holding it can preview the invitation. It can only be accepted for the intended organization: an existing partner’s partner administrators or owners accept it, and for a new, unclaimed organization the first signed-in person to accept it becomes that organization’s owner.

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.

Resolve a handle (service owner or trust administrator, rate limited)
GET https://api.mutualtrust.io/v1/partner-handles/harbor-logistics-7kq2m9xd Authorization: Bearer mtk_…
200 OK
{ "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 /v1/invitations/{id}/preview
POST https://app.mutualtrust.io/v1/invitations/inv_01jcqb2n8c4d6e8f0g2h4j6k8m/preview Content-Type: application/json { "token": "mtinv_V9n2…" }
200 OK
{ "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 /v1/invitations/{id}/accept
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" } }
201 Created
{ "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.

Acceptance errors
StatusCodeCause
403forbiddenconsumer_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.
404not_foundUnknown invitation or wrong token, or you are not a member of the existing organization the invitation is bound to.
409concurrent_changeSomeone else claimed the new organization first.
409invitation_not_pendingAlready accepted, declined, canceled, or lost a race.
409invitation_expiredPast expires_at. Ask the API owner for a new invitation.
409capacity_exceededProduction only: the sponsor has no remaining connections. See Billing.
422validation_failedSandbox attestation in production; OIDC binding missing issuer, audience or subject; issuer not approved for your organization.
428idempotency_key_requiredMissing 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 needs partner_admin or owner.
  • 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.

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.

Turn on sponsor review (service owner or trust administrator)
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:

Approve
POST https://api.mutualtrust.io/v1/grants/gr_01jcqb3p1n3p5q7r9s1t3v5w7x/approve If-Match: "1" Content-Type: application/json { "note": "Contract signed 2026-11-14" }
Reject
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

Decline (recipient, with the secret)
POST /v1/invitations/inv_01jcqb2n8c4d6e8f0g2h4j6k8m/decline Content-Type: application/json { "token": "mtinv_V9n2…" }
200 OK
{ "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 and renew_after.
  • Detail: GET /v1/grants/{id} returns side (sponsor or consumer), the workload principal, recent certificates and last_successful_call.

Change scope with a preview

Preview first. No If-Match is needed and nothing changes:

Preview a scope change
PATCH https://api.mutualtrust.io/v1/grants/gr_01jcqb3p1n3p5q7r9s1t3v5w7x/scope Content-Type: application/json { "scope": { "v": 1, "routes": [ { "methods": ["GET"], "path": "/orders/*" } ] }, "preview": true }
200 OK: preview
{ "preview": true, "diff": { "added": [], "removed": [ "POST /orders" ], "unchanged": [ "GET /orders/*" ] }, "current_revision": 2 }

Then publish the same scope with the revision you previewed against:

Apply the scope change
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" }
200 OK (ETag: "3")
{ "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.