Temporal.io + Signbee: Durable Contract Signing Workflows in TypeScript (2026)
Human contract execution takes hours, days, or weeks—breaking stateless serverless timeouts and fragile cron polling jobs. Here is the production blueprint for combining Temporal.io durable execution with Signbee REST APIs using idempotent Activities, webhook Signals, 48-hour condition() timers, and Saga compensations.
Quick Answer (TL;DR)
Temporal.io TypeScript SDK + Signbee Durable Execution Architecture
To orchestrate multi-week contract signing with Temporal.io and Signbee, execute POST https://signb.ee/api/v1/send inside a Temporal Activity (never directly inside the deterministic Workflow V8 sandbox), passing Temporal's workflowId as the Idempotency-Key and inside the contract metadata object. Pause the Workflow using await condition(() => state.status !== 'PENDING', '48 hours') to run durable reminder loops with zero CPU usage. When Signbee fires the document.signed webhook, verify X-Signbee-Signature and call handle.signal(contractSignedSignal, payload) to wake the Workflow and trigger downstream provisioning.
1. The Human-Latency Problem: Why Stateless Serverless & Cron Jobs Fail at Contract Pipelines
Most backend API operations complete in 20 to 300 milliseconds: charging a credit card token, inserting a PostgreSQL row, or dispatching an email. Electronic signature workflows are fundamentally different because they sit at the intersection of machine speed and human latency.
Consider a real-world enterprise B2B onboarding or fintech loan origination pipeline:
- Reserve credit line or inventory capacity in your core ledger.
- Generate a customized Master Services Agreement (MSA) and dispatch it via Signbee (
POST /api/v1/send). - Wait up to 14 days for the client's CFO to review and sign, sending polite Slack/email nudges at 48 hours and 96 hours if unsigned.
- Once signed, verify the cryptographic SHA-256 certificate hash, provision the production tenant, and release the credit hold—or if declined/expired, execute a compensating rollback to release the reserved inventory.
When engineering teams try to build this multi-week state machine using stateless serverless functions, Redis queues, and hourly cron jobs scanning a contracts table, business logic fragments across 6 different files. Race conditions emerge when a cron reminder fires at the exact millisecond a webhook arrives, retry logic accidentally sends three duplicate contracts to the client's CEO, and failed post-signature provisioning leaves the customer with a signed legal contract but no active account.
Pairing Temporal.io with Signbee solves this architectural split completely. Your entire multi-week contract lifecycle is expressed as a single, sequential TypeScript function whose execution state is durably persisted by Temporal. To see why engineering teams pair Temporal with lightweight REST APIs rather than legacy monolithic platforms, read our guide to the best e-signature APIs for developers in 2026 and DocuSign API pricing explained.
| Failure Mode / Requirement | Temporal.io + Signbee REST API | AWS Step Functions (ASL JSON) | Stateless Webhook + Cron Polling |
|---|---|---|---|
| Workflow Definition Format | Pure TypeScript (Type-safe loops & branches) | Verbose YAML/JSON state machine DSL | Scattered across cron jobs & DB flags |
| 48-Hour Reminder + Webhook Race | Atomic condition(fn, '48h') primitive | Requires Task Token + Choice state | High race condition risk without DB locks |
| Duplicate Envelope Prevention | Deterministic Activity Idempotency-Key | Manual Context ID wiring | Frequently sends duplicate emails on retry |
| Mid-Flight Contract Redlines | Native defineSignal / defineUpdate | Must abort & restart state machine | Manual status overrides in SQL |
| Saga Rollback on Decline/Expiry | Standard try / catch / finally array | Complex Catch fallback states | Orphaned holds & manual ops cleanup |
2. Golden Rule of Temporal: Determinism in Workflows vs. Side Effects in Activities
In the Temporal TypeScript SDK (@temporalio/workflow and @temporalio/activity), Workflow code runs inside a deterministic, isolated V8 VM without access to Node.js fetch, fs, or process.env. Why? Because when a Workflow wakes up 6 days later after receiving a Signbee webhook Signal, the Temporal Worker replays your Workflow function from line 1 against the persisted Event History to reconstruct local variables in memory.
Therefore, every external side effect—calling Signbee's POST /api/v1/send, sending a Slack reminder, or provisioning a customer database—must live inside a Temporal Activity. During replay, Temporal does not re-execute completed Activities; it immediately substitutes the recorded return value (documentId, signingUrl, verifyUrl) from the Event History in sub-millisecond time.
3. Implementing Idempotent Signbee Activities (`activities.ts`)
Let's write our Temporal Activities in TypeScript. Notice how we use Context.current().info from @temporalio/activity to extract the workflowExecution.workflowId and activityId.
Passing `${workflowId}-${activityId}` in Signbee's Idempotency-Key header and embedding workflow_id inside the metadata payload accomplishes two critical distributed-systems guarantees:
- Zero Duplicate Emails on Retry: If your Worker experiences a transient TCP reset right after Signbee accepts the
POST /api/v1/sendrequest, Temporal automatically retries the Activity 2 seconds later. Signbee recognizes theIdempotency-Keyand returns the exact same envelope without sending a second email to the recipient. - Stateless Webhook-to-Workflow Routing: Because Signbee echoes your
metadata.temporal_workflow_idfield back in every webhook event, your webhook endpoint can signal the exact running Temporal Workflow without even needing a database lookup table!
import { Context, ApplicationFailure } from "@temporalio/activity";
export interface DispatchContractInput {
customerId: string;
title: string;
markdown: string;
senderName: string;
senderEmail: string;
recipientName: string;
recipientEmail: string;
expiresInDays: number;
revisionNumber: number;
}
export interface DispatchContractResult {
documentId: string;
status: string;
signingUrl: string;
verifyUrl: string;
expiresAt: string;
}
export async function dispatchSignbeeContractActivity(
input: DispatchContractInput
): Promise<DispatchContractResult> {
const ctx = Context.current();
const { workflowId } = ctx.info.workflowExecution;
const idempotencyKey = `${workflowId}-rev-${input.revisionNumber}`;
const apiKey = process.env.SIGNBEE_API_KEY;
const webhookUrl = process.env.SIGNBEE_WEBHOOK_URL;
if (!apiKey || !webhookUrl) {
throw ApplicationFailure.nonRetryable(
"Missing SIGNBEE_API_KEY or SIGNBEE_WEBHOOK_URL in Worker environment",
"ConfigurationError"
);
}
const response = await fetch("https://signb.ee/api/v1/send", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({
title: input.title,
markdown: input.markdown,
sender_name: input.senderName,
sender_email: input.senderEmail,
recipient_name: input.recipientName,
recipient_email: input.recipientEmail,
expires_in_days: input.expiresInDays,
webhook_url: webhookUrl,
metadata: {
temporal_workflow_id: workflowId,
customer_id: input.customerId,
revision: String(input.revisionNumber),
},
}),
});
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
const errorBody = await response.text();
// 4xx validation errors should not be retried by Temporal
throw ApplicationFailure.nonRetryable(
`Signbee 4xx rejection (${response.status}): ${errorBody}`,
"SignbeeValidationError"
);
}
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Transient Signbee HTTP ${response.status}: ${errorBody}`);
}
const data = (await response.json()) as {
id: string;
status: string;
signing_url: string;
verify_url: string;
expires_at: string;
};
return {
documentId: data.id,
status: data.status,
signingUrl: data.signing_url,
verifyUrl: data.verify_url,
expiresAt: data.expires_at,
};
}
export async function sendSigningReminderActivity(params: {
recipientEmail: string;
recipientName: string;
signingUrl: string;
reminderAttempt: number;
}): Promise<void> {
Context.current().log.info("Sending contract reminder nudge", params);
// Dispatch reminder via Resend, Slack, or WhatsApp
}
export async function provisionEnterpriseTenantActivity(params: {
customerId: string;
signbeeDocumentId: string;
sha256Hash: string;
}): Promise<{ tenantId: string }> {
Context.current().log.info("Provisioning enterprise account post-signature", params);
return { tenantId: `tenant_${params.customerId}` };
}
export async function releaseReservedCreditHoldActivity(params: {
customerId: string;
reason: string;
}): Promise<void> {
Context.current().log.warn("Executing Saga compensation: releasing credit hold", params);
}4. Writing the Durable Contract Workflow (`workflows.ts`) with `defineSignal` and `condition()`
Now let's write the deterministic Temporal Workflow in src/temporal/workflows/contract-signing.workflow.ts. This single 95-line TypeScript function replaces hundreds of lines of cron schedulers, state machine tables, and dead-letter queue consumers.
Look closely at how await condition(() => state.status !== "PENDING", "48 hours") works inside the for loop:
- If the counterparty signs (or declines) the contract within 48 hours, the webhook handler delivers
contractWebhookSignal, mutatingstate.status.condition()immediately resolves totrue, cancels the remaining timer, and exits the reminder loop. - If 48 hours elapse with no signature,
condition()resolves tofalse. The loop executessendSigningReminderActivityand sleeps for the next 48-hour window—up to 3 cycles (6 days total). - If the contract is declined or all reminder cycles time out, the Workflow runs the registered Saga compensation (
releaseReservedCreditHoldActivity) to cleanly roll back any pre-allocated resources.
import {
proxyActivities,
defineSignal,
defineQuery,
setHandler,
condition,
} from "@temporalio/workflow";
import type * as activities from "../activities/signbee-activities";
const {
dispatchSignbeeContractActivity,
sendSigningReminderActivity,
provisionEnterpriseTenantActivity,
releaseReservedCreditHoldActivity,
} = proxyActivities<typeof activities>({
startToCloseTimeout: "30 seconds",
retry: {
initialInterval: "2 seconds",
backoffCoefficient: 2.0,
maximumInterval: "30 seconds",
maximumAttempts: 5,
},
});
export interface SignbeeWebhookSignalPayload {
documentId: string;
event: "document.signed" | "document.declined" | "document.expired" | "document.viewed";
sha256Hash?: string;
signedPdfUrl?: string;
verifyUrl?: string;
signedAt?: string;
}
export const contractWebhookSignal = defineSignal<[SignbeeWebhookSignalPayload]>(
"signbeeContractWebhook"
);
export const contractStateQuery = defineQuery<{
status: string;
documentId: string | null;
signingUrl: string | null;
remindersSent: number;
sha256Hash: string | null;
}>("getContractWorkflowState");
export async function enterpriseContractOnboardingWorkflow(input: {
customerId: string;
title: string;
markdown: string;
senderName: string;
senderEmail: string;
recipientName: string;
recipientEmail: string;
}): Promise<{ status: string; tenantId?: string; sha256Hash?: string }> {
const state = {
status: "PENDING" as "PENDING" | "SIGNED" | "DECLINED" | "EXPIRED",
documentId: null as string | null,
signingUrl: null as string | null,
remindersSent: 0,
sha256Hash: null as string | null,
};
setHandler(contractStateQuery, () => state);
setHandler(contractWebhookSignal, (payload) => {
// Ignore stale signals from older contract revisions
if (state.documentId && payload.documentId !== state.documentId) {
return;
}
if (payload.event === "document.signed") {
state.status = "SIGNED";
state.sha256Hash = payload.sha256Hash ?? null;
} else if (payload.event === "document.declined") {
state.status = "DECLINED";
} else if (payload.event === "document.expired") {
state.status = "EXPIRED";
}
});
// Step 1: Dispatch contract via Signbee REST API Activity
const dispatchResult = await dispatchSignbeeContractActivity({
...input,
expiresInDays: 7,
revisionNumber: 1,
});
state.documentId = dispatchResult.documentId;
state.signingUrl = dispatchResult.signingUrl;
// Step 2: Durable human wait loop with 48-hour reminders (max 3 reminders = 6 days)
const maxReminderCycles = 3;
for (let cycle = 1; cycle <= maxReminderCycles; cycle++) {
const resolvedBeforeTimeout = await condition(
() => state.status !== "PENDING",
"48 hours"
);
if (resolvedBeforeTimeout) {
break;
}
// Still PENDING after 48 hours -> trigger reminder Activity
await sendSigningReminderActivity({
recipientEmail: input.recipientEmail,
recipientName: input.recipientName,
signingUrl: dispatchResult.signingUrl,
reminderAttempt: cycle,
});
state.remindersSent = cycle;
}
// Step 3: Evaluate outcome or run Saga compensation
if (state.status !== "SIGNED" || !state.sha256Hash) {
await releaseReservedCreditHoldActivity({
customerId: input.customerId,
reason: `Contract ended in status: ${state.status}`,
});
return { status: state.status };
}
// Step 4: Contract cryptographically signed -> provision tenant
const { tenantId } = await provisionEnterpriseTenantActivity({
customerId: input.customerId,
signbeeDocumentId: dispatchResult.documentId,
sha256Hash: state.sha256Hash,
});
return {
status: "SIGNED",
tenantId,
sha256Hash: state.sha256Hash,
};
}Notice how we also registered contractStateQuery via defineQuery. Your Next.js frontend or internal operations dashboard can query the live state of any in-flight contract (status, signingUrl, remindersSent, sha256Hash) directly from Temporal at any moment without maintaining a separate read-model cache. If you are designing an embedded customer-facing portal, pair this with our white-label e-signature API guide or compare self-hosted alternatives in our open-source e-signature API comparison.
5. Bridging Signbee Webhooks to Temporal Signals (`route.ts`)
The final piece of the architecture is the HTTP webhook bridge that receives Signbee's POST callback, verifies the X-Signbee-Signature HMAC-SHA256 digest in constant time using crypto.timingSafeEqual, extracts metadata.temporal_workflow_id, and signals the Temporal cluster via @temporalio/client.
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import { Connection, Client } from "@temporalio/client";
import { contractWebhookSignal } from "@/temporal/workflows/contract-signing.workflow";
function verifySignbeeSignature(
rawBody: string,
signatureHeader: string | null,
secret: string
): boolean {
if (!signatureHeader) return false;
const cleanHex = signatureHeader.replace(/^sha256=/, "").trim();
const expectedHex = crypto
.createHmac("sha256", secret)
.update(rawBody, "utf8")
.digest("hex");
const a = Buffer.from(cleanHex, "utf8");
const b = Buffer.from(expectedHex, "utf8");
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
let cachedTemporalClient: Client | null = null;
async function getTemporalClient(): Promise<Client> {
if (cachedTemporalClient) return cachedTemporalClient;
const connection = await Connection.connect({
address: process.env.TEMPORAL_ADDRESS ?? "localhost:7233",
});
cachedTemporalClient = new Client({
connection,
namespace: process.env.TEMPORAL_NAMESPACE ?? "default",
});
return cachedTemporalClient;
}
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const signature = req.headers.get("x-signbee-signature");
const secret = process.env.SIGNBEE_WEBHOOK_SECRET ?? "";
if (!verifySignbeeSignature(rawBody, signature, secret)) {
return NextResponse.json({ error: "Invalid HMAC signature" }, { status: 401 });
}
const body = JSON.parse(rawBody) as {
event: "document.signed" | "document.declined" | "document.expired" | "document.viewed";
document_id: string;
sha256_hash?: string;
signed_pdf_url?: string;
verify_url?: string;
signed_at?: string;
metadata?: { temporal_workflow_id?: string };
};
const workflowId = body.metadata?.temporal_workflow_id;
if (!workflowId) {
return NextResponse.json({ received: true, skipped: "No temporal_workflow_id" });
}
const temporal = await getTemporalClient();
const handle = temporal.workflow.getHandle(workflowId);
await handle.signal(contractWebhookSignal, {
documentId: body.document_id,
event: body.event,
sha256Hash: body.sha256_hash,
signedPdfUrl: body.signed_pdf_url,
verifyUrl: body.verify_url,
signedAt: body.signed_at,
});
return NextResponse.json({ received: true, signaledWorkflowId: workflowId });
}6. Unit Economics: Temporal Cloud + Signbee vs. Legacy Enterprise CLM Suites
Many organizations mistakenly assume they need a $50,000/year enterprise Contract Lifecycle Management (CLM) suite just to get automated multi-day reminders, sequential approvals, and post-signature ERP provisioning. Let's compare the actual monthly infrastructure and API cost for 2,500 executed contracts per month:
| Cost Component (2,500 Contracts / Mo) | Temporal.io + Signbee Pro | AWS Step Functions + Legacy API | Enterprise CLM + Legacy E-Sign |
|---|---|---|---|
| E-Signature API Envelopes (2,500) | $20 / mo (Unlimited Pro Plan) | $10,000 – $14,500 / mo ($4–$5.80/env) | $12,500+ / mo + seat licenses |
| Orchestration State Transitions (~15k) | < $1.00 / mo (Temporal Actions) | ~$0.40 / mo (Standard Workflows) | Bundled in $50k/yr CLM contract |
| Custom Saga & DB Provisioning Code | Full TypeScript codebase control | Lambda + IAM role sprawl | Proprietary low-code connector fees |
| Total Monthly Run Rate | ~$21 / month | $10,000+ / month | $16,500+ / month |
For a detailed breakdown of how flat-rate API pricing eliminates per-envelope surcharges across every volume tier, check out our guide to affordable e-signature API integration, our comprehensive e-signature API pricing comparison (2026), and how to prototype locally with our free e-signature API.
7. Testing Multi-Week Contract Timers in 50 Milliseconds with `@temporalio/testing`
How do you verify in CI/CD that your 48-hour reminder loop and 6-day expiration Saga rollback actually work without waiting 6 real calendar days? Temporal's TestWorkflowEnvironment.createTimeSkipping() from @temporalio/testing automatically fast-forwards virtual cluster time whenever all running Workflows are blocked on a timer or condition().
In your Vitest or Jest suite, you can mock dispatchSignbeeContractActivity, start enterpriseContractOnboardingWorkflow, let the time-skipping server automatically advance through all three 48-hour windows in under 50 milliseconds of wall-clock time, and assert that sendSigningReminderActivity was called exactly 3 times before releaseReservedCreditHoldActivity executed.
8. Frequently Asked Questions: Temporal.io & Signbee Durable Workflows
Why can't I call Signbee's POST /api/v1/send directly inside a Temporal Workflow function?
Temporal Workflows execute inside a deterministic V8 isolate sandbox so the Temporal Worker can replay the Event History from the beginning after a pod restart or worker crash. Performing non-deterministic network I/O (such as fetch('https://signb.ee/api/v1/send')), reading process.env, or generating random UUIDs directly inside a Workflow function would produce different results during replay and trigger a NonDeterministicWorkflowError. Instead, always place the Signbee HTTP call inside a Temporal Activity and invoke it from the Workflow via proxyActivities.
How do I prevent duplicate Signbee contract emails when a Temporal Activity retries on network timeout?
Temporal's default Activity retry policy automatically retries failed or timed-out Activities with exponential backoff. To guarantee that retrying dispatchSignbeeContractActivity never creates or emails a duplicate envelope, derive a deterministic Idempotency-Key from the Temporal Activity execution context—specifically combining workflowExecution.workflowId and the contract revision number—and pass it in the Idempotency-Key HTTP header of POST /api/v1/send. Signbee deduplicates any retry carrying the same idempotency key and returns the original document_id and signing_url.
How does a Signbee webhook resume a paused Temporal Workflow using Signals?
After dispatching the contract via an Activity, the Temporal Workflow pauses execution without consuming CPU or worker threads by calling await condition(() => state.status !== 'PENDING', '48 hours'). When the counterparty signs or declines the document in Signbee, Signbee sends a signed POST webhook to your API server. Your webhook endpoint verifies the X-Signbee-Signature HMAC-SHA256 header, looks up the Workflow handle via temporalClient.workflow.getHandle(workflowId), and calls await handle.signal(contractWebhookSignal, webhookPayload), immediately waking the Workflow.
How do I implement multi-day reminder loops and automatic contract escalation in Temporal?
In the Temporal TypeScript SDK, await condition(predicate, timeout) returns true if the Signal satisfied the predicate before the timer expired, or false if the timer timed out first. By wrapping await condition(() => state.status !== 'PENDING', '48 hours') inside a bounded for loop (for example, up to 3 reminder cycles over 6 days), your Workflow can execute a sendSigningReminderActivity every 48 hours if the contract remains unsigned, and escalate to an account executive or trigger a Saga compensation step if all cycles expire.
What happens if my Temporal Worker pods restart or redeploy while waiting 14 days for a customer signature?
Nothing is lost. When a Temporal Workflow awaits condition() or sleep('14 days'), the Temporal Server persists the timer and workflow state in its durable history store (PostgreSQL, MySQL, or Cassandra), and the Worker can evict the workflow from memory. You can deploy 50 new container versions or scale your Worker fleet down and up during those two weeks; the instant the Signbee webhook delivers the Signal or the 48-hour timer fires, any available Worker replays the cached Activity results in milliseconds and resumes execution at the exact line of code.
How do I handle contract redlines or amended terms mid-flight using Temporal Signals or Updates?
If a customer requests a pricing or scope revision before signing Party B's link, your CRM can send an amendContractSignal (or synchronous Temporal Update) containing the revised markdown terms to the running Workflow. The Workflow catches the amendment flag inside its condition() predicate, dispatches a fresh Signbee agreement via dispatchSignbeeContractActivity with an incremented revisionNumber idempotency key, updates its tracked state.documentId so stale webhooks from the superseded draft are ignored, and resets the reminder timer.
Build Fault-Tolerant Contract Pipelines with Temporal & Signbee
Connect your Temporal Activities to Signbee in minutes. Start free with 5 documents/month or upgrade to $20/mo Pro for unlimited envelopes.