Before you start
Your application is the service provider. In this guide, you give it a signing identity, send an approval request, and run the action only after a verified approval. The example is Acme MDM, at acme.example.com, which asks for approval before it wipes a laptop.
You add two things with the StepAuth SDK:
- A send call before the sensitive action. It names the action, the person who started it, and the approver. It returns at once, with a pending state that your application stores.
- A callback endpoint that receives the decision later. The SDK verifies the decision against the pending state. For an approval, your code runs the action and returns the result.
The SDK signs and verifies every message, retries, and checks that each decision belongs to your request.
Install the SDK
In your application code
Add the SDK to your backend:
npm install @splitsecure/stepauthgo get github.com/splitsecure/stepauth-godotnet add package SplitSecure.StepAuthThe examples on this page use these imports.
import {
createClient,
email,
entry,
generateKeyset,
isApproved,
newRequestId,
policyId,
publicKeyset,
SendFailedError,
signingKeysetFromJSON,
signingKeysetToJSON,
type Config,
type ExecutionResult,
type HubMetadata
} from '@splitsecure/stepauth';import "github.com/splitsecure/stepauth-go"using System.Text.Encodings.Web;
using System.Text.Json;
using Microsoft.AspNetCore.Http.Features;
using SplitSecure.StepAuth;
using SplitSecure.StepAuth.Crypto;Establish a signing identity
In your application code
The keyset is the identity of your application. It holds an ed25519 key and a post-quantum ml-dsa-65 key, and both sign every message. Mint it on the first start, and store it. Every later start loads the stored copy.
Acme MDM keeps its keyset in its secret store, under the key stepauth_keyset. On the first start, it mints the keyset acme-mdm-1. Two instances can start at the same time. Acme MDM therefore asks its store to write the keyset only when the key is missing, and to return the stored value either way. The examples call that operation put-if-absent. Your store needs the same operation. Every instance then signs with the same keyset:
let stored = await store.get('stepauth_keyset');
if (!stored) {
const minted = generateKeyset('acme-mdm-1');
stored = await store.putIfAbsent('stepauth_keyset', JSON.stringify(signingKeysetToJSON(minted)));
}
const keyset = signingKeysetFromJSON(JSON.parse(stored));stored, err := store.Get(ctx, "stepauth_keyset")
if err != nil {
return fmt.Errorf("reading keyset: %w", err)
}
if stored == nil {
minted, err := stepauth.GenerateKeyset("acme-mdm-1")
if err != nil {
return fmt.Errorf("minting keyset: %w", err)
}
b, err := json.Marshal(minted)
if err != nil {
return fmt.Errorf("encoding keyset: %w", err)
}
if stored, err = store.PutIfAbsent(ctx, "stepauth_keyset", b); err != nil {
return fmt.Errorf("storing keyset: %w", err)
}
}
keyset := new(stepauth.SigningKeyset)
if err := json.Unmarshal(stored, keyset); err != nil {
return fmt.Errorf("parsing keyset: %w", err)
}var stored = await store.GetAsync("stepauth_keyset");
if (stored is null)
{
var minted = SigningKeyset.Generate("acme-mdm-1");
stored = await store.PutIfAbsentAsync("stepauth_keyset", minted.ToJson());
}
var keyset = SigningKeyset.FromJson(stored);The keyset holds private keys. Keep it in your secret store, and never send it out of the application.
The service provider metadata is the document that the organization administrator registers. It holds your entity, the public half of your keyset, and your default callback URL. The SDK exports the public half without the private keys.
Acme MDM exports its service provider metadata with the entity acme.example.com and the callback URL https://acme.example.com/stepauth/callback:
const appMetadata = {
entity: 'acme.example.com',
keysets: [publicKeyset(keyset)],
callbackUrl: 'https://acme.example.com/stepauth/callback'
};
console.log(JSON.stringify(appMetadata, null, 2));appMetadata := struct {
Entity string `json:"entity"`
Keysets []stepauth.Keyset `json:"keysets"`
CallbackURL string `json:"callbackUrl"`
}{
Entity: "acme.example.com",
Keysets: []stepauth.Keyset{keyset.PublicKeyset()},
CallbackURL: "https://acme.example.com/stepauth/callback",
}
b, err := json.MarshalIndent(appMetadata, "", " ")
if err != nil {
return fmt.Errorf("encoding app metadata: %w", err)
}
fmt.Println(string(b))var appMetadata = new
{
entity = "acme.example.com",
keysets = new[] { keyset.ToPublicKeyset() },
callbackUrl = "https://acme.example.com/stepauth/callback",
};
Console.WriteLine(JsonSerializer.Serialize(appMetadata, new JsonSerializerOptions
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
}));The Acme MDM document looks like this one. The hub ignores the $schema line, so the export does not need it.
{
"$schema": "https://docs.splitsecure.com/schemas/stepauth/app/v1.json",
"entity": "acme.example.com",
"keysets": [
{
"keysetId": "acme-mdm-1",
"keys": [
{
"idx": 0,
"alg": "ed25519",
"pub": "ZTGd3DIu0K045tM+3ghNKU8+Z8oKzRuP6R7750gHWsc="
},
{
"idx": 1,
"alg": "ml-dsa-65",
"pub": "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"
}
]
}
],
"callbackUrl": "https://acme.example.com/stepauth/callback"
}Send the document to the organization administrator, who registers it in Register the service provider .
Rotate the keyset
The hub cannot update a registration. If you start to sign with a new keyset before the administrator registers it, the hub rejects every request with invalid_signature.
To replace the keyset without rejected requests:
- Mint a keyset with a new
keysetId, for exampleacme-mdm-2. Keep the old keyset in use. - Export service provider metadata that holds both public keysets. The administrator replaces the registration with it. The hub finds the keyset of each request by its
keysetId. - Sign with the new keyset only.
- Export service provider metadata with the new keyset only. The administrator replaces the registration again.
Between the deletion of a registration and the new registration, the hub rejects requests with unknown_sender. Do each replacement at a quiet time.
Configure the client
In your application code
Parse the hub metadata document as the administrator sent it, and pass it in unchanged. Exactly one hub is the default.
Acme MDM reads the hub metadata of the Acme organization from its configuration, and tags that hub acme:
const hubMetadata: HubMetadata = JSON.parse(hubMetadataJSON);
const cfg: Config = {
sp: {
entity: 'acme.example.com',
callbackUrl: 'https://acme.example.com/stepauth/callback'
},
signingKeyset: keyset,
hubs: [{ tag: 'acme', metadata: hubMetadata, default: true }]
};
const client = createClient(cfg);var hubMetadata stepauth.HubMetadata
if err := json.Unmarshal(hubMetadataJSON, &hubMetadata); err != nil {
return fmt.Errorf("parsing hub metadata: %w", err)
}
client, err := stepauth.NewClient(stepauth.Config{
SigningKeyset: keyset,
SP: stepauth.SPMetadata{
Entity: "acme.example.com",
CallbackURL: "https://acme.example.com/stepauth/callback",
},
Hubs: []stepauth.HubEntry{{Tag: "acme", Metadata: hubMetadata, Default: true}},
})
if err != nil {
return fmt.Errorf("configuring stepauth: %w", err)
}var hubMetadata = JsonSerializer.Deserialize<HubMetadata>(hubMetadataJson)!;
var client = new StepAuthClient(new StepAuthClientOptions
{
SigningKeyset = keyset,
ServiceProvider = new ServiceProviderMetadata
{
Entity = "acme.example.com",
CallbackUrl = "https://acme.example.com/stepauth/callback",
},
Hubs = [new HubEntry { Tag = "acme", Metadata = hubMetadata, IsDefault = true }],
});The client rejects a hub document without a host, and a host that is not HTTPS.
Send a request
In your application code
The send call of the SDK builds the request, signs it once, and posts it to the hub.
- It always sets
senderIdto your entity andrecipientIdto the hub entity. - If you leave them unset, it sets
timestampto now,expiresAtto 30 minutes later, andcallbackUrlto the callback URL of your configuration. requestIdis yours to set. It is the idempotency key, the id that makes a repeat safe. A retry above the SDK must reuse it. The SDK has a helper that mints a random one.
The result holds the hub response and the pending state. Store that state, keyed by request id, before you tell the caller anything.
Dana, an IT administrator at Acme, clicks Wipe on the laptop MBP-4417, which left the company with a former employee. Acme MDM asks for approval under the laptop-wipe policy:
try {
const result = await client.send({
requestId: newRequestId(),
principal: { subject: email('[email protected]') },
action: {
name: 'com.acme.mdm.device.wipe',
category: 'infra.destroy',
title: 'Wipe laptop MBP-4417',
summary: 'Erases every disk on MBP-4417 and removes it from the fleet.',
details: [entry('device', 'Device', 'MBP-4417'), entry('owner', 'Last user', '[email protected]')],
approver: policyId('laptop-wipe')
}
});
await pending.put(result.pending.requestId, result.pending);
} catch (err) {
if (err instanceof SendFailedError) {
await pending.put(err.state.requestId, err.state);
}
throw err;
}approver := stepauth.PolicyID("laptop-wipe")
result, err := client.Send(ctx, stepauth.SendRequestInput{
RequestID: stepauth.NewRequestID(),
Principal: stepauth.Principal{Subject: stepauth.Email("[email protected]")},
Action: stepauth.Action{
Name: "com.acme.mdm.device.wipe",
Category: stepauth.CategoryInfraDestroy,
Title: "Wipe laptop MBP-4417",
Summary: "Erases every disk on MBP-4417 and removes it from the fleet.",
Details: stepauth.Entries(
stepauth.Entry("device", "Device", "MBP-4417"),
stepauth.Entry("owner", "Last user", "[email protected]"),
),
Approver: &approver,
},
})
var sendErr *stepauth.SendError
if errors.As(err, &sendErr) {
if perr := pending.Put(ctx, sendErr.Pending); perr != nil {
return errors.Join(err, perr)
}
}
if err != nil {
return fmt.Errorf("sending wipe request: %w", err)
}
if err := pending.Put(ctx, result.Pending); err != nil {
return fmt.Errorf("storing pending state: %w", err)
}SendResult result;
try
{
result = await client.SendAsync(new SendRequest
{
RequestId = StepAuthClient.NewRequestId(),
Principal = new Principal { Subject = NameId.Email("[email protected]") },
Action = new RequestedAction
{
Name = "com.acme.mdm.device.wipe",
Category = ActionCategory.InfraDestroy,
Title = "Wipe laptop MBP-4417",
Summary = "Erases every disk on MBP-4417 and removes it from the fleet.",
Details =
[
LabeledEntry.Text("device", "Device", "MBP-4417"),
LabeledEntry.Text("owner", "Last user", "[email protected]"),
],
Approver = NameId.Policy("laptop-wipe"),
},
}, cancellationToken);
}
catch (SendException e)
{
await pending.PutAsync(e.Pending.RequestId, e.Pending);
throw;
}
await pending.PutAsync(result.Pending.RequestId, result.Pending);The send call can report an error after the hub already stored the request, for example when the network drops the hub’s answer. The hub then treats the request as live, and an approver can still approve it. Your callback gets a decision for a request that your code thinks never went out.
For that reason the send error carries the same pending state that a successful send returns. Save it before you report the error, exactly as you would after a success. The callback handler then finds it and can verify the decision.
An error before the SDK posts anything, such as a missing field, carries no pending state. Nothing reached the hub, so no decision can arrive.
A requestId is unique in the whole organization, across every registered application. The SDK reports a 409 from the hub as an earlier submission of your own request, not as an error. If your id is already in use by another request, no decision arrives for it. Use the request id helper of the SDK, or put a prefix that is unique to your application on a natural key.
What an approver reads
An approver decides from the text of your request only. The approver has no access to your application. The following parts explain each field of the request.
Write each request for a person who did not start the action. That person must be able to tell from the text that the action is correct.
| Field | Purpose |
|---|---|
action.name | Your own identifier for the operation. Required. 120 characters or fewer. |
action.title | A short title in the imperative. Optional. 120 characters or fewer. |
action.summary | One or two sentences that state the effect. Required. |
action.details | The specifics: which resource, which values, which scope. |
If you send no title, the hub shows action.name as the title.
Write the summary as the consequence of an approval, not as the name of the operation. The approver decides from it.
| Summary | Why |
|---|---|
| Wipe device. | Weak. It names the operation, not the device or the effect. |
| Erases every disk on MBP-4417 and removes it from the fleet. The data cannot be recovered. | Strong. It names the target, the effect and the risk. |
Email and Slack messages show 12 detail rows or fewer. They cut each value at 200 characters, and they flatten nesting deeper than four levels. Put the rows that matter most first.
Details
A detail is an entry with a key, a label and a value. The approver sees the label and the value, so the label carries the meaning. A value is a string, or a list of child entries. The SDK rejects two entries with the same key in one list. The hub does not check keys.
Acme MDM shows the laptop, its last user, and what the wipe erases:
const details = [
entry('device', 'Device', 'MBP-4417'),
entry('owner', 'Last user', '[email protected]'),
group('scope', 'What the wipe erases', entry('disks', 'Disks', '2'), entry('backups', 'Backups kept', 'None'))
];details := stepauth.Entries(
stepauth.Entry("device", "Device", "MBP-4417"),
stepauth.Entry("owner", "Last user", "[email protected]"),
stepauth.Group("scope", "What the wipe erases",
stepauth.Entry("disks", "Disks", "2"),
stepauth.Entry("backups", "Backups kept", "None"),
),
)var details = new[]
{
LabeledEntry.Text("device", "Device", "MBP-4417"),
LabeledEntry.Text("owner", "Last user", "[email protected]"),
LabeledEntry.Group("scope", "What the wipe erases",
LabeledEntry.Text("disks", "Disks", "2"),
LabeledEntry.Text("backups", "Backups kept", "None")),
};NameIDs
A NameID names a person, a group or a policy. On the wire, it is an object with two fields:
| Field | Meaning |
|---|---|
kind | email, persistent, group or policy. The kinds that a request can carry. |
id | The value for that kind. It cannot be empty. |
In a decision, decidedBy lists the parties that decided, with the kinds user and group. A request never carries those two kinds.
The SDK has one helper for each kind, and a parse helper that converts the kind:id text that the Web App shows, such as a policy written as policy:laptop-wipe. These guides write a NameID as kind:id for short.
Acme MDM keeps the approver of each action as text in its configuration. It converts the text to a NameID at send time:
const approver = parseNameID('policy:laptop-wipe');approver, err := stepauth.ParseNameID("policy:laptop-wipe")
if err != nil {
return fmt.Errorf("parsing wipe approver: %w", err)
}var approver = NameId.Parse("policy:laptop-wipe");The principal
principal.subject names the party that started the action. It must name one individual:
| Form | Use |
|---|---|
email:[email protected] | A person that the organization knows by address. |
persistent:<id> | A stable identifier that is not an address. |
The hub rejects a group: or policy: NameID as the subject.
An agent that acts for a person
An AI agent or an automation can start a sensitive action on behalf of a person. The request then names the agent as the subject and the person as the operator, so the approver sees both. The agent is not blocked for lacking a role. It waits for a person to say yes to this particular action.
When an agent starts the action, set subject to the agent. Set operator to the person that the agent acts for. The chain can have up to five levels.
The Acme cleanup bot wipes MBP-4417 for Dana, who asked it to clean up the laptops of former employees:
const principal = {
subject: persistent('urn:acme:agent:cleanup-bot'),
attributes: [entry('agent', 'Agent', 'Cleanup bot')],
operator: { subject: email('[email protected]') }
};principal := stepauth.Principal{
Subject: stepauth.Persistent("urn:acme:agent:cleanup-bot"),
Attributes: stepauth.Entries(stepauth.Entry("agent", "Agent", "Cleanup bot")),
Operator: &stepauth.Principal{Subject: stepauth.Email("[email protected]")},
}var principal = new Principal
{
Subject = NameId.Persistent("urn:acme:agent:cleanup-bot"),
Attributes = [LabeledEntry.Text("agent", "Agent", "Cleanup bot")],
Operator = new Principal { Subject = NameId.Email("[email protected]") },
};The operator chain informs the approver. It shows who stands behind the agent. It does not give the agent a permission.
Action name and category
action.name is your own identifier, in reverse-DNS form, for example com.acme.mdm.device.wipe. The hub does not interpret it.
action.category comes from a closed registry. The hub rejects any other value with unknown_category. The SDK has a constant for each category.
The approver
action.approver is one NameID: the email of a member, the id of a group from the hub directory, or the name of a policy. A request without an approver notifies nobody. Route requests to approvers explains the three forms and their limits.
Expiry
expiresAt is 30 minutes after timestamp by default. If a person waits for the action, set it shorter. If the action can wait for an approver on another shift, set it longer. The hub sets no maximum.
timestamp must be within five minutes of the clock of the hub, in RFC 3339 format. The SDK also compares it with the local clock, before it sends the request.
policyVersion
policyVersion is a free-text tag for your own permission rules, for example acme-mdm-rules@2026-04-15. It records which of your rules decided that the action needs approval. It does not name a hub policy.
What the pending state holds
| Field | Why it exists |
|---|---|
requestId | The key that the decision callback uses to find the state. |
payload | The exact signed bytes, in base64. The decision digest is calculated over them. |
hub | The local tag of the hub, so the SDK finds the keysets that verify the decision. |
hubEntity | The hub entity at send time. A later change to the tags cannot change what a decision must match. |
result | The execution result, after you run the action. |
The signature covers the payload bytes exactly as sent. The SDK signs them once, every retry sends the same bytes, and the decision carries a digest of them.
Store the payload as the SDK returns it. Do not rebuild a request from parsed fields to compare it with a decision. The bytes change, and the digest does not match.
Keep the state, with its recorded result, for 24 hours after a decision arrives. The hub can deliver the same decision again in that time, and the handler answers it with the recorded result. A decision can also arrive after expiresAt, so keep the state of an undecided request past its expiry. The handler needs it to answer 200 with a status of expired. See Handle redelivery .
Retries
The SDK makes up to three attempts. It waits about 1 second, then about 4 seconds. It retries only a transport error or a hub 5xx. Every other error is a verdict on the exact bytes sent, and the same bytes get the same verdict.
Each retry sends the same signed envelope. If a retry gets 409, an earlier attempt reached the hub and its response was lost. The SDK reports an already-submitted result, not an error. The SDK does not follow redirects.
Receive the decision
In your application code
The hub posts the signed decision to your callback URL. The handler takes two functions. The first reads the pending state by request id. The second runs the decision and returns the execution result.
When the IT lead approves the wipe of MBP-4417, Acme MDM wipes the laptop and records the result. When the IT lead denies it, Acme MDM records the denial and does nothing else:
const handle = client.callbackHandler(
(requestId) => pending.get(requestId),
async (decision) => {
let result: ExecutionResult = { requestId: decision.requestId, status: 'success' };
if (isApproved(decision)) {
try {
await runAction(decision.requestId);
} catch {
result = { requestId: decision.requestId, status: 'error', error: 'The wipe did not complete.' };
}
}
try {
await pending.update(decision.requestId, { result });
} catch (error) {
console.error(`recording result of ${decision.requestId}`, error);
}
return result;
}
);
export async function POST(request: Request): Promise<Response> {
const { status, body } = await handle(await request.text());
return new Response(body ? JSON.stringify(body) : null, {
status,
headers: { 'content-type': 'application/json' }
});
}lookup := func(ctx context.Context, requestID string) (*stepauth.PendingState, error) {
return pending.Get(ctx, requestID)
}
handler := func(ctx context.Context, decision *stepauth.Decision) stepauth.ExecutionResult {
result := stepauth.ExecutionResult{RequestID: decision.RequestID, Status: "success"}
if decision.IsApproved() {
if err := runAction(ctx, decision.RequestID); err != nil {
result = stepauth.ExecutionResult{RequestID: decision.RequestID, Status: "error", Error: "the wipe did not complete"}
}
}
if err := pending.SetResult(ctx, decision.RequestID, result); err != nil {
log.Printf("recording result of %s: %v", decision.RequestID, err)
}
return result
}
http.Handle("/stepauth/callback", client.CallbackHandler(lookup, handler))var handler = client.CreateCallbackHandler(
async (requestId, ct) => await pending.GetAsync(requestId, ct),
async (decision, ct) =>
{
var result = new ExecutionResult { RequestId = decision.RequestId, Status = "success" };
if (decision.IsApproved)
{
try
{
await RunActionAsync(decision.RequestId, ct);
}
catch (Exception)
{
result = new ExecutionResult { RequestId = decision.RequestId, Status = "error", Error = "The wipe did not complete." };
}
}
try
{
await pending.SetResultAsync(decision.RequestId, result, ct);
}
catch (StoreException e)
{
logger.LogError(e, "recording result of {RequestId}", decision.RequestId);
}
return result;
});
app.MapPost("/stepauth/callback", async (HttpRequest request, HttpResponse response, CancellationToken ct) =>
{
request.HttpContext.Features.Get<IHttpMaxRequestBodySizeFeature>()!.MaxRequestBodySize = 1 << 20;
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer, ct);
var answer = await handler.HandleAsync(buffer.ToArray(), ct);
response.StatusCode = answer.StatusCode;
if (answer.Body is not null)
{
await response.WriteAsJsonAsync(answer.Body, ct);
}
});Record the result on success and on error. A redelivery of a recorded decision gets the recorded result, and the SDK does not run your function again.
The SDK answers the hub with 200 whether your function returns a result or throws. A recording error therefore never makes the hub resend the decision. A decision can still arrive twice, for example when the hub loses the response. If the first result is not recorded yet, the SDK runs your function again. Make the action safe to repeat for the same request id. When MBP-4417 is already wiped, Acme MDM skips the wipe.
What the handler checks
The handler reads the request id from the envelope, but it does not trust it. It finds the stored state. An unknown id gets 401. Then it checks, in this order:
- Both signatures verify against the keysets of the hub.
senderIdequals the hub entity recorded at send time.requestIdequals the request id in the stored state.recipientIdequals your own entity.requestDigestequals the SHA-256 digest of the stored payload bytes.- The decision arrived before
expiresAt.
If one of the first five checks does not pass, the handler returns 401 with no body. It never reports the cause, because a difference tells a forger which check to attack next.
A genuine decision that arrives after expiresAt gets 200 with a status of expired. The handler does not run your function.
The callback endpoint has no transport authentication. The envelope signature is the only security boundary. Do not treat a request without credentials as low risk, and do not put a gateway rule in front of the endpoint that expects credentials.
Handle redelivery
Delivery is at least once. The hub retries for 24 hours until it gets a 2xx response. Any 2xx counts as delivered, with or without a body. At delivery time, the hub applies the callback URL rules again. If a URL no longer meets them, the hub marks the delivery as failed and stops.
- Record the execution result in the stored state after you run the action.
- Return the stored state for every request id that you issued, also after the decision. The SDK then returns the recorded result.
- Make the action idempotent by request id. Two deliveries can arrive at the same time, and both can read the state before either writes it.
If the lookup returns nothing for an id that you issued, the hub reads the 401 as a rejection and retries for a day.
Report the execution result
The body that the handler returns is the execution result: the request id, a status of success or error, and an optional message.
If your function throws, the handler still returns 200 with status: "error" and the message execution failed. The decision was genuine at that point. A 401 would make the hub deliver it again, for an action that is possibly half done.
Test it end to end
In your application code
Ask your administrator to register your application under a test display name, as in Configure the hub , and name yourself as the approver of the test requests. The administrator deletes the registration when you are done, and registers the real one.
A hosted hub cannot reach localhost, and it delivers decisions to an HTTPS URL only. Run a tunnel that forwards to your application, then use the HTTPS URL it prints, plus your callback path, as the callback URL in the service provider metadata:
cloudflared tunnel --url http://localhost:18090A quick tunnel gets a new hostname on every start, and the callback URL is part of the registered metadata. Register the service provider again after the hostname changes, or use a named tunnel with a hostname that you own.
Serve only the callback path through the tunnel. Every other page of your application answers to the tunnel’s hostname too, unless your application checks the Host header.
- The hub accepts the request and the approver gets an email.
- After the approval, the callback receives the decision and your handler returns a result.
- After the first result is recorded, a duplicate delivery gets the recorded result and does not run the action again. Two deliveries before the record both run it, so claim the request before you run the action.
- A request without an approver, or with an approver outside the directory, is rejected or expires without a decision.
The category registry
| Category | For |
|---|---|
data.read | Reading records that are normally not visible. |
data.export | Moving data out of the system. |
data.modify | Changing stored records. |
data.delete | Erasing stored records. |
identity.create | Creating an account. |
identity.modify | Changing an account. |
identity.escalate | Raising the permissions of an account. |
identity.disable | Disabling an account. |
infra.modify | Changing infrastructure. |
infra.destroy | Destroying infrastructure. |
financial.transfer | Moving money. |
financial.approve | Approving a financial commitment. |
code.deploy | Deploying code. |
code.release | Releasing a version. |
communication.send | Sending a message for the organization. |
access.grant | Granting access to a resource. |
access.revoke | Removing access from a resource. |
Errors
| Code | HTTP | Meaning and what to do |
|---|---|---|
unknown_sender | 401 | No registration at the hub has your entity. Ask the administrator to register your service provider metadata. |
invalid_signature | 401 | The registration does not hold your keyset, or a signature is not valid. Register your current keyset. |
wrong_recipient | 403 | The request is for a different hub. Use the hub metadata of the right organization. |
org_locked | 403 | SplitSecure locked the organization. Requests work again after SplitSecure unlocks it. |
org_deactivated | 403 | The organization is deactivated or scheduled for deletion. Ask its administrator. |
request_not_found | 404 | The hub has no metadata for the organization, or the host is wrong. Copy the hub metadata again. |
duplicate_request | 409 | The SDK never returns this code. It reports an already-submitted result. See the warning below. |
malformed_request | 400 | A required field is missing or has an incorrect value. |
unknown_category | 400 | Use a category from the registry. |
unknown_approver | 400 | The approver is not an active member, or the group or policy does not exist. Use the hub directory. |
approver_set_too_large | 400 | The approver expands to more than 99 groups or 5000 members. Name a smaller group or policy. |
operator_depth_exceeded | 400 | Shorten the operator chain to five levels or fewer. |
invalid_callback_url | 400 | The callback URL is missing, is not HTTPS, or does not match the pattern on the registration. |
timestamp_out_of_range | 400 | The clock of your host is more than five minutes from the clock of the hub. Run NTP. |
internal_error | 500 | The hub had an error. The SDK retries. |
internal_error | 503 | The hub cannot evaluate requests at the moment. The SDK retries. |
An answer without an error code, such as a proxy error page, gets the code http_<status> from the SDK, for example http_502. In every SDK, a request for a hub tag that is not in the client configuration gets a local error. The SDK sends nothing.
A requestId is unique in the whole organization, across every registered application. If a request id is already in use, the SDK reports an already-submitted result, and no decision arrives for your request. Use the request id helper of the SDK, or put a prefix that is unique to your application on a natural key.
Only a transport error and a hub 5xx are retryable, and the SDK retries those. Most 4xx codes in the table are a verdict on the exact bytes you sent, and the same bytes get the same answer. org_locked and org_deactivated are different. They depend on the state of the organization, and the directory calls return them too.
Limits
| Limit | Value |
|---|---|
action.name and action.title | 120 characters or fewer |
| Detail rows shown in email and Slack | 12, each value cut at 200 characters |
| Detail nesting shown | 4 levels |
| Operator chain | 5 levels |
| Approver expansion | 99 groups and 5000 members |
| Default expiry | 30 minutes after timestamp |
Clock skew on timestamp | 5 minutes |
| Request and callback body | 1 MiB |
| Decision redelivery | 24 hours, until a 2xx answer |
| Policy name | 120 characters or fewer |
| Policies per organization | 200 or fewer |
Troubleshooting
| Issue | Solution |
|---|---|
Every request gets unknown_sender | The hub has no registration for your entityExport the service provider metadata and ask the administrator to register it |
Every request gets invalid_signature | You sign with a keyset that the registration does not hold, for example after a new keysetExport the service provider metadata again. Ask the administrator to replace the registration. See Rotate the keyset |
Every request gets wrong_recipient | The hub metadata in your configuration belongs to a different organizationGet the hub metadata again from the administrator of the right organization |
| The client rejects the configuration | The hub document has no host or an http host, or more or fewer than one hub is the defaultUse the hub document as copied, with its HTTPS host. Mark exactly one hub as the default |
The callback answers 401 to every decision | The stored payload is not the signed bytes, or the lookup returns nothingStore the payload of the pending state as is. Do not rebuild it from parsed fields |
| The hub keeps delivering a decision | The callback returns a status that is not 2xxReturn the stored state for every id that you issued, also after the decision |
Requests get timestamp_out_of_range | The clock of your host is more than five minutes from the clock of the hubRun NTP on the host |
| The action runs twice | Two deliveries of one decision ran your function at the same timeClaim the request row with a conditional write before you run the action. Run it after a successful claim only |
| No decision arrives, and the send call succeeded | The request names no approver, or its requestId was already in useName an approver on every request. Use the request id helper |