Integration Guide

Integrating StepAuth into your service

A practical guide for service providers. From rationale to production deployment.

1. Why StepAuth?

The authorization gap

Your service already has authorization. RBAC or ABAC tells you whether a principal can perform an operation — whether they have the right role, the right attributes, the right scope. This is necessary, but it answers the wrong question for sensitive actions.

An MDM admin can wipe any device. That's their role. But whether wiping this specific device, right now, for this stated reason is appropriate — that's a different question. RBAC grants capability. It doesn't verify intent.

This gap has always existed, but it was manageable when humans were the only actors. Humans self-regulate — they double-check targets, hesitate before irreversible actions, ask a colleague. The authorization system didn't need to encode that judgment because the human provided it implicitly.

Agents change the equation

Your customers are increasingly using AI agents to interact with your service. Agents are capable enough to handle real work — processing refunds, managing infrastructure, modifying accounts — but they don't exercise the same judgment humans do. They don't hesitate. They don't double-check. They execute.

Without a way to verify intent, you're forced into a bad choice:

  • Restrict agents to safe operations. The agent can list devices but not wipe them. Can view orders but not issue refunds. This throws away most of the agent's value and frustrates your customers who are trying to automate real workflows.
  • Trust agents with broad access. The agent can do everything a human admin can. You hope the prompt engineering is good enough. This is unacceptable risk.

This isn't a hypothetical. It's the blocker your customers hit today when they try to connect agents to your service. The industry has lacked a standard mechanism for intent verification — a way to let agents attempt any action while ensuring the right oversight happens before execution.

What this means for your service

StepAuth removes the tradeoff. When your service integrates StepAuth, your customers can point agents at your full API surface. Sensitive actions get routed for review — the agent isn't blocked by lacking a role, it's blocked until the right person (or policy) confirms this specific action makes sense.

Your customers can use agents without fear. They automate more, rely on your service more, and unlock workflows that weren't previously safe. Services that integrate StepAuth become agent-ready. Services that don't will either block agents from their most valuable operations or accept the liability — neither is competitive as agentic usage grows.

How StepAuth fits with existing authorization

StepAuth doesn't replace RBAC or ABAC. It adds a layer:

  1. RBAC/ABAC runs first — does this principal have the capability?
  2. StepAuth runs second — should this principal exercise that capability right now, on this specific target, in this context?
  3. Execute only after both pass.

Your existing authorization stays in place. StepAuth adds intent verification for the actions where capability alone isn't enough. The SP describes what will happen. The authorizer — configured by your customer, not by you — decides whether it should.

Separation of concerns. The SP knows what the action does. The authorizer knows organizational policy. The SP describes; the authorizer decides. Neither needs to understand the other's domain.

2. Integration models

Option 1: Authorizer-owns-policy (recommended)

If a tenant has StepAuth enabled, all sensitive actions go to the authorizer. The authorizer decides what to do with each one — auto-approve, auto-deny, or route for review. The SP doesn't encode any per-action policy.

This is the recommended model because it preserves a clean separation of concerns:

  • No split policy. All authorization policy lives in one place — the authorizer. The SP doesn't need to know which actions require review, how many approvers are needed, or who the reviewers are.
  • Tenant autonomy. Tenants control their own policies through their authorizer configuration. They don't need to ask you to change anything when their requirements evolve.
  • Simpler SP code. The SP's logic is: "is this tenant StepAuth-enabled? If yes, submit. If no, proceed as normal." No routing tables, no per-action configuration.

The SP's only design decision is which of its actions are "sensitive" — and this is a product-level decision, not a per-tenant policy decision. You define your sensitive action types once in your codebase. If a tenant's authorizer wants to auto-approve some of them, it can. That's the authorizer's job.

Option 2: SP-side routing (not recommended)

The SP's own authorization layer decides per-action whether to call StepAuth, and potentially which authorizer to contact. For example: the RBAC check returns "allowed, but requires StepAuth check with authorizer X."

This works but has drawbacks. Policy is split between the SP and the authorizer — the SP is deciding which actions are "sensitive enough" for StepAuth, which is exactly the kind of decision StepAuth is designed to externalize. If the organization wants to start requiring approval for an action the SP considered low-risk, they have to ask you to change your configuration.

Use this model only if your product has specific requirements that make per-action routing unavoidable.

