Skip to content
OUTIS DOCS

SDKs

There are three SDKs. Each one asks Outis for approval, waits for the answer or hands the call to a worker, and verifies webhooks. None of them has runtime dependencies.

Language Package Client Needs
Node @outis/sdk new Outis({ apiKey }) Node 20 or later, ESM
Python outis Outis(api_key=...) Python 3.10 or later
Go github.com/outis-auth/outis-go outis.New(apiKey) Go 1.25 or later

Every client talks to https://api.outis.tech. Python also reads OUTIS_API_KEY and OUTIS_BASE_URL when you don’t pass them.

npm install @outis/sdk

Everything starts with guard. You tell it the action, what the approvers see, and one of two things: wait here for the answer, or hand the call to a worker.

Your situation Use
People usually answer within minutes guard with wait
The answer could take hours or days guard with deferTo, plus a worker
You want to guard a method you already call guardMethod (guard_method in Python, outis.GuardFunc in Go)
That method is on Stripe the Stripe recipes

Every guard takes exactly one of wait or deferTo. Neither or both is an error before any request is sent. Outis never runs the operation. Your code does, after approval, with your own credentials.

guard with wait creates the request, polls it in the background, and hands you the approved request once people say yes.

import { Outis } from "@outis/sdk";

const outis = new Outis({ apiKey: process.env.OUTIS_API_KEY!, requester: "payouts-api" });

await outis.guard({
  action: "payouts.release",
  showApprovers: { amount: "48000.00", currency: "USD", to: "acct_9f2" },
  wait: "5m",
});
await releasePayout(); // only runs once people approve

The options:

Option Node Python Go What it is
Action action action Action The action name. Policy for it lives in Outis.
Requester requester requester Requester Who is asking. Falls back to the client’s default (requester on the client, WithRequester in Go).
What approvers see showApprovers show_approvers ShowApprovers Shown on the device exactly as written, and bound by the approval. Values must be strings. It’s sent as the request’s params.
Summary summary summary Summary Optional one line description.
Idempotency key idempotencyKey idempotency_key IdempotencyKey Optional. The same key and the same operation return the original request.
Wait wait wait Wait How long to wait for an answer, at most 30 minutes.

Put the real values in what approvers see: amounts as plain decimals, account ids, shas. That’s what people read on the box, and it’s what their approval covers.

Each SDK returns its language’s usual async value, so you decide whether your code waits for the answer.

outis
  .guard({ action: "payouts.release", showApprovers: { amount: "48000.00" }, wait: "5m" })
  .then(() => releasePayout())
  .catch((err) => console.error("payout not approved", err));

guard returns a plain promise and polls on timers, so the rest of the process keeps running. Always add a .catch to a guard you don’t await, or a denial turns into an unhandled rejection.

  • wait is required on this path. There’s no default.
  • It’s capped at 30 minutes. Ask for more and the call fails before it creates anything.
  • Running out doesn’t cancel the request. It stays open in Outis, and the timeout error carries its id so you can check on it later.
  • You can stop waiting early: pass an AbortSignal as signal in Node, call approval.cancel() in Python, or cancel ctx in Go. Polling stops, but the request stays open until someone decides or it expires.
  • The wait lives in your process. If the process exits first, the code after it never runs. Anything that has to survive a restart uses deferTo.

Durations are strings like "30s" or "5m" (or milliseconds in Node, seconds or a timedelta in Python) and a time.Duration in Go.

With deferTo, the SDK records the exact call (which client, which method, which arguments), encrypts it with your intent key and sends it with the request. It returns as soon as the request exists. Nothing waits and nothing runs yet. A worker you run makes the call once people approve it.

const deferred = await outis.guard({
  action: "stripe.transfer",
  showApprovers: { amount: "48000.00", currency: "USD", to: "acct_9f2" },
  deferTo: {
    worker: "stripe",
    call: "transfers.create",
    args: [{ amount: 4_800_000, currency: "usd", destination: "acct_9f2" }],
  },
});
await db.payouts.update(payoutId, { outisRequestId: deferred.id });

What goes in deferTo (defer_to in Python, DeferTo in Go):

  • worker: the name the worker registered the client under, like stripe.
  • call: the dotted method on that client, like transfers.create.
  • args: the call’s arguments, plain JSON data only. Python also takes kwargs, which only a Python worker can replay.
  • executeWithin (optional): how long after the request is created a worker may still run it. 7 days by default, at most 30.

