Integration patterns
Adapters, webhooks, signed tokens, and how to integrate external systems without bypassing the pipeline.
External systems show up in three shapes:
- Outbound calls — your handler needs to invoke a vendor API.
- Inbound webhooks — a vendor wants to tell your system something happened.
- External actor calls — a non-logged-in third party needs to invoke an action via a signed token (e.g. a borrower clicking a magic link).
Each pattern has a place in the pipeline. None of them bypass it.
1. Outbound calls — adapter steps
Outbound vendor calls live in adapters, declared as adapterSteps on the action:
{
actionId: "lending.pull_credit",
...
adapterSteps: [
{
adapterType: "credit_bureau",
operation: "pull_credit",
retryPolicy: { idempotent: true, maxAttempts: 3 },
getInput: (params, handlerResult) => ({
partyId: handlerResult.partyId,
ssn: handlerResult.ssn,
}),
},
],
}The handler runs first and prepares the input. The runtime then calls the registered adapter via executeWithAdapterRetry, which:
- Forces non-idempotent operations to one attempt.
- Caps idempotent retries at 5 with exponential backoff.
- Optionally enforces a circuit breaker.
- Emits
AdapterInvocationStarted/AdapterInvocationSucceeded/AdapterInvocationFailedevents.
Adapters live in @repo/adapters. They implement AdapterImplementation:
interface AdapterImplementation {
adapterType: string;
operation: string;
vendor: string;
idempotent: boolean;
retryPolicy?: AdapterRetryPolicy;
circuitBreaker?: AdapterCircuitBreakerConfig;
execute: AdapterExecutor;
}The vertical's FabricModule registers actions; a separate AdapterRegistry (instantiated by the host app) registers implementations. This separation lets you swap a vendor without touching action logic.
Anti-pattern: calling a vendor from inside the handler
// ❌ Don't
async function handler(ctx, params) {
const result = await fetch("https://vendor.example/credit", { ... });
}This bypasses the adapter retry, circuit breaker, and the AdapterInvocation audit events. Use adapterSteps.
2. Inbound webhooks
A webhook is an event from outside the system. Treat it as one:
- The webhook endpoint authenticates (HMAC, signed JWT, mTLS).
- It records a platform-level
WebhookReceivedevent. - It calls
invokeActionwithactorType: "integration"and the parsed payload as parameters.
The handler receiving a webhook is no different from any other handler: it has policies, a state machine if applicable, and emits domain events. The webhook envelope itself is captured for audit (in WebhookReceived); the domain-meaningful event is what the handler emits.
Pattern: a per-vendor ingest action
{
actionId: "lending.ingest_credit_decision",
namespace: "lending",
schema: creditDecisionWebhookSchema,
policies: ["lending.vendor_signature.v1"],
emitsEvents: ["CreditDecisionReceived"],
idempotent: true, // webhooks may be redelivered
mutatesDomain: true,
...
}idempotent: true is critical for webhook ingest. Vendors retry. Use a vendor-supplied event ID as the idempotency key inside the handler so a duplicate delivery is a no-op.
3. External actor calls — signed tokens
External callers (a borrower clicking an offer link, an insurance applicant uploading a document) authorize themselves with a signed token, not a session.
The pattern:
// 1. Public oRPC procedure — no auth middleware, but takes a token
export const acceptOfferProcedure = publicProcedure
.input(acceptOfferTokenSchema)
.handler(async ({ input, context }) => {
// 2. Verify the signed token BEFORE invokeAction
const tokenPayload = await resolveOfferFromToken(input.token);
if (!tokenPayload) throw new Error("Invalid token");
// 3. Now — and only now — invoke with actorType: external_system
return await invokeAction({
actionId: "lending.accept_offer",
actorType: "external_system",
actorId: tokenPayload.partyId,
parameters: {
offerId: tokenPayload.offerId,
acceptanceSource: "borrower_portal_token",
acceptedByPartyId: tokenPayload.partyId,
},
...
});
});Stamp audit metadata as parameters, not post-update writes
The wrong shape:
// ❌ Don't
await invokeAction({ actionId: "lending.accept_offer", parameters: { offerId } });
await db.offer.update({
where: { id: offerId },
data: { properties: { acceptanceSource } },
});The post-update is a domain mutation outside the runtime. Boundary tests reject it. The right shape: pass acceptanceSource as a parameter, let the handler write it inside the same transaction as the rest of the work.
UI implication
Because invokeAction returns immediately with { status: "pending", actionInvocationId }, the UI shows a "Processing…" interstitial and polls a read endpoint until the offer reaches its terminal state. The borrower portal does this for offer acceptance.
See also
- Action triggers — the three valid entry paths.
- Stage 8 — Adapter steps — retry semantics.
- Audit trail — where webhook and external-actor evidence is read out.