← All examples
BackendWebhookIdempotency

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

Text
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 acknowledgement

The workflow

WOML
<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
<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

Terminal
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.woml

Create 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.