SDKs: Go, Python and TypeScript

Typed clients for the Mutual Trust API, generated from its OpenAPI contract. Go and Python are the defaults and depend on nothing but their standard libraries; TypeScript is an option. Each handles Idempotency-Key and If-Match, retries that honor Retry-After, and typed errors from RFC 9457 problem details.

Documentation menu

Choose an SDK

Three clients are generated from the same OpenAPI contract and expose the same operations, grouped and named the same way in each language's style. Go and Python are the defaults: each is a single library with no third-party dependencies, so there is nothing else to review or install, and it runs on any server that has the language. The TypeScript SDK is an option for teams that already run Node.js.

SDKs
SDKRequiresDependenciesPackage
GoGo 1.22 or laterGo standard library onlymutualtrust.io/sdk-go (package mutualtrust)
PythonPython 3.9 or laterPython standard library only (urllib)mutualtrust
TypeScript (option)Node.js 18.17 or later, Deno, Bun, browsers or Workersnone (standard fetch)@sectigo/mutualtrust-sdk

All three send the API credential as a bearer token, generate and reuse Idempotency-Key values, send If-Match for changes that need a revision, retry 429 and 503 with the same rules, and turn RFC 9457 problem details into one typed error. Operations only a portal session can call (passkeys, minting API credentials, MCP consent) and the Stripe webhook are not in the SDKs.

Install

Go

Go module
go get mutualtrust.io/sdk-go

Until the import path is published

The module path is mutualtrust.io/sdk-go, but mutualtrust.io does not serve the Go import metadata for it yet, so go get cannot fetch it. Until it does, use the module from a checkout of the Mutual Trust repository, where it lives in sdks/go: go mod edit -require=mutualtrust.io/sdk-go@v0.1.0 -replace=mutualtrust.io/sdk-go=/path/to/mutualtrust/sdks/go. Your imports stay the same when the path is published; only the replace line goes away.

Python

pip
pip install mutualtrust

Until the package is on PyPI

The package is not on PyPI yet, so pip install mutualtrust does not find it. Until it is, install the wheel CI builds on every change (dist/python-sdk/mutualtrust-0.1.0-py3-none-any.whl in the bundles workflow artifact) with pip install ./mutualtrust-0.1.0-py3-none-any.whl, or install from a checkout of the repository with pip install ./sdks/python. The wheel is pure Python with no dependencies, so it installs on a host without internet access: copy it over and run pip install --no-index ./mutualtrust-0.1.0-py3-none-any.whl.

TypeScript (option)

Build from the repository
cd packages/sdk npm run build # emits dist/ (ES modules + .d.ts)

Create a client

The Go and Python clients read the credential from the MUTUALTRUST_API_CREDENTIAL environment variable unless you pass one; the TypeScript client takes it as an option. Create the credential in the portal under Organization, API credentials; it carries a subset of your roles in one organization. The base URL defaults to https://api.mutualtrust.io.

