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.
Install
Section titled “Install”npm install @outis/sdk
pip install outis
pip install 'outis[durable]' # adds defer_to and the worker
go get github.com/outis-auth/outis-go
Pick a path
Section titled “Pick a path”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 and wait
Section titled “Guard and wait”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
from outis import Outis
outis = Outis(requester="payouts-api") # reads OUTIS_API_KEY
approval = outis.guard(
action="payouts.release",
show_approvers={"amount": "48000.00", "currency": "USD", "to": "acct_9f2"},
wait="5m",
)
approval.result() # blocks until people decide, raises unless they approve
release_payout()
client := outis.New(os.Getenv("OUTIS_API_KEY"), outis.WithRequester("payouts-api"))
_, err := client.Guard(ctx, outis.GuardOptions{
Action: "payouts.release",
ShowApprovers: map[string]string{"amount": "48000.00", "currency": "USD", "to": "acct_9f2"},
Wait: 5 * time.Minute,
})
if err != nil {
return err // not approved, timed out, or Outis couldn't be reached
}
return releasePayout(ctx)
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.
Keep going without waiting
Section titled “Keep going without waiting”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.
approval = outis.guard(action="payouts.release", show_approvers={"amount": "48000.00"}, wait="5m")
def on_done(f):
if not f.cancelled() and f.exception() is None:
release_payout()
approval.add_done_callback(on_done)guard returns a concurrent.futures.Future right away and polls on a background thread. Call approval.result() whenever you’re ready to block, or use the standard library’s add_done_callback, which runs on that background thread. In asyncio code, await outis.guard_async(...) waits on the running event loop instead.
go func() {
if _, err := client.Guard(ctx, opts); err == nil {
releasePayout(ctx)
}
}()Guard blocks the goroutine that calls it, so start a goroutine to keep going. Inside an HTTP handler, give that goroutine a context that outlives the request.
The rules for wait
Section titled “The rules for wait”waitis 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
AbortSignalassignalin Node, callapproval.cancel()in Python, or cancelctxin 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.
Guard and deferTo
Section titled “Guard and deferTo”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 });
deferred = outis.guard(
action="stripe.transfer",
show_approvers={"amount": "48000.00", "currency": "USD", "to": "acct_9f2"},
defer_to={
"worker": "stripe",
"call": "transfers.create",
"args": [{"amount": 4_800_000, "currency": "usd", "destination": "acct_9f2"}],
},
)
save_request_id(payout_id, deferred.id)
key, err := outis.ParseIntentKey(os.Getenv("OUTIS_INTENT_KEY"))
if err != nil {
return err
}
client := outis.New(os.Getenv("OUTIS_API_KEY"), outis.WithIntentKey(key), outis.WithRequester("payouts-api"))
d, err := client.Defer(ctx, outis.GuardOptions{
Action: "stripe.transfer",
ShowApprovers: map[string]string{"amount": "48000.00", "currency": "USD", "to": "acct_9f2"},
IdempotencyKey: "payout-7731",
DeferTo: &outis.DeferTo{Worker: "stripe", Call: "v1transfers.create", Args: []any{params}},
})
if err != nil {
return err
}
savePayoutRequest(payoutID, d.ID)In Go, waiting and deferring are two methods: client.Guard waits, and client.Defer hands off.
What goes in deferTo (defer_to in Python, DeferTo in Go):
worker: the name the worker registered the client under, likestripe.call: the dotted method on that client, liketransfers.create.args: the call’s arguments, plain JSON data only. Python also takeskwargs, 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
Section titled “The worker”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
import os
from stripe import StripeClient
from outis import Outis, WorkerClient
stripe = StripeClient(os.environ["STRIPE_SECRET_KEY"])
worker = Outis().worker(
clients={"stripe": WorkerClient(stripe.v1, idempotency="stripe")},
allow=["stripe.transfers.create"],
)
worker.start() # polls every 15 seconds and stops cleanly on SIGTERM or Ctrl-C
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
keys, err := outis.ParseIntentKeys(os.Getenv("OUTIS_INTENT_KEY"))
if err != nil {
return err
}
w := outis.NewWorker(outis.New(os.Getenv("OUTIS_API_KEY")), outis.WorkerOptions{
Keys: keys,
Allow: []string{"stripe.v1transfers.create"},
})
w.Register("stripe", stripe.NewClient(os.Getenv("STRIPE_KEY")), outis.InjectIdempotencyKey())
return w.Run(ctx, 15*time.Second)Run polls on the interval you give it. When ctx ends, it stops taking new work and finishes what’s in flight. On a cron job, w.Poll(ctx) makes one pass and returns how many requests it ran.
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.
Guard a method you already call
Section titled “Guard a method you already call”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" });
create_transfer = outis.guard_method(
stripe.v1.transfers,
"create",
action="stripe.transfer",
show_approvers=lambda t: {"amount": str(t["amount"]), "currency": t["currency"], "to": t["destination"]},
wait="5m",
)
transfer = create_transfer({"amount": 4_800_000, "currency": "usd", "destination": "acct_9f2"})guard_method blocks until approval and then returns the method’s result. guard_method_async does the same in asyncio code, and awaits the method if it’s async.
transfer, err := outis.GuardFunc(ctx, client, outis.GuardOptions{
Action: "stripe.transfer",
ShowApprovers: map[string]string{"amount": "48000.00", "currency": "USD", "to": "acct_9f2"},
Wait: 5 * time.Minute,
}, func(ctx context.Context) (*stripe.Transfer, error) {
return sc.V1Transfers.Create(ctx, params)
})Go doesn’t wrap methods. GuardFunc waits for approval and then runs your function. For the worker path, call client.Defer with DeferTo.
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" });
create_transfer = outis.guard_method(
stripe.v1.transfers,
"create",
action="stripe.transfer",
show_approvers=lambda t: {"amount": str(t["amount"]), "currency": t["currency"], "to": t["destination"]},
defer_to={"worker": "stripe", "call": "transfers.create"},
)
deferred = create_transfer({"amount": 4_800_000, "currency": "usd", "destination": "acct_9f2"})
Stripe recipes
Section titled “Stripe recipes”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.
from outis.recipes import stripe as stripe_recipes
create_transfer = outis.guard_method(
stripe.v1.transfers, "create", **stripe_recipes.transfers.create(), wait="5m"
)A recipe is a dict of options, so spread it with **. It carries call too, so with defer_to={"worker": "stripe"} the recipe fills in call for you, the same as Node.
import stripeguard "github.com/outis-auth/outis-go/recipes/stripe"
shown, err := stripeguard.Transfer{
Amount: params.Amount,
Currency: params.Currency,
Destination: params.Destination,
}.ShowApprovers()
if err != nil {
return err // a field Stripe requires is missing
}
opts := outis.GuardOptions{
Action: stripeguard.ActionTransfersCreate,
ShowApprovers: shown,
Wait: 5 * time.Minute,
}Each type copies the fields of the stripe-go params struct it’s named for, with the same pointer types, and the package doesn’t import stripe-go. ShowApprovers() returns the map and an error. Use the options with client.Guard, client.Defer or outis.GuardFunc.
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().
Errors
Section titled “Errors”| 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;
}
from outis import NotAuthorizedError, WaitTimeoutError
try:
outis.guard(action="db.restore", show_approvers={"db": "payments"}, wait="10m").result()
except NotAuthorizedError as e:
print(f"not approved: {e.outcome}")
except WaitTimeoutError as e:
print(f"still waiting on {e.request_id}")
else:
restore("payments")
_, err := client.Guard(ctx, outis.GuardOptions{
Action: "db.restore",
ShowApprovers: map[string]string{"db": "payments"},
Wait: 10 * time.Minute,
})
var notAuth *outis.NotAuthorizedError
var timedOut *outis.WaitTimeoutError
switch {
case errors.As(err, ¬Auth):
return fmt.Errorf("restore was %s", notAuth.Outcome)
case errors.As(err, &timedOut):
return fmt.Errorf("still waiting on %s", timedOut.RequestID)
case err != nil:
return err
}
return restore(ctx, "payments")
Advanced: the low-level API
Section titled “Advanced: the low-level API”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, then observe
Section titled “Create, then observe”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
}
from outis import Outis
outis = Outis() # reads OUTIS_API_KEY
request = outis.requests.create(
action="payouts.release",
requester="keith",
params={"amount": "48000.00", "currency": "USD", "to": "acct_9f2"},
summary="Release the March payout",
idempotency_key="payout-2026-03",
)
save_request_id("2026-03", request.id)
# later
latest = outis.requests.retrieve(request.id)
if latest.is_authorized:
... # run it
elif not latest.is_pending:
print(latest.outcome) # denied, expired or aborted
client := outis.New(os.Getenv("OUTIS_API_KEY"))
req, err := client.Requests.Create(ctx, outis.CreateParams{
Action: "payouts.release",
Requester: "keith",
Params: map[string]string{"amount": "48000.00", "currency": "USD", "to": "acct_9f2"},
Summary: "Release the March payout",
}, outis.WithIdempotencyKey("payout-2026-03"))
if err != nil {
return err
}
saveRequestID("2026-03", req.ID)
// later
latest, err := client.Requests.Retrieve(ctx, req.ID)
if err != nil {
return err
}
switch {
case latest.IsAuthorized():
// run it
case !latest.IsPending():
log.Printf("not authorized: %s", latest.Outcome)
}
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.
Check before you execute
Section titled “Check before you execute”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" },
});
outis.requests.assert_authorized(
request_id,
action="payouts.release",
params={"amount": "48000.00", "currency": "USD", "to": "acct_9f2"},
)
_, err := client.Requests.AssertAuthorized(ctx, requestID, outis.Operation{
Action: "payouts.release",
Params: map[string]string{"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.
Verify webhooks
Section titled “Verify webhooks”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();
});
import os
from flask import Flask, request
from outis import WebhookVerificationError, verify_webhook
app = Flask(__name__)
@app.post("/outis/events")
def outis_events():
try:
event = verify_webhook(request.get_data(), request.headers, os.environ["OUTIS_WEBHOOK_SECRET"])
except WebhookVerificationError:
return "", 400
if already_seen(event.id):
return "", 200
if event.type == "request.authorized":
enqueue(event.request.id)
return "", 200
http.HandleFunc("/outis/events", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
http.Error(w, "too big", http.StatusRequestEntityTooLarge)
return
}
ev, err := outis.VerifyWebhook(body, r.Header, os.Getenv("OUTIS_WEBHOOK_SECRET"), nil)
if err != nil {
http.Error(w, "bad signature", http.StatusBadRequest)
return
}
if !seen(ev.ID) && ev.Type == "request.authorized" {
enqueue(ev.Data.Request.ID)
}
w.WriteHeader(http.StatusOK)
})
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.
Drive the worker yourself
Section titled “Drive the worker yourself”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.
Intents, claims and reports
Section titled “Intents, claims and reports”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);
pending = 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"}],
idempotency_key="payout-7731",
execute_within="7d",
)
print(pending.request_id, pending.intent_digest)
pending, err := client.Intents.Propose(ctx, outis.IntentParams{
Action: "stripe.transfer",
Requester: "payouts-api",
Params: map[string]string{"amount": "48000.00", "currency": "USD", "to": "acct_9f2"},
Client: "stripe",
Method: "v1transfers.create",
Args: []any{params},
ExecuteWithin: 7 * 24 * time.Hour,
}, outis.WithIdempotencyKey("payout-7731"))
if err != nil {
return err
}
log.Printf("waiting on %s", pending.RequestID)
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.
Wrap a whole client
Section titled “Wrap a whole client”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.