Validates that duplicate requests with the same key return cached responses instead of creating duplicates.
Implementation
import { EventEmitter } from 'events';
// Domain types
type SubscriptionStatus = 'trialing' | 'active' | 'canceled' | 'paused';
type CustomerRecord = { id: string; email: string; subscriptionId?: string };
type SubscriptionRecord = { id: string; customerId: string; status: SubscriptionStatus; plan: string };
type WebhookPayload = { event: string; data: Record<string, unknown>; timestamp: number };
// State registry with transition guards
class StateRegistry {
private customers = new Map<string, CustomerRecord>();
private subscriptions = new Map<string, SubscriptionRecord>();
private idempotencyKeys = new Map<string, unknown>();
getCustomer(id: string): CustomerRecord | undefined {
return this.customers.get(id);
}
createCustomer(id: string, email: string, idempotencyKey?: string): CustomerRecord {
if (idempotencyKey && this.idempotencyKeys.has(idempotencyKey)) {
return this.idempotencyKeys.get(idempotencyKey) as CustomerRecord;
}
const record: CustomerRecord = { id, email };
this.customers.set(id, record);
if (idempotencyKey) this.idempotencyKeys.set(idempotencyKey, record);
return record;
}
attachSubscription(subId: string, customerId: string, plan: string, idempotencyKey?: string): SubscriptionRecord {
if (idempotencyKey && this.idempotencyKeys.has(idempotencyKey)) {
return this.idempotencyKeys.get(idempotencyKey) as SubscriptionRecord;
}
const customer = this.customers.get(customerId);
if (!customer) throw new Error(`Customer ${customerId} not found`);
const record: SubscriptionRecord = { id: subId, customerId, status: 'trialing', plan };
this.subscriptions.set(subId, record);
customer.subscriptionId = subId;
if (idempotencyKey) this.idempotencyKeys.set(idempotencyKey, record);
return record;
}
updateSubscriptionStatus(subId: string, newStatus: SubscriptionStatus): SubscriptionRecord {
const sub = this.subscriptions.get(subId);
if (!sub) throw new Error(`Subscription ${subId} not found`);
const validTransitions: Record<SubscriptionStatus, SubscriptionStatus[]> = {
trialing: ['active', 'canceled'],
active: ['paused', 'canceled'],
paused: ['active', 'canceled'],
canceled: []
};
if (!validTransitions[sub.status].includes(newStatus)) {
throw new Error(`Invalid transition: ${sub.status} β ${newStatus}`);
}
sub.status = newStatus;
return sub;
}
reset(): void {
this.customers.clear();
this.subscriptions.clear();
this.idempotencyKeys.clear();
}
}
// Webhook simulator with retry emulation
class WebhookSimulator extends EventEmitter {
private deliveryQueue: Array<{ payload: WebhookPayload; attempts: number }> = [];
async dispatch(event: string, data: Record<string, unknown>): Promise<void> {
const payload: WebhookPayload = {
event,
data,
timestamp: Date.now()
};
this.deliveryQueue.push({ payload, attempts: 0 });
await this.processQueue();
}
private async processQueue(): Promise<void> {
while (this.deliveryQueue.length > 0) {
const item = this.deliveryQueue.shift()!;
item.attempts++;
// Simulate network latency
await new Promise(res => setTimeout(res, 150 + Math.random() * 200));
// Emit event to consumer
this.emit('webhook.received', item.payload);
// Simulate retry on failure (80% success rate for demo)
if (Math.random() > 0.8 && item.attempts < 3) {
this.deliveryQueue.push(item);
}
}
}
}
// Workflow orchestrator
class WorkflowOrchestrator {
constructor(
private registry: StateRegistry,
private simulator: WebhookSimulator
) {}
async runBillingLifecycle(customerId: string, subId: string, plan: string): Promise<WebhookPayload[]> {
const receivedWebhooks: WebhookPayload[] = [];
this.simulator.on('webhook.received', (payload: WebhookPayload) => {
receivedWebhooks.push(payload);
});
// Step 1: Create customer
const customer = this.registry.createCustomer(customerId, 'integration@test.dev', 'idemp-cust-01');
console.log(`[WORKFLOW] Customer created: ${customer.id}`);
// Step 2: Attach subscription
const subscription = this.registry.attachSubscription(subId, customerId, plan, 'idemp-sub-01');
console.log(`[WORKFLOW] Subscription attached: ${subscription.id} (${subscription.status})`);
// Step 3: Simulate webhook for creation
await this.simulator.dispatch('subscription.created', subscription);
// Step 4: Transition to active
const updated = this.registry.updateSubscriptionStatus(subId, 'active');
console.log(`[WORKFLOW] Status updated: ${updated.status}`);
// Step 5: Simulate webhook for activation
await this.simulator.dispatch('subscription.activated', updated);
// Step 6: Cancel subscription
const canceled = this.registry.updateSubscriptionStatus(subId, 'canceled');
console.log(`[WORKFLOW] Subscription canceled: ${canceled.id}`);
// Step 7: Simulate cancellation webhook
await this.simulator.dispatch('subscription.canceled', canceled);
return receivedWebhooks;
}
}
// Usage example
async function main() {
const registry = new StateRegistry();
const simulator = new WebhookSimulator();
const orchestrator = new WorkflowOrchestrator(registry, simulator);
try {
const webhooks = await orchestrator.runBillingLifecycle('cust_8821', 'sub_9910', 'pro_monthly');
console.log(`\n[RESULT] Received ${webhooks.length} webhook events:`);
webhooks.forEach(w => console.log(` β ${w.event} | ${JSON.stringify(w.data)}`));
} catch (err) {
console.error('[WORKFLOW FAILED]', err);
} finally {
registry.reset();
}
}
main();
Why This Architecture Works
- State isolation per run prevents test pollution. Each workflow execution starts clean and ends deterministically.
- Explicit transition guards catch logical errors early. Attempting to cancel a
canceled subscription throws immediately, mirroring production validation.
- Webhook simulation with retry logic exposes race conditions and idempotency gaps that static mocks hide.
- Idempotency enforcement ensures the sandbox behaves like a production API that respects
Idempotency-Key headers, a common source of duplicate billing records.
Pitfall Guide
1. Synchronous Webhook Mocking
Explanation: Returning webhook payloads immediately after a REST call hides asynchronous timing issues. Production webhooks arrive with variable latency, and consumers often process them concurrently with API responses.
Fix: Introduce configurable delay ranges and process webhooks on a separate event loop. Validate that consumer handlers are idempotent and safe for out-of-order delivery.
2. Ignoring Idempotency Validation
Explanation: Many sandboxes accept duplicate POST requests and create duplicate resources. Real APIs reject or deduplicate based on idempotency keys.
Fix: Store idempotency keys in the state registry. Return cached responses for repeated keys and reject mismatched payloads with 409 Conflict.
3. Static Response Caching
Explanation: Returning hardcoded JSON for every GET /subscriptions/{id} breaks stateful workflows. The response must reflect the current state after mutations.
Fix: Bind response generation to the live state registry. Serialize records dynamically rather than serving static fixtures.
4. Missing Transition Guards
Explanation: Allowing invalid state jumps (e.g., trialing β canceled without passing through active) masks business logic violations.
Fix: Implement a state machine with explicit allowed transitions. Reject invalid mutations with descriptive error codes and payload hints.
5. Overlooking Retry Semantics
Explanation: Webhook providers retry failed deliveries with exponential backoff. Sandboxes that deliver exactly once fail to surface duplicate-processing bugs.
Fix: Simulate retry windows. Track delivery attempts and emit duplicate events. Ensure consumer handlers use idempotency tokens or upsert logic.
6. Hardcoding Tenant Context
Explanation: Single-tenant sandboxes break when consumers test multi-workspace routing, RBAC boundaries, or cross-tenant isolation.
Fix: Parameterize workspace/tenant IDs in workflow definitions. Validate that requests scoped to one tenant cannot access another's resources.
7. Skipping Deterministic Teardown
Explanation: State leaking between test runs causes flaky integrations. Subsequent workflows inherit mutated state from previous executions.
Fix: Implement a reset() method that clears all registries, queues, and caches. Run teardown automatically after each workflow completion or test suite.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Early integration prototyping | Stateful Workflow Sandbox | Fast iteration, catches lifecycle bugs before production, no infra overhead | Low (dev time only) |
| Pre-launch validation | Full Staging Environment | Mirrors production topology, validates scaling and auth boundaries | Medium (infra + maintenance) |
| CI/CD pipeline gating | Static Mock + Workflow Validator | Deterministic, fast, integrates with existing test runners | Low (CI compute) |
| Multi-tenant SaaS rollout | Stateful Sandbox + Tenant Isolation Tests | Validates cross-tenant routing, RBAC, and data partitioning | Medium (test complexity) |
| Legacy API with no OpenAPI | Contract-First Workflow Definition | Establishes expected state transitions before implementation | Low (documentation effort) |
Configuration Template
# workflow-config.yaml
workflow:
name: billing_lifecycle
version: 1.0
steps:
- id: create_customer
method: POST
path: /customers
idempotency_key: "cust_{{timestamp}}"
payload:
email: "integration@acme.dev"
metadata:
source: "sandbox_test"
- id: attach_subscription
method: POST
path: /subscriptions
idempotency_key: "sub_{{timestamp}}"
payload:
customer_id: "{{create_customer.id}}"
plan: "pro_monthly"
trial_days: 14
- id: verify_state
method: GET
path: "/subscriptions/{{attach_subscription.id}}"
assertions:
- field: "status"
operator: "eq"
value: "trialing"
- id: cancel_subscription
method: PATCH
path: "/subscriptions/{{attach_subscription.id}}"
payload:
status: "canceled"
webhooks:
- event: "subscription.created"
delay_ms: [100, 300]
retry_policy:
max_attempts: 3
backoff: "exponential"
- event: "subscription.canceled"
delay_ms: [50, 150]
retry_policy:
max_attempts: 2
backoff: "linear"
teardown:
reset_state: true
clear_queues: true
Quick Start Guide
- Initialize the sandbox runtime: Install the workflow orchestrator package and import the
StateRegistry and WebhookSimulator classes. Configure the state store with your domain types.
- Define your critical workflow: Map the sequence of API calls, state transitions, and expected webhook events. Use the YAML template or programmatic builder to declare steps and assertions.
- Execute the workflow: Run the orchestrator in a test environment. Monitor console output or attach a test runner to validate state mutations and webhook payloads.
- Validate and iterate: Check that all assertions pass, webhooks arrive within expected latency windows, and idempotency keys prevent duplicates. Adjust transition guards or retry policies based on observed behavior.
- Integrate into CI: Add the workflow execution as a pre-merge check. Fail builds if state transitions violate business rules or webhook payloads drift from the contract.