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.
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.
| SDK | Requires | Dependencies | Package |
|---|---|---|---|
| Go | Go 1.22 or later | Go standard library only | mutualtrust.io/sdk-go (package mutualtrust) |
| Python | Python 3.9 or later | Python standard library only (urllib) | mutualtrust |
| TypeScript (option) | Node.js 18.17 or later, Deno, Bun, browsers or Workers | none (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 get mutualtrust.io/sdk-goUntil the import path is published
Python
pip install mutualtrustUntil the package is on PyPI
TypeScript (option)
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.
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)
}
}import mutualtrust
mt = mutualtrust.Client() # credential from MUTUALTRUST_API_CREDENTIAL
for s in mt.identity.overview()["services"]:
print(s["id"], s["name"], s["environment"])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);| Setting | Go option | Python argument | TypeScript option | Default |
|---|---|---|---|---|
| API origin | WithBaseURL | base_url= | baseUrl | https://api.mutualtrust.io |
| API credential | WithCredential | first argument or credential= | credential | MUTUALTRUST_API_CREDENTIAL (Go, Python); none (TypeScript) |
| HTTP stack | WithHTTPClient(*http.Client) | ssl_context=, opener= | fetch | 60 s timeout, proxy from the environment, redirects not followed |
| Retries after the first attempt | WithMaxRetries | max_retries= | maxRetries | 2 |
| First backoff, longest accepted wait | WithRetryDelays(base, max) | retry_base_delay=, max_retry_delay= | retryBaseDelayMs, maxRetryDelayMs | 0.5 s, 30 s |
| Extra headers | WithHeader | headers= | headers | none |
| User-Agent prefix | WithUserAgent | user_agent= | via headers | SDK 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.
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)
}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 withdrawnList 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).
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))
}
}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 outExport 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.
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")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.
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)
}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).
| Group (Go / Python / TypeScript) | Methods (TypeScript names) |
|---|---|
Identity / identity | me, overview, tryOrdersApi |
Organizations / organizations | create, listMembers, assignRole, removeMember, removeRole, getPartnerHandle, rotatePartnerHandle, resolvePartnerHandle, listWorkloadIssuers, approveWorkloadIssuer, listAuditEvents, listSupportGrants, createSupportGrant, revokeSupportGrant, listExportGrants, createExportGrant, revokeExportGrant, exportAudit, listApiCredentials, revokeApiCredential, listProductionAccessRequests, requestProductionAccess, getApprovalPolicy |
Billing / billing | getSummary, createCheckoutSession, createPortalSession |
Spaces / spaces | create, get, getTrustBundle, getApprovalPolicy, putApprovalPolicy |
Services / services | create, get, update, listReceipts, listInvitations, createInvitation, getApprovalPolicy, putApprovalPolicy |
Gateways / gateways | approvePairing, createPairing, getPairing, disable, getManifest, acknowledge, sendReceipts |
Invitations / invitations | preview, accept, decline, cancel |
Grants / grants | list, get, approve, reject, revoke, suspend, resume, updateScope, getOperation |
Enrollment / enrollment | updateWorkloadBinding, createEnrollmentToken, start, complete, getIssuanceRequest |
Certificates / certificates | renew, confirmReload, revoke, getChain |
Integrations / integrations | listAdapters, create, list, get, update, getDesiredState, createPlan, listPlans, getPlan, getApproval, createRun, listRuns, getEvidence, getApprovalPolicy, putApprovalPolicy |
Platform / platform | recordAdoptionEvent, 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.
| Go | Python | TypeScript | Meaning |
|---|---|---|---|
Status | status | status | HTTP status; 0 when no response arrived (the code is then network_error). |
Code | code | code | Catalogue code, for example revision_mismatch, forbidden, rate_limited. |
Title | title | title | Human-readable summary. |
RequestID | request_id | requestId | request_id from the problem (or the X-Request-Id header). Quote it to support. |
Errors | errors | errors | Field errors for 422 validation failures (path and message). |
RetryAfter | retry_after | retryAfter | Seconds the server asked you to wait (429, 503). |
Problem | problem | problem | The full problem document. |
_, 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)
}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.
inv, err := mt.Services.CreateInvitation(ctx, serviceID, body,
&mutualtrust.RequestOptions{IdempotencyKey: "onboarding-" + ticketID})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.
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)})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.
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 },
}))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.
| SDK | Generator | Generated files |
|---|---|---|
| Go | scripts/gen-sdk-go.mjs (output formatted with gofmt) | sdks/go/models_gen.go, sdks/go/operations_gen.go |
| Python | scripts/gen-sdk-python.mjs | sdks/python/src/mutualtrust/models.py, sdks/python/src/mutualtrust/_client.py |
| TypeScript | scripts/gen-sdk.mjs | packages/sdk/src/generated.ts |
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