You get back a handle with the request’s id, the request itself and the intentDigest (intent_digest in Python, IntentDigest in Go). Store the id wherever you’d keep the payout’s status.

deferTo needs an intent key. Generate one with openssl rand -base64 32 (or python -m outis keygen) and set it as OUTIS_INTENT_KEY wherever you call guard and wherever the worker runs. Node and Python read it from the environment. Go parses it and passes it in with WithIntentKey. Outis never gets the key, so it can’t read or change the call.

The worker picks up each approved call, checks it against what the approvers saw, and makes it with your own client and credentials.

import Stripe from "stripe";
import { Outis } from "@outis/sdk";

const outis = new Outis({ apiKey: process.env.OUTIS_API_KEY! }); // reads OUTIS_INTENT_KEY
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

const worker = outis.worker({
  clients: { stripe: { client: stripe, idempotency: "stripe" } },
  allow: ["stripe.transfers.create"],
});
await worker.start(); // polls every 15s; on SIGTERM or SIGINT it finishes current runs and stops

The worker’s API key needs the read and execute scopes. The stripe idempotency option hands the request id to Stripe as its idempotency key, so a run that’s retried after a crash can’t pay twice. Durable execution covers the checks the worker makes, claims, webhooks, serverless hosts and the starters each SDK can write for you.

guardMethod wraps one method on an object you already use. The new function takes the same arguments, and every option can be a function of them. The real method still runs on the real object.

const createTransfer = outis.guardMethod(stripe.transfers, "create", {
  action: "stripe.transfer",
  showApprovers: (t) => ({ amount: String(t.amount), currency: t.currency, to: t.destination }),
  wait: "5m",
});

const transfer = await createTransfer({ amount: 4_800_000, currency: "usd", destination: "acct_9f2" });

With wait, each call waits for approval, runs the real method with the same arguments and gives you its result. If people say no or time runs out, you get the same errors as guard, and the method never runs.

With deferTo instead, leave out args: the call’s own arguments are what gets sealed. Each call returns the deferred handle and the method doesn’t run here.

const createTransfer = outis.guardMethod(stripe.transfers, "create", {
  action: "stripe.transfer",
  showApprovers: (t) => ({ amount: String(t.amount), currency: t.currency, to: t.destination }),
  deferTo: { worker: "stripe", call: "transfers.create" },
});

const deferred = await createTransfer({ amount: 4_800_000, currency: "usd", destination: "acct_9f2" });

Recipes are ready-made options for Stripe’s official libraries. Each one sets the action and a vetted list of what approvers see: ids, amounts in the smallest currency unit, the currency, a few settings, and a description cut to 64 characters. They never show metadata, emails or card data. You still pick wait or deferTo, and requester if the client has no default.

import { recipes } from "@outis/sdk/recipes";

const createTransfer = outis.guardMethod(
  stripe.transfers,
  "create",
  recipes.stripe.transfers.create({ wait: "5m" }),
);

With deferTo: { worker: "stripe" }, the recipe fills in call for you.

The recipes and their actions:

Recipe Action Node Python Go
Transfer stripe.transfers.create recipes.stripe.transfers.create transfers.create() Transfer, ActionTransfersCreate
Payout stripe.payouts.create recipes.stripe.payouts.create payouts.create() Payout, ActionPayoutsCreate
Refund stripe.refunds.create recipes.stripe.refunds.create refunds.create() Refund, ActionRefundsCreate
Customer delete stripe.customers.delete recipes.stripe.customers.del customers.delete() CustomerDelete, ActionCustomersDelete

What each one shows the approvers. It’s the same in all three SDKs. A field you didn’t set is left out.

Recipe Shows Required
Transfer amount, currency, destination, source_transaction, description, stripe_account amount, currency and destination
Payout amount, currency, destination, method, source_type, description, stripe_account amount and currency
Refund charge, payment_intent, amount (full when left out, since Stripe then refunds everything left), reason, reverse_transfer, refund_application_fee, stripe_account charge or payment_intent
Customer delete customer, stripe_account the customer id

stripe_account is the connected account, when you make the call on behalf of one. A description longer than 64 characters is cut and ends in ....

A call that’s missing a required field is refused before any request is sent, with the error stripe <method> needs <field> (like stripe transfers.create needs destination). Node throws a TypeError, Python raises a ValueError, and Go returns the error from ShowApprovers().

