Durable engines
If you already run a durable workflow engine, it’s good at waiting for days, so let it do the waiting. The shape is the same everywhere:
- A step creates the Outis request, with an idempotency key derived from the run, so a retried step finds the same request instead of opening a second one.
- The workflow waits on the engine’s own primitive for an outside event, with a timeout.
- The Outis event reaches that primitive, straight from Outis or through a small bridge you run.
- A step calls
assertAuthorizedwith the same action and params, then a step does the work with your credentials.
Step 4 is the check. The event only says when to look, so a workflow that wakes on an event it can’t verify still reads the request before it acts.
| Engine | Waits with | Outis event gets there by | Bridge |
|---|---|---|---|
| Temporal | A signal, raced against a timer | Your bridge calls SignalWorkflow |
Yes |
| Inngest | step.waitForEvent |
An Inngest webhook URL with a transform, as the callback URL | No |
| Trigger.dev | wait.forToken |
The token’s URL, as the callback URL | No |
| Cloudflare Workflows | step.waitForEvent |
Your Worker calls instance.sendEvent |
Yes |
| AWS Step Functions | A .waitForTaskToken task |
Your Lambda calls SendTaskSuccess |
Yes |
If the request carries a sealed intent, the last step is worker.execute(requestId) instead of the check and the call by hand. It claims, checks and runs the call, and reports the result.
The SDKs scaffold most of these: npx @outis/sdk init worker -runtime temporal (or inngest, trigger, cloudflare), outis init worker -runtime temporal-go, and the Python SDK’s temporal starter.
Temporal
Section titled “Temporal”The workflow creates the request in an activity, waits on an outisDecision signal raced against a timer, and checks the request in an activity before the one that does the work. The bridge verifies the Outis webhook and signals the workflow named in the request’s params.
// payouts/workflow.go
package payouts
import (
"context"
"errors"
"time"
"github.com/outis-auth/outis-go"
"go.temporal.io/sdk/activity"
"go.temporal.io/sdk/workflow"
)
type Payout struct {
Requester string
Amount string
To string
}
type Decision struct {
RequestID string
Type string
}
func operation(p Payout, workflowID string) outis.Operation {
return outis.Operation{Action: "payouts.release", Params: map[string]string{
"amount": p.Amount,
"currency": "USD",
"to": p.To,
"workflow_id": workflowID,
}}
}
func PayoutWorkflow(ctx workflow.Context, p Payout) error {
ctx = workflow.WithActivityOptions(ctx, workflow.ActivityOptions{StartToCloseTimeout: time.Minute})
var a *Activities
var requestID string
if err := workflow.ExecuteActivity(ctx, a.RequestApproval, p).Get(ctx, &requestID); err != nil {
return err
}
var d Decision
timedOut := false
sel := workflow.NewSelector(ctx)
sel.AddReceive(workflow.GetSignalChannel(ctx, "outisDecision"), func(c workflow.ReceiveChannel, _ bool) {
c.Receive(ctx, &d)
})
sel.AddFuture(workflow.NewTimer(ctx, 7*24*time.Hour), func(workflow.Future) { timedOut = true })
sel.Select(ctx)
if timedOut || d.Type != outis.EventRequestAuthorized {
return errors.New("payout not authorized")
}
if err := workflow.ExecuteActivity(ctx, a.CheckApproval, requestID, p).Get(ctx, nil); err != nil {
return err
}
return workflow.ExecuteActivity(ctx, a.ReleasePayout, p).Get(ctx, nil)
}
type Activities struct {
Outis *outis.Client
Release func(ctx context.Context, to, amount, idempotencyKey string) error
}
func (a *Activities) RequestApproval(ctx context.Context, p Payout) (string, error) {
wf := activity.GetInfo(ctx).WorkflowExecution.ID
op := operation(p, wf)
req, err := a.Outis.Requests.Create(ctx, outis.CreateParams{
Action: op.Action,
Requester: p.Requester,
Params: op.Params,
}, outis.WithIdempotencyKey("temporal-"+wf))
if err != nil {
return "", err
}
return req.ID, nil
}
func (a *Activities) CheckApproval(ctx context.Context, requestID string, p Payout) error {
wf := activity.GetInfo(ctx).WorkflowExecution.ID
_, err := a.Outis.Requests.AssertAuthorized(ctx, requestID, operation(p, wf))
return err
}
func (a *Activities) ReleasePayout(ctx context.Context, p Payout) error {
wf := activity.GetInfo(ctx).WorkflowExecution.ID
return a.Release(ctx, p.To, p.Amount, wf)
}// cmd/payouts/main.go
package main
import (
"errors"
"io"
"log"
"net/http"
"os"
"github.com/outis-auth/outis-go"
"go.temporal.io/api/serviceerror"
"go.temporal.io/sdk/client"
"go.temporal.io/sdk/worker"
"example.com/payments/ledger"
"example.com/payments/payouts"
)
func main() {
tc, err := client.Dial(client.Options{HostPort: os.Getenv("TEMPORAL_ADDRESS")})
if err != nil {
log.Fatal(err)
}
defer tc.Close()
w := worker.New(tc, "payouts", worker.Options{})
w.RegisterWorkflow(payouts.PayoutWorkflow)
w.RegisterActivity(&payouts.Activities{
Outis: outis.New(os.Getenv("OUTIS_API_KEY")),
Release: ledger.New(os.Getenv("LEDGER_TOKEN")).Release,
})
http.HandleFunc("POST /outis/events", bridge(tc, os.Getenv("OUTIS_WEBHOOK_SECRET")))
go func() { log.Fatal(http.ListenAndServe(":8080", nil)) }()
if err := w.Run(worker.InterruptCh()); err != nil {
log.Fatal(err)
}
}
func bridge(tc client.Client, secret string) http.HandlerFunc {
return 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, secret, nil)
if err != nil {
http.Error(w, "bad signature", http.StatusBadRequest)
return
}
wf := ev.Data.Request.Params["workflow_id"]
if wf == "" {
w.WriteHeader(http.StatusOK)
return
}
err = tc.SignalWorkflow(r.Context(), wf, "", "outisDecision", payouts.Decision{
RequestID: ev.Data.Request.ID,
Type: ev.Type,
})
var gone *serviceerror.NotFound
if err != nil && !errors.As(err, &gone) {
http.Error(w, "signal failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
}
// activities.ts
import { Context } from "@temporalio/activity";
import { Outis } from "@outis/sdk";
import { ledger } from "./ledger";
const outis = new Outis({ apiKey: process.env.OUTIS_API_KEY! });
export type Op = { action: string; params: Record<string, string> };
export async function requestApproval(op: Op, requester: string): Promise<string> {
const { workflowId } = Context.current().info.workflowExecution;
const req = await outis.requests.create(
{ action: op.action, requester, params: op.params },
{ idempotencyKey: `temporal-${workflowId}` },
);
return req.id;
}
export async function checkApproval(requestId: string, op: Op): Promise<void> {
await outis.requests.assertAuthorized(requestId, op);
}
export async function releasePayout(params: Record<string, string>): Promise<void> {
const { workflowId } = Context.current().info.workflowExecution;
await ledger.release(params.to, params.amount, { idempotencyKey: workflowId });
}// workflows.ts
import { condition, defineSignal, proxyActivities, setHandler, workflowInfo } from "@temporalio/workflow";
import type * as activities from "./activities";
const { requestApproval, checkApproval, releasePayout } = proxyActivities<typeof activities>({
startToCloseTimeout: "1 minute",
});
export type Decision = { requestId: string; type: string };
export const outisDecision = defineSignal<[Decision]>("outisDecision");
export async function payoutWorkflow(p: { requester: string; amount: string; to: string }): Promise<string> {
let decision: Decision | undefined;
setHandler(outisDecision, (d) => {
decision = d;
});
const op = {
action: "payouts.release",
params: { amount: p.amount, currency: "USD", to: p.to, workflow_id: workflowInfo().workflowId },
};
const requestId = await requestApproval(op, p.requester);
const decided = await condition(() => decision?.requestId === requestId, "7 days");
if (!decided || decision!.type !== "request.authorized") return "not authorized";
await checkApproval(requestId, op);
await releasePayout(op.params);
return "released";
}// worker.ts
import express from "express";
import { Client, Connection, WorkflowNotFoundError } from "@temporalio/client";
import { NativeConnection, Worker } from "@temporalio/worker";
import { verifyWebhook, WebhookVerificationError } from "@outis/sdk";
import * as activities from "./activities";
const address = process.env.TEMPORAL_ADDRESS ?? "localhost:7233";
const temporal = new Client({ connection: await Connection.connect({ address }) });
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;
}
const workflowId = event.data.request.params.workflow_id;
if (workflowId) {
try {
await temporal.workflow
.getHandle(workflowId)
.signal("outisDecision", { requestId: event.data.request.id, type: event.type });
} catch (err) {
if (!(err instanceof WorkflowNotFoundError)) return res.status(500).end();
}
}
res.status(200).end();
});
app.listen(8080);
const worker = await Worker.create({
connection: await NativeConnection.connect({ address }),
taskQueue: "payouts",
workflowsPath: new URL("./workflows.js", import.meta.url).pathname,
activities,
});
await worker.run();
The workflow id rides in the params, so the operators see it and the operation hash covers it. That’s why the check activity rebuilds the same operation. If you’d rather keep it off the box, store your own map from request id to workflow id and have the bridge look it up there.
The idempotency key comes from the workflow id, so a retried activity finds the same request. A repeated delivery signals a workflow that’s already moved on, which does nothing. A 500 from the bridge makes Outis retry the delivery. The timer is your deadline. The request’s own window in Outis may end it first, as request.expired.
Install: go get go.temporal.io/sdk github.com/outis-auth/outis-go, or npm install @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activity @outis/sdk express.
Inngest
Section titled “Inngest”Inngest can take the Outis event with no bridge. Create a webhook in the Inngest dashboard, give it this transform, and pass its URL as the request’s callbackUrl:
function transform(evt, headers = {}, queryParams = {}, raw = "") {
return {
id: evt.id,
name: "outis/request.decided",
data: { type: evt.type, request_id: evt.data.request.id },
};
}
Inngest runs the transform on its side and turns the Outis envelope into an event. id is the Outis event id, so a retried delivery is deduplicated.
import { Inngest } from "inngest";
import { NotAuthorizedError, Outis } from "@outis/sdk";
export const inngest = new Inngest({ id: "payments" });
const outis = new Outis({ apiKey: process.env.OUTIS_API_KEY! });
export const releasePayout = inngest.createFunction(
{ id: "release-payout" },
{ event: "payouts/release.requested" },
async ({ event, step }) => {
const op = {
action: "payouts.release",
params: { amount: event.data.amount, currency: "USD", to: event.data.to },
};
const requestId = await step.run("ask for the keys", async () => {
const req = await outis.requests.create(
{ ...op, requester: event.data.requester, callbackUrl: process.env.INNGEST_OUTIS_WEBHOOK_URL },
{ idempotencyKey: `inngest-${event.id}` },
);
return req.id;
});
await step.waitForEvent("wait for the decision", {
event: "outis/request.decided",
timeout: "7d",
if: `async.data.request_id == "${requestId}"`,
});
const authorized = await step.run("check", async () => {
try {
await outis.requests.assertAuthorized(requestId, op);
return true;
} catch (err) {
if (err instanceof NotAuthorizedError) return false;
throw err;
}
});
if (!authorized) return { released: false };
await step.run("release", () => release(op.params, { idempotencyKey: requestId }));
return { released: true };
},
);
Inngest doesn’t check the Outis signature at its ingress, so the check step treats the event as a wake up and reads the request. That read also covers a decision that landed before the wait began: the wait times out, and the check still finds the request authorized.
Trigger.dev
Section titled “Trigger.dev”A wait token has a URL, and a POST to it completes the token with the JSON body as its output. Outis can post straight to it, so there’s no bridge:
import { task, wait } from "@trigger.dev/sdk";
import { Outis } from "@outis/sdk";
const outis = new Outis({ apiKey: process.env.OUTIS_API_KEY! });
export const releasePayout = task({
id: "release-payout",
run: async (payload: { requester: string; amount: string; to: string }, { ctx }) => {
const op = {
action: "payouts.release",
params: { amount: payload.amount, currency: "USD", to: payload.to },
};
const token = await wait.createToken({ timeout: "7d", idempotencyKey: `outis-${ctx.run.id}` });
const req = await outis.requests.create(
{ ...op, requester: payload.requester, callbackUrl: token.url },
{ idempotencyKey: `trigger-${ctx.run.id}` },
);
await wait.forToken(token);
await outis.requests.assertAuthorized(req.id, op);
await release(op.params, { idempotencyKey: req.id });
},
});
Both idempotency keys come from the run id, so a retried attempt gets the same token and the same request. The token completes on the first event Outis posts, whatever the outcome, and the output is the Outis envelope. Trigger.dev doesn’t pass the headers through, so the signature can’t be checked in the task. assertAuthorized is the check, and it throws unless the request was authorized for exactly this operation.
Cloudflare Workflows
Section titled “Cloudflare Workflows”A Workflow waits with step.waitForEvent. Outis can’t post to an instance directly (the REST route needs a Cloudflare API token), so the Worker that hosts the Workflow is the bridge. It verifies the signature and calls sendEvent.
import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
import { Outis, verifyWebhook } from "@outis/sdk";
type Env = { PAYOUTS: Workflow; OUTIS_API_KEY: string; OUTIS_WEBHOOK_SECRET: string };
type Payout = { requester: string; amount: string; to: string };
type Decision = { requestId: string; type: string };
export class PayoutWorkflow extends WorkflowEntrypoint<Env, Payout> {
async run(event: WorkflowEvent<Payout>, step: WorkflowStep) {
const outis = new Outis({ apiKey: this.env.OUTIS_API_KEY });
const op = {
action: "payouts.release",
params: {
amount: event.payload.amount,
currency: "USD",
to: event.payload.to,
workflow_id: event.instanceId,
},
};
const requestId = await step.do("ask for the keys", async () => {
const req = await outis.requests.create(
{ ...op, requester: event.payload.requester },
{ idempotencyKey: `cf-${event.instanceId}` },
);
return req.id;
});
try {
await step.waitForEvent<Decision>("wait for the decision", { type: "outis-decision", timeout: "7 days" });
} catch {
// No decision inside the timeout. The check below reads the request either way.
}
await step.do("check and release", async () => {
await outis.requests.assertAuthorized(requestId, op);
await release(op.params, { idempotencyKey: requestId });
});
}
}
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const url = new URL(req.url);
if (req.method !== "POST" || url.pathname !== "/outis/events") return new Response(null, { status: 404 });
let event;
try {
event = verifyWebhook(await req.text(), req.headers, env.OUTIS_WEBHOOK_SECRET);
} catch {
return new Response(null, { status: 400 });
}
const instanceId = event.data.request.params.workflow_id;
if (instanceId) {
const instance = await env.PAYOUTS.get(instanceId);
await instance.sendEvent({ type: "outis-decision", payload: { requestId: event.data.request.id, type: event.type } });
}
return new Response(null, { status: 200 });
},
};
The SDK uses node:crypto, so turn on the nodejs_compat compatibility flag. An event type is letters, digits, underscores and hyphens, up to 100 characters. The wait’s timeout runs from 1 second to 365 days and defaults to 24 hours, so set it.
AWS Step Functions
Section titled “AWS Step Functions”A task with .waitForTaskToken pauses until something calls SendTaskSuccess or SendTaskFailure with its token. Those calls are SigV4 signed, so the bridge is a Lambda behind a function URL. The token is too long for a param, so the approval Lambda stores it in DynamoDB under the Outis request id.
{
"StartAt": "Ask for the keys",
"States": {
"Ask for the keys": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken",
"Parameters": {
"FunctionName": "outis-approval",
"Payload": {
"token.$": "$$.Task.Token",
"execution.$": "$$.Execution.Name",
"payout.$": "$.payout"
}
},
"TimeoutSecondsPath": "$.approval_window_seconds",
"ResultPath": "$.approval",
"Next": "Release"
},
"Release": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": { "FunctionName": "release-payout", "Payload.$": "$" },
"End": true
}
}
}
# outis_approval.py: creates the request and parks the task token
import os
import boto3
from outis import Outis
outis = Outis()
tokens = boto3.resource("dynamodb").Table(os.environ["TOKENS_TABLE"])
def handler(event, _context):
p = event["payout"]
req = outis.requests.create(
action="payouts.release",
requester=p["requester"],
params={"amount": p["amount"], "currency": "USD", "to": p["to"]},
idempotency_key=f"sfn-{event['execution']}",
)
tokens.put_item(Item={"request_id": req.id, "task_token": event["token"]})
# outis_bridge.py: the function URL Outis posts to
import base64
import json
import os
import boto3
from outis import WebhookVerificationError, verify_webhook
sfn = boto3.client("stepfunctions")
tokens = boto3.resource("dynamodb").Table(os.environ["TOKENS_TABLE"])
def handler(event, _context):
body = event.get("body") or ""
raw = base64.b64decode(body) if event.get("isBase64Encoded") else body.encode()
try:
ev = verify_webhook(raw, event["headers"], os.environ["OUTIS_WEBHOOK_SECRET"])
except WebhookVerificationError:
return {"statusCode": 400}
item = tokens.get_item(Key={"request_id": ev.request.id}).get("Item")
if item is None:
return {"statusCode": 200}
try:
if ev.type == "request.authorized":
sfn.send_task_success(taskToken=item["task_token"], output=json.dumps({"request_id": ev.request.id}))
else:
sfn.send_task_failure(taskToken=item["task_token"], error=ev.type, cause=ev.request.outcome or "")
except (sfn.exceptions.InvalidToken, sfn.exceptions.TaskDoesNotExist, sfn.exceptions.TaskTimedOut):
pass
return {"statusCode": 200}
# release_payout.py
from outis import Outis
outis = Outis()
def handler(event, _context):
p = event["payout"]
params = {"amount": p["amount"], "currency": "USD", "to": p["to"]}
outis.requests.assert_authorized(event["approval"]["request_id"], action="payouts.release", params=params)
release(params, idempotency_key=event["approval"]["request_id"])
Waiting on a task token works in Standard workflows, not Express. TimeoutSecondsPath reads the window from the execution’s input, and past it the task fails with States.Timeout. A denied, expired or aborted request fails the task with the event type as its error, so add a Catch if you want to handle those. A repeated delivery finds the token already used, and the bridge answers 200 so Outis stops retrying.