Process payment webhooks into an idempotent ledger
Turn an at-least-once provider webhook into one durable application record, one inventory action, and one observable result.
Payment providers retry webhooks. This workflow combines WOML trigger deduplication with a database uniqueness boundary and stable managed-operation identities.
The workflow at a glance
Payment webhook
→ Normalize provider event
→ Insert event with unique provider ID
→ Route event type
payment.succeeded → Start fulfillment
payment.failed → Publish failure
other → Record ignored
→ Return acknowledgementThe workflow
<woml>
<workflow
id="payment-webhook-ledger"
name="Payment webhook ledger"
description="Deduplicate payment events and start the correct backend process."
version="1.0.0"
>
<config concurrency="32" rate-limit="600/1m" timeout="2m" queue="payments" />
<triggers>
<webhook
id="paymentEvent"
path="/webhooks/payments"
method="POST"
auth="bearer"
secret="{{secrets.PAYMENT_WEBHOOK_TOKEN}}"
>
<schema>
{
"type": "object",
"required": ["eventId", "type", "paymentId", "orderId", "amount"],
"properties": {
"eventId": { "type": "string" },
"type": { "type": "string" },
"paymentId": { "type": "string" },
"orderId": { "type": "string" },
"amount": { "type": "number", "minimum": 0 }
},
"additionalProperties": false
}
</schema>
</webhook>
</triggers>
<steps>
<step id="normalizeEvent" name="Normalize payment event">
<script>
return {
eventId: context.payload.eventId,
type: context.payload.type.trim().toLowerCase(),
paymentId: context.payload.paymentId,
orderId: context.payload.orderId,
amount: context.payload.amount,
receivedAt: new Date().toISOString()
};
</script>
</step>
<step id="recordEvent" name="Record provider event" retry="3" retry-backoff="exponential">
<script>
const db = services.db({ driver: "postgres", connection: secrets.POSTGRES_URL });
const result = await db.execute({
text: `
INSERT INTO payment_events
(provider_event_id, type, payment_id, order_id, amount, received_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (provider_event_id) DO NOTHING
`,
values: [
context.steps.normalizeEvent.eventId,
context.steps.normalizeEvent.type,
context.steps.normalizeEvent.paymentId,
context.steps.normalizeEvent.orderId,
context.steps.normalizeEvent.amount,
context.steps.normalizeEvent.receivedAt
]
}, { name: "insert-payment-event" });
return { firstDelivery: result.rowsAffected === 1 };
</script>
</step>
<switch id="routeEvent" name="Route payment event" value="{{context.steps.normalizeEvent.type}}">
<case value="payment.succeeded">
<step id="startFulfillment" name="Start fulfillment">
<script>
if (!context.steps.recordEvent.firstDelivery) {
return { status: "duplicate", orderId: context.steps.normalizeEvent.orderId };
}
const child = await services.workflows.start(
"fulfill-paid-order",
{
orderId: context.steps.normalizeEvent.orderId,
paymentId: context.steps.normalizeEvent.paymentId,
amount: context.steps.normalizeEvent.amount
},
{ name: "start-paid-order-fulfillment" }
);
return { status: "fulfillment-started", childRunId: child.runId };
</script>
</step>
<result value="{{context.steps.startFulfillment}}" />
</case>
<case value="payment.failed">
<step id="publishFailure" name="Publish payment failure">
<script>
const publication = await services.events.emit(
"payment.failed",
{
orderId: context.steps.normalizeEvent.orderId,
paymentId: context.steps.normalizeEvent.paymentId
},
{ name: "publish-payment-failure" }
);
return { status: "failure-published", publicationId: publication.publicationId };
</script>
</step>
<result value="{{context.steps.publishFailure}}" />
</case>
<default>
<step id="ignoreEvent"><script>return { status: "ignored" };</script></step>
<result value="{{context.steps.ignoreEvent}}" />
</default>
</switch>
<step id="acknowledgement" name="Return processing result">
<script>
return {
eventId: context.steps.normalizeEvent.eventId,
firstDelivery: context.steps.recordEvent.firstDelivery,
outcome: context.steps.routeEvent
};
</script>
</step>
</steps>
</workflow>
</woml>The fulfillment target
Save the independently owned target as fulfill-paid-order.woml. It has no trigger because the payment workflow starts it directly:
<woml>
<workflow
id="fulfill-paid-order"
name="Fulfill paid order"
description="Reserve fulfillment work after a confirmed payment."
version="1.0.0"
>
<config concurrency="20" timeout="10m" queue="fulfillment" />
<steps>
<step id="createFulfillment" name="Create fulfillment job" retry="3">
<script>
const response = await services.http.request({
method: "POST",
url: "https://fulfillment.example.com/jobs",
headers: { authorization: `Bearer ${secrets.FULFILLMENT_API_TOKEN}` },
json: {
orderId: context.payload.orderId,
paymentId: context.payload.paymentId,
amount: context.payload.amount
},
idempotency: {
header: "Idempotency-Key",
value: attempt.idempotencyKey
}
}, { name: "create-paid-order-fulfillment" });
return {
orderId: context.payload.orderId,
fulfillmentId: response.data.id,
status: "accepted"
};
</script>
</step>
</steps>
</workflow>
</woml>Configure and run it
woml secrets set PAYMENT_WEBHOOK_TOKEN
woml secrets set POSTGRES_URL
woml secrets set FULFILLMENT_API_TOKEN
woml check payment-webhook-ledger.woml fulfill-paid-order.woml
woml run payment-webhook-ledger.woml fulfill-paid-order.womlCreate a unique constraint on payment_events.provider_event_id. Send the same provider occurrence twice and verify that the second delivery returns firstDelivery: false without starting another fulfillment run.
Why this shows WOML's range
Correct webhook processing is not just receiving HTTP. The example combines schema rejection before run creation, durable occurrence identity, a database uniqueness proof, exact string routing, asynchronous child execution, and event fan-out while preserving one readable business flow.