Node Python Go When
NotAuthorizedError NotAuthorizedError *outis.NotAuthorizedError People said no. The outcome is denied, expired or aborted.
WaitTimeoutError WaitTimeoutError *outis.WaitTimeoutError wait ran out. The request is still open, and the error carries its id (requestId, request_id, RequestID).
OutisApiError APIError *outis.APIError The API refused a call. Carries the status and the API’s error code.
IdempotencyConflictError IdempotencyConflictError *outis.APIError with code idempotency_conflict The idempotency key was already used for a different operation.
OutisConnectionError APIConnectionError the transport error, wrapped Outis couldn’t be reached.
OperationMismatchError OperationMismatchError *outis.OperationMismatchError Authorized, but for a different operation (from assertAuthorized).
WebhookVerificationError WebhookVerificationError *outis.WebhookVerificationError A delivery failed verification. Answer 400 and drop it.
TypeError, RangeError ValueError, TypeError a plain error The options are wrong, like both wait and deferTo. Nothing was sent.

In Node, bad options reject the promise instead of throwing. In Python they’re raised right away, before you get a future. In Go, check the error types with errors.As.

import { NotAuthorizedError, WaitTimeoutError } from "@outis/sdk";

try {
  await outis.guard({ action: "db.restore", showApprovers: { db: "payments" }, wait: "10m" });
  await restore("payments");
} catch (err) {
  if (err instanceof NotAuthorizedError) console.log(`not approved: ${err.outcome}`);
  else if (err instanceof WaitTimeoutError) console.log(`still waiting on ${err.requestId}`);
  else throw err;
}

guard is built on smaller pieces, and they’re all public. Use them when you want full control: your own polling, your own queue, or a workflow engine that does the waiting.

Create the request, store its id, and find out later from a webhook or a read. Here params is what approvers see, the same thing showApprovers sets on guard.

import { Outis } from "@outis/sdk";

const outis = new Outis({ apiKey: process.env.OUTIS_API_KEY! });

const request = await outis.requests.create(
  {
    action: "payouts.release",
    requester: "keith",
    params: { amount: "48000.00", currency: "USD", to: "acct_9f2" },
    summary: "Release the March payout",
  },
  { idempotencyKey: "payout-2026-03" },
);
await db.payouts.update("2026-03", { outisRequestId: request.id });

// later
const latest = await outis.requests.retrieve(request.id);
if (latest.isAuthorized) {
  // run it
} else if (!latest.isPending) {
  console.log(latest.outcome); // denied, expired or aborted
}

create also takes a callbackUrl (callback_url, CallbackURL) and a quorum. With an idempotency key, a retried create can’t open a second request. The same key and the same operation hand back the original request with replayed set; the same key with a different operation is an idempotency conflict. A create without a key is never retried by the client. Reads retry a 429, a 5xx or a dropped connection with backoff and honor Retry-After.

waitFor (wait_for in Python, WaitFor in Go) is the waiting half on its own. It takes a required timeout, capped at 30 minutes, polls from one second backing off to ten, and returns on any outcome, not only authorized.

Whatever runs the operation calls assertAuthorized right before it does. It reads the request, requires the outcome authorized, and requires the request’s operation hash to match the action and params you’re about to run. A request approved for 250 dollars can’t be spent on 250 thousand.

await outis.requests.assertAuthorized(requestId, {
  action: "payouts.release",
  params: { amount: "48000.00", currency: "USD", to: "acct_9f2" },
});

It throws a not authorized error (denied, expired, aborted, or still pending) or an operation mismatch error. Record the request id as used once you’ve run it, so a repeated webhook or an overlapping poll can’t run it twice. The worker does both for you.

Every event Outis posts, to an organization’s endpoint or to a request’s callback URL, carries Outis-Signature: t=<unix seconds>,v1=<hex>. The helper checks it against the raw body, refuses a timestamp more than five minutes off, and returns the parsed event. Pass a list of secrets while you rotate one.

SDK Function On a client Callback key
Node verifyWebhook(rawBody, headers, secret) outis.webhooks.verify(...) callbackSecret(apiKey)
Python verify_webhook(raw_body, headers, secret) outis.webhooks.verify(...) callback_secret(api_key)
Go outis.VerifyWebhook(body, r.Header, secret, nil) none, it’s a package function outis.CallbackSecret(apiKey), verified with outis.VerifyWebhookKeys

