Durable execution
A payout that needs two approvers can sit for two days because one of them’s on a plane. Nothing in your code should block that long, and nothing should have to remember the call’s arguments by hand until they land.
Durable execution splits the call in two. Your code makes the call as usual, and the SDK seals it into an intent and sends it with the request instead of running it. Once the operators authorize the request, a worker you run gets the intent back, checks it and makes the call with your credentials.
How it works
Section titled “How it works”-
Your code seals the call.
guardwithdeferTo(orguardMethod, orintents.proposeunderneath them) records which client, which method and the JSON arguments, then encrypts that with your intent key. It adds the plaintext’s SHA-256 digest to the request’s params asintent, and creates the request with the ciphertext attached. The real method doesn’t run. -
Operators approve the exact call. The params you chose (the amount, the account, whatever tells an operator what’s about to happen) go to the box as usual, along with the
intentdigest. The digest is part of the operation hash, so the approval covers those exact bytes and nothing else. -
A worker claims it. Once the request is authorized, your worker hears about it from a webhook or a poll and claims it. Only one worker holds a claim at a time. It decrypts the intent, checks it, and calls the method on the client it registered, under your credentials.
-
The worker reports back. It tells Outis whether the call succeeded (with a reference, like the Stripe transfer id) or failed (with the error). That lands on the request’s
executionrecord and goes out as arequest.executedorrequest.execution_failedevent.
Outis holds the ciphertext, the digest and the claim. It never sees the plaintext arguments or your intent key, and it never holds a credential for Stripe or your database. Your worker makes every call.
What the worker checks
Section titled “What the worker checks”Before it calls anything, the worker checks these in order. At the first miss it reports failed with the reason code and doesn’t call anything.
| Check | Reason code |
|---|---|
A worker key matches the envelope’s kid |
unknown_key |
| The ciphertext decrypts under that key for the request’s action. An intent sealed for one action won’t open under another. | decrypt_failed |
The plaintext’s digest equals the request’s params.intent |
digest_mismatch |
| The request’s operation hash equals the hash of its action and params | operation_mismatch |
The outcome is authorized |
not_authorized |
| The intent names a client (or a handler) the worker registered | client_not_registered |
client.method matches your allow list, if you gave one |
not_allowed |
A call that throws reports failed with its error message.
Set it up
Section titled “Set it up”Generate an intent key
Section titled “Generate an intent key”The intent key is 32 random bytes, base64 encoded. Generate one with openssl, or with the Python SDK:
openssl rand -base64 32
python -m outis keygen
Store it in your secret manager as OUTIS_INTENT_KEY, and give it to the code that proposes and the workers that execute. Nobody else needs it, and Outis never gets it. The Node and Python clients read OUTIS_INTENT_KEYS, then OUTIS_INTENT_KEY, from the environment. In Go you parse it and pass it in.
To rotate, set OUTIS_INTENT_KEYS to the new key and the old one, comma separated, new first. Proposers seal with the first key, and workers open with whichever key the envelope names. Drop the old key once nothing sealed under it is still waiting. If you lose a key, intents sealed under it can’t be opened, and those requests run out their window unexecuted.
Generate the API keys
Section titled “Generate the API keys”The proposing side needs a key with propose (and read, if it also reads requests). A worker needs read and execute. Generate them from your organization’s keys page in the dashboard, and keep the worker’s key with the worker.
Pick an execution window
Section titled “Pick an execution window”A sealed request can be executed for 7 days after it’s created, unless you say otherwise. Set executeWithin (execute_within in Python, ExecuteWithin in Go) up to 30 days. Past the window, a claim is refused and the request stays unexecuted.
Claims, and running once
Section titled “Claims, and running once”A claim is a lease, 10 minutes unless the worker asks for another length (up to an hour). While it’s live, nobody else can claim the request. The worker reports inside the lease, and the report closes the request for good: a request that’s been reported can’t be claimed again.
If a worker crashes after the call and before the report, the lease runs out and another worker claims the request again. The request id is the idempotency key that makes that second run harmless, so hand it downstream:
- Register Stripe with the idempotency option and the worker adds the request id as Stripe’s idempotency key on every call:
{ client: stripe, idempotency: "stripe" }in Node,WorkerClient(stripe.v1, idempotency="stripe")in Python,outis.InjectIdempotencyKey()in Go. - A handler you write gets it on its context:
ctx.idempotencyKey,ctx.idempotency_keyorx.IdempotencyKey.
Seal a call
Section titled “Seal a call”Use guard with deferTo to seal one call, or guardMethod with deferTo to seal every call to a method you already use. The SDKs guide has both in full.
import Stripe from "stripe";
import { Outis } from "@outis/sdk";
const outis = new Outis({ apiKey: process.env.OUTIS_API_KEY!, requester: "payouts" }); // reads OUTIS_INTENT_KEY
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const createTransfer = outis.guardMethod(stripe.transfers, "create", {
action: "stripe.transfer",
showApprovers: (t) => ({
amount: (t.amount / 100).toFixed(2),
currency: t.currency.toUpperCase(),
to: t.destination,
}),
summary: (t) => `Transfer to ${t.destination}`,
deferTo: { worker: "stripe", call: "transfers.create", executeWithin: "7d" },
});
const deferred = await createTransfer({
amount: 4_800_000,
currency: "usd",
destination: "acct_9f2",
});
console.log(deferred.id, deferred.intentDigest);
import os
from stripe import StripeClient
from outis import Outis
stripe = StripeClient(os.environ["STRIPE_SECRET_KEY"])
outis = Outis(requester="payouts") # reads OUTIS_API_KEY and OUTIS_INTENT_KEY
create_transfer = outis.guard_method(
stripe.v1.transfers,
"create",
action="stripe.transfer",
show_approvers=lambda t: {
"amount": f"{t['amount'] / 100:.2f}",
"currency": t["currency"].upper(),
"to": t["destination"],
},
defer_to={"worker": "stripe", "call": "transfers.create", "execute_within": "7d"},
)
deferred = create_transfer({"amount": 4_800_000, "currency": "usd", "destination": "acct_9f2"})
print(deferred.id, deferred.intent_digest)
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))
d, err := client.Defer(ctx, outis.GuardOptions{
Action: "payouts.release",
Requester: "payouts",
ShowApprovers: map[string]string{"amount": "48000.00", "currency": "USD", "to": "acct_9f2"},
IdempotencyKey: "payout-2026-03",
DeferTo: &outis.DeferTo{
Worker: "payouts",
Call: "release",
Args: []any{payouts.ReleaseParams{Account: "acct_9f2", Amount: "48000.00", Currency: "USD"}},
ExecuteWithin: 7 * 24 * time.Hour,
},
})
if err != nil {
return err
}
log.Printf("waiting on %s", d.ID)
The call returns as soon as the request exists, with a handle holding the request’s id, the request and the intentDigest. Keep the request id wherever you’d keep the payout’s status.
The arguments have to be plain JSON data: objects, arrays, strings, numbers, booleans and null. A function, a class instance or a stream is refused before anything is sent. Python can also seal keyword arguments, which only a Python worker replays, so use positional arguments for an intent a Node or Go worker runs. The Python SDK needs its durable extra for the encryption: pip install 'outis[durable]'.
showApprovers is what the operators read, so derive it from the same arguments and make it say what the call will do. Amounts go in as plain decimals, like 48000.00.
The low-level wrap has a hybrid mode that waits up to a limit you set and falls back to a worker. A call that’s approved inside the wait runs right there, and one that isn’t stays sealed for a worker. A denial inside the wait throws.
const payouts = outis.wrap(stripe, rules, { mode: "hybrid", client: "stripe", wait: "45s", idempotency: "stripe" });
const settled = await payouts.transfers.create(transfer);
if (settled.status === "pending") await markWaiting(settled.requestId);
else console.log(settled.result.id);
Run a worker
Section titled “Run a worker”A worker registers clients, never individual calls. It runs whatever an authorized intent names on a client it holds, which is why you give it an allow list for anything broader than one method. Run it in a loop that polls every few seconds, behind a webhook so it starts the moment the keys turn, or both. Both is the safe default: the webhook for speed, the poll to pick up anything a missed delivery left behind.
A Go service with a ticker
Section titled “A Go service with a ticker”One binary: it serves the webhook and runs the poll loop in a goroutine. Register resolves the intent’s method by name on the value you hand it (a dotted path, ignoring case and underscores), decodes the JSON arguments into the method’s parameter types and passes a context.Context first if the method takes one. Handle covers anything reflection can’t reach, matched on the full client.method name. A handler gets the Execution and the intent’s arguments as the raw JSON array, which it decodes itself.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/outis-auth/outis-go"
"example.com/payments/internal/payouts"
"example.com/payments/internal/restore"
)
type restoreArgs struct {
DB string `json:"db"`
Snapshot string `json:"snapshot"`
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
keys, err := outis.ParseIntentKeys(os.Getenv("OUTIS_INTENT_KEYS"))
if err != nil {
log.Fatal(err)
}
w := outis.NewWorker(outis.New(os.Getenv("OUTIS_API_KEY")), outis.WorkerOptions{
Keys: keys,
Allow: []string{"payouts.release", "db.restore"},
})
w.Register("payouts", payouts.NewClient(os.Getenv("PAYOUTS_TOKEN")))
w.Handle("db.restore", func(ctx context.Context, x outis.Execution, args json.RawMessage) (string, error) {
var in []restoreArgs
if err := json.Unmarshal(args, &in); err != nil || len(in) != 1 {
return "", fmt.Errorf("db.restore takes one argument")
}
return restore.Run(ctx, in[0].DB, in[0].Snapshot, x.IdempotencyKey)
})
mux := http.NewServeMux()
mux.Handle("POST /outis/events", w.Handler(os.Getenv("OUTIS_WEBHOOK_SECRET")))
srv := &http.Server{Addr: ":8080", Handler: mux, ReadHeaderTimeout: 10 * time.Second}
go func() {
if err := w.Run(ctx, 15*time.Second); err != nil && !errors.Is(err, context.Canceled) {
log.Printf("worker: %v", err)
}
}()
go func() {
<-ctx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(shutdown)
w.Wait()
}()
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}
Run returns once the context ends and the runs in flight have reported. The webhook handler answers 202 and starts the run, and Wait waits out the runs it started. Execute(ctx, id) runs one request by id, for when something else decides when to run it. Poll(ctx) makes one pass for a cron job: it runs what’s executable, waits for it and returns how many ran.
A Node background worker
Section titled “A Node background worker”// worker.ts
import express from "express";
import Stripe from "stripe";
import { Outis } from "@outis/sdk";
const outis = new Outis({ apiKey: process.env.OUTIS_API_KEY! }); // reads OUTIS_INTENT_KEYS
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const worker = outis.worker({
clients: { stripe: { client: stripe, idempotency: "stripe" } },
handlers: {
"db.restore": (ctx, db: string, snapshot: string) => restore(db, snapshot, ctx.idempotencyKey),
},
allow: ["stripe.transfers.create", "db.restore"],
concurrency: 4,
onResult: (r) => console.log(r.requestId, r.status, r.reason ?? ""),
});
const app = express();
app.post("/outis/events", express.raw({ type: "application/json" }), worker.handler(process.env.OUTIS_WEBHOOK_SECRET!));
const server = app.listen(8080);
await worker.start({ every: "15s" });
server.close();
worker.handler needs the raw body, since the signature covers the exact bytes, so mount it behind express.raw. start polls until SIGTERM or SIGINT, then lets the runs in flight finish and resolves. For your own stop signal, worker.run({ every, signal }) runs the same loop until the AbortSignal fires. A handler gets the context first, then the intent’s arguments. worker.execute(requestId) runs one request, for a queue consumer or an engine step.
A Python worker
Section titled “A Python worker”# worker.py
import os
from stripe import StripeClient
from outis import Outis, WorkerClient
stripe = StripeClient(os.environ["STRIPE_SECRET_KEY"])
outis = Outis() # the API key needs read and execute; reads OUTIS_INTENT_KEYS
worker = outis.worker(
clients={"stripe": WorkerClient(stripe.v1, idempotency="stripe")},
allow=["stripe.transfers.create"],
on_result=lambda r: print(r.request_id, r.status, r.reason),
)
worker.start(every="15s")
Run it with python worker.py under whatever keeps your other processes up. On SIGTERM or Ctrl-C, start finishes what it started and returns. To stop it yourself, worker.run(every="15s", stop=event) runs the same loop until the threading.Event is set, and worker.poll() makes a single pass for a cron job. A handler in handlers is called as handler(ctx, *args, **kwargs), the context first and then the intent’s arguments. For webhooks, worker.handle_webhook(raw_body, headers, secret) returns a status and a body for any framework, and worker.wsgi_app(secret) and worker.asgi_app(secret) mount as apps of their own:
import asyncio
import os
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.post("/outis/events")
async def outis_events(request: Request):
status, body = await asyncio.to_thread(
worker.handle_webhook, await request.body(), request.headers, os.environ["OUTIS_WEBHOOK_SECRET"]
)
return JSONResponse(body, status_code=status)
A serverless route (Next.js)
Section titled “A serverless route (Next.js)”A route handler has no loop, so the webhook does the work, and a cron route sweeps up anything a missed delivery left behind. Put the client and the worker in one module:
// lib/outis.ts
import Stripe from "stripe";
import { Outis } from "@outis/sdk";
export const outis = new Outis({ apiKey: process.env.OUTIS_API_KEY! });
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export const worker = outis.worker({
clients: { stripe: { client: stripe, idempotency: "stripe" } },
allow: ["stripe.transfers.create"],
});
export const createTransfer = outis.guardMethod(stripe.transfers, "create", {
action: "stripe.transfer",
requester: "payouts",
showApprovers: (t) => ({ amount: (t.amount / 100).toFixed(2), currency: t.currency.toUpperCase(), to: t.destination }),
deferTo: { worker: "stripe", call: "transfers.create" },
});
The route that proposes:
// app/api/payouts/route.ts
import { createTransfer } from "@/lib/outis";
export const runtime = "nodejs";
export async function POST(req: Request) {
const { amount, destination } = await req.json();
const deferred = await createTransfer({ amount, currency: "usd", destination });
return Response.json({ requestId: deferred.id }, { status: 202 });
}
The webhook route that executes:
// app/api/outis/route.ts
import { worker } from "@/lib/outis";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 60;
const handle = worker.fetchHandler(process.env.OUTIS_WEBHOOK_SECRET!);
export async function POST(request: Request) {
return handle(request);
}
And the cron route that sweeps up:
// app/api/outis/sweep/route.ts
import { worker } from "@/lib/outis";
export const runtime = "nodejs";
export const maxDuration = 60;
export async function GET(req: Request) {
if (req.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response(null, { status: 401 });
}
const results = await worker.poll();
return Response.json({ ran: results.length });
}
worker.poll() is one pass: it lists what’s executable and runs it. Add the webhook route as an endpoint for your organization in the dashboard, and schedule the sweep with your platform’s cron (Vercel sends CRON_SECRET as a bearer token). Keep maxDuration above the slowest call you register. worker.fetchHandler works the same way in a Cloudflare Worker, Deno or Bun. The Python and Go workers have the same sweep as worker.poll() and w.Poll(ctx).
Scaffold a worker
Section titled “Scaffold a worker”Each SDK writes a runnable starter into the current directory, and refuses to overwrite anything already there:
npx @outis/sdk init worker -runtime next
python -m outis init worker -runtime fastapi
outis init worker -runtime go
| SDK | Starters |
|---|---|
| Node | node (the default, a background worker), next, cloudflare, temporal, inngest, trigger |
| Python | plain (the default, a thread with signal handling), fastapi, celery, temporal. |
| Go | go (the default, a service with a ticker and a graceful drain), temporal-go |
The engine starters follow Durable engines.
Read the result
Section titled “Read the result”A sealed request’s read body carries intent (the envelope, as you sent it) and execution:
"execution": {
"state": "succeeded",
"claimed_at": <epoch ms>,
"lease_expires_at": <epoch ms>,
"reported_at": <epoch ms>,
"reference": "tr_1Q2w3E",
"error": null,
"execute_by": <epoch ms>
}
state is none for a request without an intent, pending once it’s authorized and unclaimed, then claimed, then succeeded or failed. The request’s own state and outcome still describe what the operators decided. execution is only about your worker’s run.