FabricFabricPlatform
Platform referenceDevelopment patterns

Integration patterns

Adapters, webhooks, signed tokens, and how to integrate external systems without bypassing the pipeline.

External systems show up in three shapes:

  1. Outbound calls — your handler needs to invoke a vendor API.
  2. Inbound webhooks — a vendor wants to tell your system something happened.
  3. 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 / AdapterInvocationFailed events.

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:

  1. The webhook endpoint authenticates (HMAC, signed JWT, mTLS).
  2. It records a platform-level WebhookReceived event.
  3. It calls invokeAction with actorType: "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

On this page