SP-side routing vs. tenant routing scripts. SP-side routing is the SP deciding which authorizer handles what — this splits policy and is not recommended. Tenant routing scripts (see Authorizer routing) are different: the tenant expresses which of their authorizers handles which requests via a CEL expression. This is fully compatible with authorizer-owns-policy because the tenant controls the routing, not the SP.

3. Multi-tenancy

Per-tenant keys

Your service is multi-tenant. Each tenant that enables StepAuth gets its own:

  • Ed25519 keypair — generated by the SP, per tenant
  • spId — a unique identity registered with the tenant's authorizer
  • Authorizer configuration — endpoint URL, authorizer public key(s)

Per-tenant keys isolate blast radius. A compromised key affects only one tenant. Keys can be rotated independently. Different tenants can use different authorizer providers.

Never share keys across tenants. A single global keypair means one compromise affects all tenants, key rotation is all-or-nothing, and you can't support tenants using different authorizers.

Multiple authorizers

A tenant must be able to configure at least one authorizer. Supporting multiple active authorizers is recommended — for migration between authorizer providers, for routing different actions to different authorizers, or for redundancy.

Each authorizer entry has a name (unique within the tenant's configuration) and an isDefault flag. Exactly one authorizer must be marked as the default — it serves as the fallback when routing fails.

When multiple authorizers are active:

  • No routing script: The SP sends requests to all of them and accepts the first valid signed decision. This is a topology concern, not a policy concern.
  • With a routing script: A CEL expression determines which authorizer(s) to solicit for each request. The SP sends to the selected authorizers and accepts the first valid signed decision.

Data model

Your tenant configuration should look something like:

Tenant StepAuth configuration
{
  "tenantId": "acme-corp",
  "stepauth": {
    "enabled": true,
    "routingScript": "request.action.category.startsWith(\"infra.\") ? [\"security-team\"] : [\"default\"]",
    "authorizers": [
      {
        "name": "default",
        "isDefault": true,
        "endpoint": "https://authorizer.example.com",
        "spId": "sp_acme_mdm_tenant_acme",
        "signingKeyId": "sp-key-2024-acme",
        "authorizerPublicKeys": [
          {
            "keyId": "auth-key-2024",
            "algorithm": "ed25519",
            "publicKey": "MCowBQYDK2VwAyEA..."
          }
        ]
      },
      {
        "name": "security-team",
        "isDefault": false,
        "endpoint": "https://security-authorizer.example.com",
        "spId": "sp_acme_mdm_tenant_acme_sec",
        "signingKeyId": "sp-key-2024-acme-sec",
        "authorizerPublicKeys": [
          {
            "keyId": "sec-auth-key-2024",
            "algorithm": "ed25519",
            "publicKey": "MCowBQYDK2VwAyEA..."
          }
        ]
      }
    ]
  }
}

4. Authorizer routing

When to use routing scripts

If a tenant has a single authorizer, no routing is needed. When a tenant has multiple authorizers, a routing script lets them control which authorizer handles which requests. Common scenarios:

  • Compliance boundaries. Financial actions go to one authorizer, infrastructure actions go to another.
  • Department-specific authorizers. Engineering and Finance teams use different authorizer providers.
  • Redundancy. Agent-initiated actions go to two authorizers for extra oversight.
  • Migration. During a provider switch, route new action types to the new authorizer while legacy actions stay on the old one.

CEL expressions

Routing scripts use CEL (Common Expression Language) — a small, fast, safe expression language designed for exactly this kind of embedded evaluation. CEL is non-Turing-complete (always terminates), has no side effects, and has mature implementations in Go, Java, C++, and Rust. No JavaScript runtime required.

If you're using an official library like stepauth-go or stepauth-ts, CEL evaluation, input flattening, and fallback handling are built in. You don't need to implement a CEL runtime yourself — the library handles it.

The routing script receives two variables:

  • request — the authorization request with labeled entries flattened into objects. So { "key": "serial", "label": "Serial", "value": "C02Z..." } becomes { "serial": "C02Z..." }, and you can write request.action.details.serial.
  • config — the tenant's authorizer configuration, including the list of authorizer names.

The script must return a list(string) — the names of the authorizer(s) to solicit.

Examples

Route by action category
// Infrastructure-destructive actions go to a security authorizer
request.action.category.startsWith("infra.")
  ? ["security-team"]
  : ["default"]
Route by principal type
// Agent-initiated actions go to both authorizers
request.principal.type == "agent"
  ? ["primary", "backup"]
  : ["primary"]
Route by department
// Different departments use different authorizers
request.principal.attributes.department == "Finance"
  ? ["finance-authorizer"]
  : ["default"]

Immediate decisions

The routing script can also short-circuit the authorization flow entirely by returning ["$approve"] or ["$deny"]. No authorizer is contacted — the SP acts on the decision immediately. Authorizer names must not start with $.

Immediate decision (discouraged)
// Auto-approve read-only actions without contacting an authorizer
request.action.category == "data.read"
  ? ["$approve"]
  : ["default"]
Use with caution. Immediate decisions bypass the core value of StepAuth — externally signed authorization. There is no cryptographic proof of the decision, only the SP's own logs. Authorizers can already auto-approve or auto-deny with minimal latency, so prefer that mechanism when possible. Immediate decisions are intended only for cases where even a single round-trip is unacceptable.

Fallback behavior

If the routing script fails — runtime error, empty list, or unrecognized authorizer names — the SP falls back to the authorizer marked isDefault: true. The SP logs the fallback event for debugging but never fails the authorization request due to a routing error.

Safety guarantee. A bad routing script can never prevent authorization from proceeding. The worst outcome is that the request goes to the default authorizer — which is always a valid target.

Evaluating the routing script

The SP evaluates the routing script locally before submitting the request. Each authorizer is unaware of the routing decision — it receives a standard authorization request.

import { Client, evaluateRouting, parsePublicKey } from "stepauth";

// Build a client for each authorizer
const clients = new Map<string, Client>();
for (const auth of tenantConfig.stepauth.authorizers) {
  clients.set(auth.name, new Client({
    authorizerUrl: auth.endpoint,
    spId: auth.spId,
    privateKey: tenantConfig.privateKey,
    keyId: auth.signingKeyId,
    authorizerPublicKey: parsePublicKey(
      auth.authorizerPublicKeys[0].publicKey
    ),
  }));
}

// Evaluate routing — returns list of authorizer names
const targets = evaluateRouting(
  tenantConfig.stepauth.routingScript,
  request,
  tenantConfig.stepauth,
  tenantConfig.stepauth.authorizers,
);

// Submit to selected authorizers in parallel, first valid decision wins
const decision = await Promise.any(
  targets.map(name => clients.get(name)!.submit(request))
);

5. Libraries

Official client libraries handle envelope signing, signature verification, and request construction so you don't have to implement the cryptography yourself.

LanguagePackageRepository
TypeScript / JavaScriptstepauthstepauth/stepauth-ts
Gostepauthstepauth/stepauth-go

The code examples in this guide are shown in both TypeScript and Go. Use the tabs on any code block to switch — all snippets will switch together.

6. Setup

Key generation

For each tenant that enables StepAuth, generate an Ed25519 keypair. Store the private key securely — never in logs, never in API responses.

import { generateKeyPair } from "stepauth";

const [privateKey, publicKey] = generateKeyPair();

// Store privateKey (base64) securely
// Send publicKey (base64) to the authorizer during registration

Authorizer registration

Registration is out-of-band — the protocol doesn't specify how it happens. Typically, the tenant configures their authorizer with:

  • Your spId for this tenant
  • Your public key (with its keyId and algorithm)
  • A callback URL pattern (e.g., https://*.yourservice.com/stepauth/callback)

In return, you receive and store:

  • The authorizer's endpoint URL
  • The authorizer's public key(s) (with keyId and algorithm)

Callback URL pattern

Register a callback URL pattern with the authorizer. This prevents a scenario where a compromised signing key is used to redirect decisions to an attacker-controlled endpoint. The authorizer will reject any request whose callbackUrl doesn't match the registered pattern.

7. Constructing requests

Client setup

Create a client for each tenant using their stored configuration. The client handles signing, envelope construction, and HTTP communication.

import { Client, parsePublicKey } from "stepauth";

const client = new Client({
  authorizerUrl: tenantConfig.authorizer.endpoint,
  spId: tenantConfig.authorizer.spId,
  privateKey: tenantConfig.privateKey,        // base64
  keyId: tenantConfig.authorizer.signingKeyId,
  authorizerPublicKey: parsePublicKey(
    tenantConfig.authorizer.authorizerPublicKeys[0].publicKey
  ),
});

Request structure

An authorization request has fixed protocol fields and two descriptive sections: the principal (who) and the action (what). Use the entry() and group() helpers to build structured attributes and details.

import { entry, group, entries } from "stepauth";

const request = {
  callbackUrl: "https://mdm.acme.com/stepauth/callback",
  expiresIn: 1800, // 30 minutes
  principal: {
    type: "human",
    attributes: entries(
      entry("name", "Name", "Marc Tremblay"),
      entry("email", "Email", "it-admin@acme.com"),
    ),
  },
  action: {
    type: "device.wipe",
    category: "infra.destroy",
    summary: "If approved, the MacBook Pro assigned to Jane Doe " +
      "(serial C02ZX1ABCDEF) will be remotely wiped. All local data " +
      "will be permanently erased. This is not reversible.",
    details: entries(
      group("device", "Device",
        entry("name", "Name", "Jane's MacBook Pro 16\""),
        entry("serial", "Serial", "C02ZX1ABCDEF"),
      ),
      entry("target", "Target", "jane.doe@acme.com"),
    ),
  },
};

Principals

Every principal has a typehuman, service, or agent — and an attributes array describing it. Include whatever context you have: name, email, role, IP address, session metadata.

For service and agent types, the operator field traces who is behind the action. An AI agent has an operator (often a human). A service calling your API on behalf of an agent has an operator chain: service → agent → human.

const request = {
  // ...
  principal: {
    type: "agent",
    attributes: entries(
      entry("name", "Name", "Support Agent (v3)"),
      entry("model", "Model", "claude-opus-4-6"),
    ),
    operator: {
      type: "human",
      attributes: entries(
        entry("email", "Email", "customer@example.com"),
        entry("ticket", "Ticket", "TK-90421"),
      ),
    },
  },
  // ...
};

Include as many operator levels as you know, up to 5 total principals. The human type cannot have an operator — humans are always terminal in the chain.

Actions and summaries

The action.type is a machine-readable identifier you define — device.wipe, order.refund, access.escalate. It's your namespace; use whatever naming convention fits your product.

The action.summary is the most important field in the entire request. It must describe the consequence of approval — not the operation name, not the API endpoint, but what will happen in the real world if this is approved.

Bad: "Wipe device C02ZX1ABCDEF"
Good: "If approved, the MacBook Pro assigned to Jane Doe (serial C02ZX1ABCDEF) will be remotely wiped. All local data will be permanently erased and the device will be returned to factory settings. This action is not reversible."

A reviewer reading only the summary should have enough context to make a decision. The summary is the contract — if it says "wipe this device," that's what must happen on approval.

Categories

The optional action.category field classifies the action using a standard vocabulary so the authorizer can write cross-SP policies. For example, an authorizer can require VP approval for all infra.destroy actions regardless of which SP they come from.

Categories are free-form strings. The spec suggests a starting set — data.read, data.export, data.delete, identity.escalate, infra.destroy, financial.transfer, etc. — but you can use any value that makes sense for your domain.

Details

The action.details array provides structured context. Each entry has a machine-readable key, a human-readable label, and a value (string or nested array).

The key fields enable policy evaluation — the authorizer can rewrite the array into a flat JSON object and run it through a policy engine like Rego. The label fields are for human display. Include both.

8. Signing and submitting

Submitting a request

The client handles signing, envelope construction, and HTTP communication. Call submit() with your request object.

const result = await client.submit(request);

// result.status is "pending" — the request is awaiting review
// result.requestId is the unique identifier for this request
console.log(`Request ${result.requestId} submitted, status: ${result.status}`);

Handling the response

The submit() method returns a SubmitResponse with the request ID and status. If the authorizer returns an error, the client throws (TypeScript) or returns an error (Go):

  • APIError — the authorizer returned a structured error (4xx/5xx). Contains the HTTP status code and error body. See Error Handling.
  • Network errors — the authorizer was unreachable. Retry with backoff, then fall back to the tenant's unavailability policy.
import { APIError } from "stepauth";

try {
  const result = await client.submit(request);
  await blockAction(result.requestId);
} catch (err) {
  if (err instanceof APIError) {
    console.error(`Authorizer error ${err.statusCode}: ${err.body}`);
  }
  // Block the action on any error
  await blockAction(request.requestId);
  throw err;
}

9. Receiving decisions

When a request is pending, you need to receive the decision asynchronously. There are three mechanisms, from most to least preferred.

Webhook callbacks (primary)

If you provided a callbackUrl in the request, the authorizer will POST the signed decision envelope to that URL when a decision is made. The library provides a handler that verifies the signature and calls your function with the parsed decision.

import { callbackHandler } from "stepauth";

app.post("/stepauth/callback", callbackHandler(
  authorizerPublicKey,
  async (decision) => {
    const request = await getRequest(decision.requestId);
    if (!request || request.decidedAt) {
      return { requestId: decision.requestId, status: "success" };
    }

    await markDecided(decision.requestId, decision);

    if (decision.decision === "approved") {
      const result = await executeAction(request);
      return {
        requestId: decision.requestId,
        status: result.success ? "success" : "error",
        ...(result.error && { error: result.error }),
      };
    }

    await notifyPrincipal(request, decision);
    return { requestId: decision.requestId, status: "success" };
  }
));

The handler verifies the authorizer's signature, parses the decision, calls your function, and returns the execution result as the HTTP response. Your callback URL must use HTTPS and must match the pattern you registered with the authorizer.

Polling (fallback)

If callbacks aren't practical (e.g., firewalled environments), use polling. The client provides two methods: poll() for a single check, and waitForDecision() which polls until a decision arrives.

// Single poll — throws PendingError if not yet decided
import { PendingError } from "stepauth";

try {
  const decision = await client.poll(requestId);
  await handleDecision(decision);
} catch (err) {
  if (err instanceof PendingError) {
    // Still waiting — try again later
  }
}

// Or: poll until decided (with 5-second interval)
const decision = await client.waitForDecision(requestId, 5000);
await handleDecision(decision);

Which method to use

MethodBest forTradeoffs
WebhookMost integrationsRequires a publicly reachable HTTPS endpoint
PollingSimple setups, firewalled environmentsHigher latency, more load on authorizer

You can combine methods. For example, use webhooks as the primary delivery mechanism and polling as a fallback for retries or missed callbacks.

10. Verifying and executing

Signature verification

Every decision arrives as a signed envelope. The library's callback handler and poll methods verify the authorizer's signature automatically — you only receive parsed, verified decisions.

If you need to verify envelopes manually (e.g., processing from a queue), use verifyEnvelope():

import { verifyEnvelope, parsePublicKey } from "stepauth";

const publicKey = parsePublicKey(authorizerPublicKeyBase64);
const [payload, keyId] = verifyEnvelope(envelopeJson, publicKey);
// payload is the verified raw bytes, keyId is the signing key ID

Checking requestId and spId

After receiving a verified decision, confirm that:

  • requestId matches a request you actually generated. Never accept a decision for an unknown request.
  • spId matches your own identity for this tenant. This prevents cross-SP replay — a decision intended for a different service being delivered to you.
  • The request has not already been decided. Use requestId as an idempotency key to prevent double execution.
  • The request has not expired (expiresAt has not passed).

Acting on the decision

async function handleDecision(decision: Decision) {
  const request = await getRequest(decision.requestId);
  if (!request) throw new Error("Unknown requestId");
  if (request.decidedAt) return; // Already handled (idempotency)

  await markDecided(decision.requestId, decision);

  switch (decision.decision) {
    case "approved":
      return await executeAction(request);
    case "denied":
    case "expired":
      await notifyPrincipal(request, decision);
      return { success: true };
  }
}

Reporting the execution result

If the decision was delivered via webhook, your callback response includes the execution result. This closes the feedback loop — the authorizer knows whether the approved action actually succeeded, and can notify the approvers if it failed.

Execution result
// Success
{ "requestId": "req_a1b2c3d4e5f6", "status": "success" }

// Failure
{
  "requestId": "req_a1b2c3d4e5f6",
  "status": "error",
  "error": "Device is no longer enrolled in MDM."
}

11. Cancellation

If the principal revokes their request or the action is no longer needed, cancel the pending authorization request. Cancellation is authenticated — you submit a signed envelope containing the requestId, spId, and current timestamp. The libraries don't wrap this call yet, so you use the Signer directly.

import { Signer } from "stepauth";

const signer = Signer.create(tenantConfig.privateKey, tenantConfig.keyId);

const cancelPayload = JSON.stringify({
  requestId: "req_a1b2c3d4e5f6",
  spId: tenantConfig.spId,
  timestamp: new Date().toISOString()
});

const envelope = signer.signEnvelope(cancelPayload);

const response = await fetch(
  `${tenantConfig.authorizerUrl}/v1/authorization-requests/cancel`,
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: envelope
  }
);

if (response.status === 200) {
  // Successfully cancelled — do not execute the action
}
if (response.status === 409) {
  // Already decided — retrieve and handle the decision
}

After cancellation, you must not execute the action — even if a decision arrives afterward. If the authorizer returns 409, the request was already decided; retrieve the decision and handle it normally.

12. Error handling

Standard error codes

All authorizer error responses use a standard format with a machine-readable error code and a human-readable message.

Error codeHTTPMeaningSP action
invalid_signature401Your signature didn't verifyCheck key configuration, alert your team
unknown_sp401spId not registeredCheck registration, alert your team
malformed_request400Payload failed schema validationLog and fix the request construction
invalid_callback_url400callbackUrl not HTTPS or doesn't match patternFix the callback URL
operator_depth_exceeded400Principal chain exceeds 5 levelsTrim the operator chain
duplicate_request409requestId already existsGenerate a new requestId
rate_limited429Too many requestsWait for Retry-After duration

Retry strategy

  • 401 errors — do not retry. These indicate a configuration problem (wrong key, unregistered spId). Alert your operations team.
  • 400 errors — do not retry. The request is malformed. Log the error and fix the request construction.
  • 429 errors — retry after the Retry-After duration.
  • 5xx errors — retry with exponential backoff.
  • Network errors — retry with exponential backoff. Same as 5xx.

Unavailability policy

If all configured authorizers remain unreachable after exhausting the retry policy, the SP's behavior is determined by the tenant's unavailability policy — a per-tenant configuration option.

  • Fail-closed (default): The action is treated as denied. The principal is notified that the authorizer is unreachable and the action was blocked.
  • Fail-open (opt-in): The action proceeds without authorization. The SP must log that the action was executed without authorizer confirmation, including the retry attempts made and the full request that would have been submitted.

Fail-open exists for availability-critical operations — for example, emergency production access when the authorizer itself may be affected by the incident. It must be an explicit opt-in by the tenant administrator, never a default.

Default to fail-closed. The safe default is denial. Fail-open must be a deliberate, per-tenant configuration choice — not an implicit behavior. Always attempt to reach the authorizer with retries before falling back.

13. Best practices

Writing effective summaries

The summary is the most important field in the protocol. It's what reviewers read to make their decision. Guidelines:

  • Describe the consequence, not the operation. "If approved, all 15,832 customer records will be exported as CSV" is better than "Export customer data."
  • Include specifics. Names, quantities, targets, durations. A reviewer shouldn't need to look up context elsewhere.
  • State irreversibility. If the action can't be undone, say so explicitly.
  • Write for someone without domain expertise. The reviewer may not know your product's internals. Plain language wins.

Choosing action types and categories

  • Action types are yours. Use a consistent naming convention — device.wipe, order.refund, access.escalate — and document them for your customers.
  • Categories are shared. Use the suggested categories from the spec when they fit. This lets authorizers write cross-SP policies without knowing your specific action types.

Idempotency

Decisions may arrive more than once — via webhook retry, polling, and streaming simultaneously. Use requestId as an idempotency key. The action must execute at most once per requestId, regardless of how many times the decision is delivered.

Defining your sensitive actions

Not every action in your service needs StepAuth. The set of actions you gate is a product decision. A useful heuristic: if the action is irreversible, affects other users, moves money, or touches infrastructure, it's a candidate. list.devices is not. device.wipe is.

Define this set once in your codebase. When a tenant enables StepAuth, all of these actions go to their authorizer. The authorizer decides which ones actually need review — your job is just to surface them.

Security hygiene

  • Store private keys securely. Never log them or return them in API responses.
  • Rotate keys periodically. The keyId field supports seamless rotation — register a new key, start signing with it, then deregister the old one.
  • Never log signed envelopes containing private data. Log requestId and decision outcomes, not payloads.
  • Validate callback URLs match your registered pattern. Never accept a decision delivered to an unexpected endpoint.

Ready to integrate?

Read the full protocol specification for complete details on every field, schema, and security consideration.