Go
package main import ( "context" "fmt" "log" mutualtrust "mutualtrust.io/sdk-go" ) func main() { ctx := context.Background() mt := mutualtrust.New() // credential from MUTUALTRUST_API_CREDENTIAL ov, err := mt.Identity.Overview(ctx, nil) if err != nil { log.Fatal(err) } for _, s := range ov.Services { fmt.Println(s.ID, s.Name, s.Environment) } }
Python
import mutualtrust mt = mutualtrust.Client() # credential from MUTUALTRUST_API_CREDENTIAL for s in mt.identity.overview()["services"]: print(s["id"], s["name"], s["environment"])
TypeScript
import { MutualTrustClient } from '@sectigo/mutualtrust-sdk'; const mt = new MutualTrustClient({ credential: process.env.MUTUALTRUST_API_CREDENTIAL }); const overview = await mt.identity.overview(); for (const s of overview.services) console.log(s.id, s.name, s.environment);
Client options
SettingGo optionPython argumentTypeScript optionDefault
API originWithBaseURLbase_url=baseUrlhttps://api.mutualtrust.io
API credentialWithCredentialfirst argument or credential=credentialMUTUALTRUST_API_CREDENTIAL (Go, Python); none (TypeScript)
HTTP stackWithHTTPClient(*http.Client)ssl_context=, opener=fetch60 s timeout, proxy from the environment, redirects not followed
Retries after the first attemptWithMaxRetriesmax_retries=maxRetries2
First backoff, longest accepted waitWithRetryDelays(base, max)retry_base_delay=, max_retry_delay=retryBaseDelayMs, maxRetryDelayMs0.5 s, 30 s
Extra headersWithHeaderheaders=headersnone
User-Agent prefixWithUserAgentuser_agent=via headersSDK name and version

Common IT tasks

These examples are compiled and tested with the SDKs (sdks/go/example_test.go, sdks/python/examples/it_tasks.py). Each needs a credential with the right roles (see Organization and roles): withdrawing grants needs grant.manage (trust_admin or service_owner), the audit export needs audit export rights granted by an owner, and an API credential can be revoked by its creator or an owner.

Withdraw a partner

Revoke every live grant one partner organization holds on a service. Pending grants are rejected. Each change sends the revision you listed as If-Match, so a grant someone changed in the meantime is reported, not overwritten. Gateways stop the partner's calls once they apply the new policy; see Suspend, withdraw and revoke.

Go
ctx := context.Background() mt := mutualtrust.New() serviceID, partnerOrgID := "svc_...", "org_..." grants, err := mt.Grants.List(ctx, &mutualtrust.GrantsListOptions{ServiceID: serviceID}) if err != nil { log.Fatal(err) } for _, g := range grants.Data { if g.ConsumerOrgID == nil || *g.ConsumerOrgID != partnerOrgID { continue } reason := mutualtrust.GrantReason{ReasonCode: mutualtrust.GrantReasonCodeContractEnded, Note: mutualtrust.Ptr("Contract ended")} opts := &mutualtrust.RequestOptions{IfMatch: mutualtrust.Revision(g.Revision)} switch g.State { case mutualtrust.GrantStateActive, mutualtrust.GrantStateSuspended: _, err = mt.Grants.Revoke(ctx, g.ID, reason, opts) case mutualtrust.GrantStatePending: _, err = mt.Grants.Reject(ctx, g.ID, reason, opts) default: continue // already revoked, rejected or expired } if mutualtrust.ErrorCode(err) == "revision_mismatch" { log.Printf("%s changed since it was listed; run again", g.ID) continue } if err != nil { log.Fatal(err) } fmt.Println("withdrew", g.ID) }
Python
def withdraw_partner(mt: mutualtrust.Client, service_id: str, partner_org_id: str) -> List[str]: """Revoke every live grant one partner organization holds on a service.""" withdrawn = [] for g in mt.grants.list(service_id=service_id)["data"]: if g.get("consumer_org_id") != partner_org_id: continue reason = {"reason_code": "contract_ended", "note": "Contract ended"} try: if g["state"] in ("active", "suspended"): mt.grants.revoke(g["id"], reason, if_match=g["revision"]) elif g["state"] == "pending": mt.grants.reject(g["id"], reason, if_match=g["revision"]) else: continue # already revoked, rejected or expired except mutualtrust.MutualTrustError as e: if e.code == "revision_mismatch": print(f"{g['id']} changed since it was listed; run again") continue raise withdrawn.append(g["id"]) return withdrawn

List expiring certificates

Each grant summary carries cert_expires_at, the expiry of the bound workload's current leaf. Leaves last 24 hours and renew at about two-thirds of their validity, so a leaf inside its last few hours usually means a workload that stopped renewing (see Renew certificates).

Go
ctx := context.Background() mt := mutualtrust.New() grants, err := mt.Grants.List(ctx, &mutualtrust.GrantsListOptions{ServiceID: "svc_..."}) if err != nil { log.Fatal(err) } soon := time.Now().Add(7 * 24 * time.Hour) for _, g := range grants.Data { if g.CertExpiresAt == nil { continue } at, err := time.Parse(time.RFC3339, *g.CertExpiresAt) if err == nil && at.Before(soon) { fmt.Printf("%s\t%s\t%s\texpires %s\n", g.ID, deref(g.ConsumerName), deref(g.WorkloadName), at.Format(time.RFC3339)) } }
Python
def expiring_certificates(mt: mutualtrust.Client, service_id: str, days: int = 7) -> List[str]: """Grants on a service whose workload certificate expires within `days`.""" soon = datetime.now(timezone.utc) + timedelta(days=days) out = [] for g in mt.grants.list(service_id=service_id)["data"]: at = g.get("cert_expires_at") if at and datetime.fromisoformat(at.replace("Z", "+00:00")) < soon: out.append(f"{g['id']}\t{g.get('consumer_name', '')}\t{g.get('workload_name', '')}\texpires {at}") return out

Export the audit log

exportAudit returns the canonical mt.audit_export.v1 document for a time range (from inclusive, to exclusive, RFC 3339). The response header x-content-sha256 (available through the response callback) is the SHA-256 of the exact body the server sent; the SDKs decode the body, so keep a byte-exact copy with the HTTP API when you need to verify that hash later.

Go
ctx := context.Background() mt := mutualtrust.New() now := time.Now().UTC() from := time.Date(now.Year(), now.Month()-1, 1, 0, 0, 0, 0, time.UTC) to := from.AddDate(0, 1, 0) export, err := mt.Organizations.ExportAudit(ctx, "org_...", &mutualtrust.OrganizationsExportAuditOptions{ From: from.Format(time.RFC3339), To: to.Format(time.RFC3339), }) if err != nil { log.Fatal(err) } f, err := os.Create("audit-" + from.Format("2006-01") + ".json") if err != nil { log.Fatal(err) } defer f.Close() if err := json.NewEncoder(f).Encode(export); err != nil { log.Fatal(err) } fmt.Println(export.RowCount, "events")
Python
def export_audit(mt: mutualtrust.Client, org_id: str, path: str) -> int: """Export last month's audit log to a JSON file (needs audit export rights).""" first = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0) start = (first - timedelta(days=1)).replace(day=1) export = mt.organizations.export_audit(org_id, from_=start.strftime("%Y-%m-%dT%H:%M:%SZ"), to=first.strftime("%Y-%m-%dT%H:%M:%SZ")) with open(path, "w", encoding="utf-8") as f: json.dump(export, f) return export["row_count"]

Rotate an API credential

Minting a credential needs a portal session (and a passkey), so it is not in the SDKs: create the new credential in the portal, put it where your automation reads MUTUALTRUST_API_CREDENTIAL (a vault, a CI secret), then use the SDK to check the new credential and revoke the old one.

Go
ctx := context.Background() orgID, oldCredentialID := "org_...", "cred_..." mt := mutualtrust.New() // now configured with the new credential // Prove the new credential works before revoking the old one. if _, err := mt.Identity.Me(ctx, nil); err != nil { log.Fatal("new credential does not work: ", err) } creds, err := mt.Organizations.ListAPICredentials(ctx, orgID, nil) if err != nil { log.Fatal(err) } for _, c := range creds.Data { fmt.Println(c.ID, c.Name, c.Active, c.ExpiresAt, deref(c.LastUsedAt)) } _, err = mt.Organizations.RevokeAPICredential(ctx, orgID, oldCredentialID, nil) var e *mutualtrust.Error if errors.As(err, &e) { log.Fatalf("revoke failed: %s (request_id %s)", e.Code, e.RequestID) }
Python
def rotate_credential(mt: mutualtrust.Client, org_id: str, old_credential_id: str) -> None: """After deploying a new credential (minted in the portal), check it and revoke the old one.""" mt.identity.me() # raises MutualTrustError (401) if the new credential does not work for c in mt.organizations.list_api_credentials(org_id)["data"]: print(c["id"], c["name"], c["active"], c["expires_at"], c.get("last_used_at")) mt.organizations.revoke_api_credential(org_id, old_credential_id)

Operations

Each client has one group per API tag and one method per operation that an API credential (or a workload or gateway proof) can call: 89 operations and 220 types, generated together. Path parameters come first, then the request body, then options. Names follow each language: mt.Grants.UpdateScope in Go, mt.grants.update_scope in Python, mt.grants.updateScope in TypeScript. Every method's documentation names its HTTP operation, and each SDK exports the full map (OperationIndex, OPERATION_INDEX).

Operation groups
Group (Go / Python / TypeScript)Methods (TypeScript names)
Identity / identityme, overview, tryOrdersApi
Organizations / organizationscreate, listMembers, assignRole, removeMember, removeRole, getPartnerHandle, rotatePartnerHandle, resolvePartnerHandle, listWorkloadIssuers, approveWorkloadIssuer, listAuditEvents, listSupportGrants, createSupportGrant, revokeSupportGrant, listExportGrants, createExportGrant, revokeExportGrant, exportAudit, listApiCredentials, revokeApiCredential, listProductionAccessRequests, requestProductionAccess, getApprovalPolicy
Billing / billinggetSummary, createCheckoutSession, createPortalSession
Spaces / spacescreate, get, getTrustBundle, getApprovalPolicy, putApprovalPolicy
Services / servicescreate, get, update, listReceipts, listInvitations, createInvitation, getApprovalPolicy, putApprovalPolicy
Gateways / gatewaysapprovePairing, createPairing, getPairing, disable, getManifest, acknowledge, sendReceipts
Invitations / invitationspreview, accept, decline, cancel
Grants / grantslist, get, approve, reject, revoke, suspend, resume, updateScope, getOperation
Enrollment / enrollmentupdateWorkloadBinding, createEnrollmentToken, start, complete, getIssuanceRequest
Certificates / certificatesrenew, confirmReload, revoke, getChain
Integrations / integrationslistAdapters, create, list, get, update, getDesiredState, createPlan, listPlans, getPlan, getApproval, createRun, listRuns, getEvidence, getApprovalPolicy, putApprovalPolicy
Platform / platformrecordAdoptionEvent, health, getCrl

Models follow each language too. Go has one struct per schema with json tags; optional fields are pointers (mutualtrust.Ptr("x") builds one), and optional request fields that accept null are mutualtrust.Nullable[T], so leaving a field out (unchanged) differs from mutualtrust.Null[string]() (cleared). Python models are TypedDicts: responses are plain dicts and request bodies are dict literals, with None sent as null. Operations that return PEM return a string; platform.getCrl returns DER bytes. Anonymous operations (invitation preview, enrollment) never send the client credential, and gateway operations take the gateway credential as a per-call option.

Errors

Every non-2xx response becomes one typed error built from the RFC 9457 problem details: *mutualtrust.Error in Go, mutualtrust.MutualTrustError in Python, MutualTrustError in TypeScript. Match on the code: codes are stable and listed in the error catalogue.

Error fields
GoPythonTypeScriptMeaning
StatusstatusstatusHTTP status; 0 when no response arrived (the code is then network_error).
CodecodecodeCatalogue code, for example revision_mismatch, forbidden, rate_limited.
TitletitletitleHuman-readable summary.
RequestIDrequest_idrequestIdrequest_id from the problem (or the X-Request-Id header). Quote it to support.
ErrorserrorserrorsField errors for 422 validation failures (path and message).
RetryAfterretry_afterretryAfterSeconds the server asked you to wait (429, 503).
ProblemproblemproblemThe full problem document.
Go
_, err := mt.Grants.Get(ctx, id, nil) if mutualtrust.IsNotFound(err) { // missing, or another organization's grant (which reads as missing) } var e *mutualtrust.Error if errors.As(err, &e) { log.Printf("%s: %s (request_id %s)", e.Code, e.Title, e.RequestID) }
Python
try: mt.grants.suspend(grant_id, {"reason_code": "security_incident"}, if_match=grant["revision"]) except mutualtrust.MutualTrustError as e: if e.code != "revision_mismatch": raise # Someone changed the grant since you read it: read it again and decide.

Idempotency

Operations that accept an Idempotency-Key get one automatically: a fresh key per call, reused on every retry of that call, so a retried create or transition is applied once. The server replays the original response for the same key and body for 24 hours and answers 409 idempotency_key_reused for the same key with a different body. Pass your own key to make a call idempotent across processes, for example one derived from a change ticket or job ID: RequestOptions.IdempotencyKey in Go, idempotency_key= in Python, options.idempotencyKey in TypeScript.

Go
inv, err := mt.Services.CreateInvitation(ctx, serviceID, body, &mutualtrust.RequestOptions{IdempotencyKey: "onboarding-" + ticketID})
Python
inv = mt.services.create_invitation(service_id, body, idempotency_key=f"onboarding-{ticket_id}")

Concurrency with If-Match

Grant changes (approve, reject, suspend, resume, revoke, updateScope), approval policy updates and integration updates require the revision you read. Pass the number (grant.revision) or the entity tag from the ETag header; the SDKs send a number as If-Match: "3". A stale revision is 412 revision_mismatch, so concurrent changes are never silently overwritten. The SDKs refuse to send these calls without a revision.

Go
g, err := mt.Grants.Get(ctx, id, nil) if err != nil { return err } _, err = mt.Grants.Revoke(ctx, id, mutualtrust.GrantReason{ReasonCode: mutualtrust.GrantReasonCodeContractEnded}, &mutualtrust.RequestOptions{IfMatch: mutualtrust.Revision(g.Revision)})
Python
grant = mt.grants.get(grant_id) mt.grants.revoke(grant_id, {"reason_code": "contract_ended"}, if_match=grant["revision"])

Retries

429 responses are retried after Retry-After. 503 responses and network errors are retried when the request is safe to repeat: a read, or a write that carries an Idempotency-Key. Without Retry-After the SDKs back off exponentially with jitter from the first backoff delay. A Retry-After longer than the longest accepted wait (30 seconds by default) is returned to you as an error at once, with the wait in its retry-after field. Set the retry count to 0 on the client or on one call to handle retries yourself.

Proxies, TLS and testing

The Go and Python clients use the proxy from the standard HTTPS_PROXY and NO_PROXY environment variables. To trust a corporate TLS inspection CA, pass your own HTTP stack: an *http.Client with a tls.Config in Go, an ssl.SSLContext in Python. Redirects are never followed; a redirect is reported as unexpected_redirect, which usually means a wrong base URL.

Go: corporate CA bundle
pool, _ := x509.SystemCertPool() pem, _ := os.ReadFile("/etc/pki/corp-ca.pem") pool.AppendCertsFromPEM(pem) mt := mutualtrust.New(mutualtrust.WithHTTPClient(&http.Client{ Timeout: 60 * time.Second, Transport: &http.Transport{Proxy: http.ProxyFromEnvironment, TLSClientConfig: &tls.Config{RootCAs: pool}}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, }))
Python: corporate CA bundle
import ssl mt = mutualtrust.Client(ssl_context=ssl.create_default_context(cafile="/etc/pki/corp-ca.pem"))

For tests, point the base URL at a local fake server (httptest.NewServer in Go, http.server in Python) and replace the wait between retries (WithSleep, sleep=), as the SDKs' own tests do. In TypeScript, pass a stub fetch and sleep.

Generated from the contract

The generators read packages/contracts/openapi.json, the OpenAPI 3.1 document generated from the same JSON Schemas the API validates every request with, and share one operation list (scripts/sdk-operations.mjs), so the three SDKs always expose the same operations. The generators run on Node.js in this repository; the Go and Python SDKs they produce do not need Node.js. Each SDK's transport is a small hand-written module (sdks/go/client.go, sdks/python/src/mutualtrust/_runtime.py, packages/sdk/src/runtime.ts). CI regenerates all three and fails on any difference, runs each SDK's tests, builds the Python wheel, and runs a Go and a Python smoke test against a local control plane.

Generators
SDKGeneratorGenerated files
Goscripts/gen-sdk-go.mjs (output formatted with gofmt)sdks/go/models_gen.go, sdks/go/operations_gen.go
Pythonscripts/gen-sdk-python.mjssdks/python/src/mutualtrust/models.py, sdks/python/src/mutualtrust/_client.py
TypeScriptscripts/gen-sdk.mjspackages/sdk/src/generated.ts
Regenerate
npm run gen:openapi # schemas -> openapi.json npm run gen:sdks # openapi.json -> Go, Python and TypeScript SDKs npm run gen:sdks:check # what CI runs: fail when a generated file is stale