An organization’s endpoint signs with its whsec_ secret. A request’s callback URL signs with a key derived from the API key that created the request, which the callback key helper computes for you.

import express from "express";
import { verifyWebhook, WebhookVerificationError } from "@outis/sdk";

const app = express();

app.post("/outis/events", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = verifyWebhook(req.body, req.headers, process.env.OUTIS_WEBHOOK_SECRET!);
  } catch (err) {
    if (err instanceof WebhookVerificationError) return res.status(400).end();
    throw err;
  }
  if (await seen(event.id)) return res.status(200).end();
  if (event.type === "request.authorized") await queue.push(event.data.request.id);
  res.status(200).end();
});

Dedupe on the event id, since a retry carries the same one. The event only says when to look, so call assertAuthorized before you execute anyway. Receive webhooks has the wire format and the retry schedule.

start() is the easy way to run a worker. These are the pieces underneath it, for hosts that need something else:

What Node Python Go
Poll loop until a stop signal worker.run({ every, signal }) worker.run(every=, stop=) w.Run(ctx, every)
One pass, for a cron job worker.poll() worker.poll() w.Poll(ctx)
Run one request by id worker.execute(requestId) worker.execute(request_id) w.Execute(ctx, id)
Run it from a webhook worker.handler(secret) (Node http, Express), worker.fetchHandler(secret) worker.handle_webhook(raw_body, headers, secret), worker.wsgi_app(secret), worker.asgi_app(secret) w.Handler(secret), w.HandlerKeys(keys...)
A call reflection can’t reach handlers: { "db.restore": (ctx, ...args) => ... } handlers={"db.restore": lambda ctx, *args, **kwargs: ...} w.Handle("db.restore", fn), with the arguments as a JSON array

Durable execution has full examples of each.

deferTo is built on intents. intents.propose seals the call and creates the request, and it takes everything requests.create does plus the client, method and arguments.

const pending = await outis.intents.propose(
  {
    action: "stripe.transfer",
    requester: "payouts-api",
    params: { amount: "48000.00", currency: "USD", to: "acct_9f2" },
    client: "stripe",
    method: "transfers.create",
    args: [{ amount: 4_800_000, currency: "usd", destination: "acct_9f2" }],
    executeWithin: "7d",
  },
  { idempotencyKey: "payout-7731" },
);
console.log(pending.requestId, pending.intentDigest);

On the worker side, the steps the worker takes are public too:

Step Node Python Go
List what’s ready to run outis.requests.listExecutable({ limit }) outis.requests.list_executable(limit=) client.Requests.ListExecutable(ctx, limit)
Take the lease outis.requests.claim(id, { leaseSeconds }) outis.requests.claim(id, lease=) client.Requests.Claim(ctx, id, lease)
Open and check the intent outis.intents.open(request) the worker does this outis.OpenIntent(keys, action, envelope)
Report how it went outis.requests.reportExecution(id, { claimId, status, reference, error }) outis.requests.report(id, claim_id=, status=, reference=, error=) client.Requests.ReportExecution(ctx, id, outis.ExecutionReport{...})

A report closes the request for good. Execution has the routes underneath.

Node and Python can also wrap an object and guard several methods at once. The methods you list ask Outis first, and everything else passes through untouched. Dotted paths reach nested methods.

const guarded = outis.wrap(
  stripe,
  {
    "transfers.create": {
      action: "stripe.transfer",
      when: (p) => p.amount >= 1_000_000, // under 10,000.00 skips Outis
      params: (p) => ({ amount: (p.amount / 100).toFixed(2), currency: p.currency, to: p.destination }),
      requester: "billing-bot",
    },
  },
  { mode: "wait", timeout: "5m" },
);

await guarded.transfers.create({ amount: 4_800_000, currency: "usd", destination: "acct_9f2" });

Each function gets the method’s own arguments, and when can skip Outis for a call that doesn’t need it. The mode decides what a call does:

Mode What a call does
wait Waits for approval with your timeout, then runs the real method. Same 30 minute cap.
durable Seals the call like deferTo and returns a pending handle. A worker runs it later.
hybrid Seals the call, then waits up to wait. If it’s approved in time, it runs right there and resolves with the result (status: "done"). If not, it resolves with the pending handle (status: "pending").

Python takes the same modes as keyword arguments (mode="durable", client="stripe"), and also accepts mode="propose" for durable. Go has no wrapper. Use GuardFunc, Defer or client.Intents.Propose.