An orange notification envelope connected to receipt records with a retry symbol.

Nov 3, 2026

Share

Category /

other

13 min read

GOAT Network

Payment Webhooks to Order Fulfillment: A GOAT Flow Example

Connect GOAT Flow webhooks to durable fulfillment with raw-body verification, purchase binding, SQLite recovery, and a locally tested SDK example.

scroll

Table of contents

GOAT Flow Webhooks to Order Fulfillment: A Locally Tested Example

A payment notification can arrive twice, arrive after a status poll, or disappear from your receiver's connection just after its database commit. Meanwhile, a fulfillment worker can deliver a report and crash before recording that delivery. Connecting payment webhooks to order fulfillment means deciding who retains the work through each of those interruptions.

This example connects GOAT Flow's documented signature and order-status contracts to a merchant-owned receipt queue, fulfillment claim and outbox. It includes a runnable local fixture using the actual server SDK 0.4.0 and persistent SQLite files. Its webhook payload and transfer evidence are synthetic; it neither executes a live payment nor supplies a production event parser. The useful result is a testable handoff with explicit authorization and recovery boundaries.

Anchor the Example to a Saved Purchase

Start with an existing merchant purchase for one report, not a new payment request. The application has already saved the GOAT order identifier returned during creation, its own purchase reference and the expected payment terms. If creation had an unknown outcome, the next action would be reconciliation of that attempt, not automatically creating another payable order.

The fixture seeds a DIRECT Crypto order and uses three separate identities: the local purchase, its payment attempt and the fulfillment operation. An attempt can change without making the customer entitled to a second copy of the same operation. Conversely, a later refund or dispute must remain recordable even after delivery; it cannot disappear behind one all-events-processed flag.

Persist the environment and merchant alongside that mapping. Save the expected integer base-unit amount, chain, token contract, payer and authoritative receiving address with the purchase terms. A later notification is a reason to inspect this saved obligation, not an opportunity to replace its price with whichever amount happens to appear in an incoming body.

The operation key in this application is a tuple of environment, merchant, local purchase and operation revision. The revision identifies a particular deliverable, such as the first report delivery. It is not a GOAT event ID. Changing it is a business decision to authorize different work, not a convenient way to make a failed retry pass.

Two adapters make the example deliberately local. extractFixtureOrder accepts only the fixture's use of the illustrated order.invoiced envelope and payload.order_id. GOAT's minimal illustration is not a complete event schema: payloads vary by event and payment method. The adapter holds other shapes rather than guessing which field identifies the purchase. A real integration needs its enabled event's verified extraction contract before accepting live traffic.

simulatedTransferEvidence returns seeded recipient and transfer terms for testing comparisons. It does not observe a blockchain, inspect a real transfer or establish a cryptographic recipient attestation. That qualification applies wherever the adapter is called, not just to its definition. Production authorization must replace it with the appropriate authoritative evidence.

For a live receiver, account enablement, event subscriptions and environment configuration are separate prerequisites. The Merchant Portal webhook setup requires a public HTTPS endpoint; the local loopback listener below is only a test harness. Test and production credentials must not be mixed, and fixture asset values do not establish supported chains, tokens, prices or merchant eligibility.

Verify Incoming Bytes; Use SDK Signing Only for API Requests

Incoming webhook authentication and outgoing merchant API authentication use different contracts. For a webhook, verify X-Webhook-Signature with that endpoint's Webhook Secret. The signature is sha256= followed by the lowercase hexadecimal HMAC-SHA256 digest of the exact request body bytes. Do not substitute the API Secret, parse and reserialize the JSON, or sign a sorted field list.

The application-owned verifier used by the fixture is small enough to inspect completely:

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyRaw(raw, signature, secret) {
  if (!secret || typeof signature !== 'string' ||
      !/^sha256=[0-9a-f]{64}$/.test(signature)) return false;
  const expected = createHmac('sha256', secret).update(raw).digest();
  return timingSafeEqual(Buffer.from(signature.slice(7), 'hex'), expected);
}
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyRaw(raw, signature, secret) {
  if (!secret || typeof signature !== 'string' ||
      !/^sha256=[0-9a-f]{64}$/.test(signature)) return false;
  const expected = createHmac('sha256', secret).update(raw).digest();
  return timingSafeEqual(Buffer.from(signature.slice(7), 'hex'), expected);
}
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyRaw(raw, signature, secret) {
  if (!secret || typeof signature !== 'string' ||
      !/^sha256=[0-9a-f]{64}$/.test(signature)) return false;
  const expected = createHmac('sha256', secret).update(raw).digest();
  return timingSafeEqual(Buffer.from(signature.slice(7), 'hex'), expected);
}

The format check establishes the supplied digest's length before constant-time comparison. Even semantically equivalent JSON can have different signed bytes: adding whitespace, changing key order or replacing an escape sequence can invalidate the signature. Capture the original buffer first. In Express, register the raw-body webhook route before a global JSON parser, as shown in the webhook signature reference.

After verification, parse the body and dispatch from its signed event. X-Webhook-Event is a convenience header, not a substitute for that authenticated field. The local HTTP test deliberately sends a misleading convenience header and confirms that the body determines processing.

The server SDK's calculateSignature and signRequest helpers serve outgoing merchant API calls. They canonicalize API parameters and produce API authentication headers. They are not webhook verifiers. In this example, the actual SDK invokes that signing path when the worker later calls getOrderStatus; the local mock independently checks the resulting API signature.

Freshness is another distinct decision. The signed envelope's created_at is a delivery timestamp, not a payment time, event sequence or durable event identity. A retry can have a different value. There is no reason to import the merchant API's timestamp/nonce scheme or its five-minute authentication rule into webhook acceptance.

The fixture chooses a ten-minute maximum age and one-minute future skew. Those are merchant test policies, not GOAT delivery guarantees. Authenticated but stale deliveries are durably held for reconciliation, not fulfilled. Tightening that policy can reduce acceptance of old requests but also hold legitimate backlog after an outage. Fresh timestamps cannot solve replay: repeated fresh deliveries still need the same purchase-operation constraint.

Malformed signatures are rejected before receipt storage. Malformed JSON or missing basic envelope structure is rejected before acceptance. An authenticated shape that this deliberately narrow adapter does not recognize is instead retained as held work. That distinction prevents a parser gap from silently becoming a delivery decision.

Resolve the Order and Apply the Purchase Gate

The notification does not need to carry every authorization fact. Resolve its supported identifier through the saved purchase mapping, then retrieve the relevant backend state. The actual SDK 0.4.0 getOrderStatus(orderId) sends an authenticated GET /api/v1/orders/{order_id} and maps the returned record into camelCase fields.

The worker compares orderId, merchantId, dappOrderId, chainId, tokenContract, fromAddress and amountWei against saved terms. Missing values fail the comparison; they do not switch a check off. The fixture uses canonical synthetic strings and integer base-unit amount strings, avoiding floating-point price conversion. A production adapter must validate and canonicalize its selected rail's identifiers consistently before comparing them.

These checks close different mistakes. A valid notification for another merchant cannot authorize this merchant's purchase. The right token symbol does not establish the right token contract. A real transfer amount does not establish the amount owed. A reference supplied by the browser does not replace the order-to-purchase association saved by the backend.

The normalized SDK status record does not contain a recipient field. The fixture therefore makes its separate, simulated transfer-evidence check explicit. It compares that adapter's order, recipient, amount, chain, token and payer with the saved terms. Missing or conflicting evidence holds the receipt. Do not add an imaginary recipient property to getOrderStatus, or treat getOrderProof().signature as an independent platform attestation: that historical proof field is a recomputable hash. Treat it as a payment record, not an independent attestation.

For this bounded example, authorization requires order status INVOICED plus the saved purchase checks and current local eligibility. INVOICED is platform order completion; it does not mean the merchant has delivered the report. The application still needs to claim and complete that separate operation.

Card provides an important counterexample to an overly broad handler. fiat.payment.succeeded is payment-level evidence and can be sent while fulfillment remains withheld. A Card order can remain at PAYMENT_CONFIRMED during a hold. A valid signature on that notification does not override the restriction, and another charge is not the recovery action.

The actual SDK's waitForConfirmation can resolve at PAYMENT_CONFIRMED, INVOICED, FAILED, EXPIRED or CANCELLED. Consequently, awaiting that helper is not a universal permission to deliver. The local suite exercises its actual return at PAYMENT_CONFIRMED and verifies that the example's completion gate still waits; this is not a simulated live Card transaction.

Checkout has a separate session status, COMPLETED, derived from the associated order reaching INVOICED. Its handle-keyed session read is public rather than merchant-HMAC authenticated; keep a saved Checkout handle confined to its purchase. The Checkout fulfillment rules distinguish session completion, order completion and held payment. This example explains that distinction without implementing another rail.

Authorization runs in the worker after receipt acceptance. Its authenticated network read happens outside the local write transaction. The following persistence step checks that local terms and eligibility have not changed before creating work. That closes a local race; it does not make a remote platform read and a merchant database commit one distributed transaction.

A temporary status-read failure leaves the durable receipt available for retry rather than treating missing evidence as success. A non-completed status keeps the receipt pending; the example does not continuously spin or prescribe a polling rate. The worker scheduler must decide when to revisit it and when repeated failures need operator attention. An unknown order or a binding mismatch belongs in held work instead: repeatedly retrying the same contradictory facts does not make the purchase eligible. Keeping those outcomes distinct also avoids charging again merely because confirmation is delayed.

Commit Recoverable Work Before Acknowledging

The selected runtime order is explicit, even though the authorization rules were explained first:

verify raw bytes and basic fixture shape
  -> commit a durable receipt
  -> acknowledge receipt acceptance
  -> worker performs authenticated SDK status read
  -> check saved terms and simulated transfer evidence
  -> atomically commit eligibility, unique claim and outbox
  -> perform the external effect
  -> commit merchant delivery completion
verify raw bytes and basic fixture shape
  -> commit a durable receipt
  -> acknowledge receipt acceptance
  -> worker performs authenticated SDK status read
  -> check saved terms and simulated transfer evidence
  -> atomically commit eligibility, unique claim and outbox
  -> perform the external effect
  -> commit merchant delivery completion
verify raw bytes and basic fixture shape
  -> commit a durable receipt
  -> acknowledge receipt acceptance
  -> worker performs authenticated SDK status read
  -> check saved terms and simulated transfer evidence
  -> atomically commit eligibility, unique claim and outbox
  -> perform the external effect
  -> commit merchant delivery completion

Here, an acknowledgement means that the application has retained responsibility for the receipt. It does not certify payment eligibility or promise that fulfillment has finished. GOAT Flow's receiver contract requires a success response only after durable acceptance. The fixture returns HTTP 202 after its receipt transaction commits; 202 is this receiver's choice, not a special GOAT fulfillment status.

The merchant SQLite file contains purchases, receipts, operations and outbox work. Receipt IDs are generated locally for tracing. Multiple receipts can describe the same purchase operation, and each remains inspectable. A body hash could help diagnostics, but it would not be a stable business key when a delivery timestamp changes.

In the worker's transaction, a unique operation key claims the deliverable and a matching outbox row records the obligation to dispatch it. Both insertions commit together. If outbox insertion fails, the operation claim rolls back too, leaving the original receipt available for recovery. A test installs a failing database trigger, confirms that neither row survives, reopens the file and then processes the same receipt successfully.

This persistence is merchant code. The API Payments application-store example identifies the need for durable claims and delivery coordination; names such as ReportStore and deliverOnce are application interfaces, not storage shipped by the SDK. The local fixture implements its own tables and transactions rather than assuming those names provide working guarantees.

Consider three receipt failures. If the receipt transaction rolls back, the receiver cannot send a success acknowledgement; the test observes HTTP 503 and no stored receipt. If the transaction commits but the connection breaks before the acknowledgement arrives, the sender may believe delivery failed while the merchant already owns pending work. The fixture drops that connection, reopens SQLite and processes the retained receipt.

If an acknowledgement succeeds and the process stops before the worker runs, the pending receipt must still be there. The restart test closes and reopens the same file, authorizes that receipt, then reopens again before dispatching its outbox work. None of those assertions relies on an in-memory map surviving a restart.

An alternative design performs authorization and the fulfillment-claim transaction before acknowledging. That can be appropriate, but it places more latency and failure handling in the receiver path. The durable-inbox design moves that responsibility to a worker; it therefore needs scheduling, retries, held-work inspection and operational monitoring. A quick acknowledgement without those owners merely relocates lost work.

Do not depend on a guessed provider timeout or guaranteed redelivery schedule to choose between the designs. Polling can recover an already saved order when a notification is missed. Refund and dispute handling also remain separate work: finishing the original purchase operation must not suppress subsequent lifecycle evidence or authorize an automatic second effect.

Recover the External Effect and the Polling Race

A durable outbox solves the local handoff. It does not, by itself, make the external effect exactly once. The difficult interval starts when the fulfillment provider may have performed the action but the merchant has not committed completion.

The fixture represents that provider with a second SQLite file. Its effect table has a unique operation key and stores a retrievable result. This is an explicit downstream stub contract, not a property attributed to GOAT Flow or an arbitrary fulfillment vendor. Keeping the files separate allows the tests to preserve an external effect while leaving merchant completion unfinished.

Before dispatch, the worker atomically claims pending outbox work with a locally generated owner token and rechecks local purchase eligibility. The effect call happens outside the database transaction. After success, the merchant commits the returned result and marks its operation delivered. An owner check prevents a completion write from a different owner in this local model; it is not a complete distributed lease or failover implementation.

One test terminates a child process after claiming work but before invoking the provider. After reopening the files, recovery finds no effect and retries the same operation. Another terminates the child after the provider commits its effect but before merchant completion. On reopening both files, lookup returns the existing result; recovery records that result without issuing a second effect call.

An ambiguous timeout follows the same rule. The provider may have completed the action even though the caller received no usable response. If the provider supports durable idempotent retries, resend the same operation key and recover the original result. The fixture tests that branch with lookup disabled: two calls produce one stored effect and one merchant completion.

When neither authoritative lookup nor idempotent retry is available, automatic recovery must stop. The separate no-lookup/no-idempotency branch places the operation in reconcile and refuses another dispatch. An operator needs to determine whether delivery happened before authorizing further action. Expiring a lease or observing an old timestamp is not proof that the earlier effect never occurred.

For that investigation, retain the local receipt identifier, mapped purchase and attempt, operation key, last delivery state and any returned downstream reference. Compare the downstream service's delivery history with the requested deliverable before deciding whether to record an existing result or authorize further action. Avoid putting endpoint secrets, API credentials or unnecessary customer content in that record. The fixture's stored result is only a synthetic report reference; a production receipt-retention policy needs appropriate access control and deletion handling. The important operational distinction is between known failure before an effect and an outcome that remains unknown, not between a short retry delay and a long one.

The fixture requires confirmation that the previous worker stopped before its recovery entry point can run. Production systems need coordination appropriate to their workers and downstream service; copying that explicit flag into an unattended recovery loop would not establish termination. Likewise, an outage scheduler and an operator interface are responsibilities to implement, not behaviors supplied by the short example.

Polling uses the same saved-order authorization path and unique operation key as webhook work. It does not call a separate ship-now handler. The race test starts two webhook contenders and a poll in separate processes against the same merchant file. They share one durable operation and outbox row. SQLite's transaction and uniqueness constraint, rather than a preliminary check in JavaScript, arbitrate that contention.

A late response needs care too. The test begins a pending-state poll, completes delivery, then releases the older pending result. That observation does not overwrite the merchant's delivered state or create another job. Preserve conflicting evidence for reconciliation rather than assuming delivery timestamps impose a global order. Before an effect, inspect current business eligibility where applicable; afterward, a refund or dispute requires its own handling, not a retrospective fiction that the delivery never occurred.

Run the Local Fixture, Then Check the Deployment Contract

Download the complete local example bundle, included beside this article in the local article package. It contains the verifier, synthetic adapters, merchant store, worker, provider stub, HTTP harness, tests and required SDK archive. This relative link addresses the packaged artifact; it is not a claim that the ZIP is publicly hosted.

Extract the ZIP to obtain this layout:

goat-flow-webhook-example/
  README.md
  example/
    README.md
    package.json
    fixture.mjs
    fixture.test.mjs
    harness.mjs
    child.mjs
    prepare-sdk.mjs
    run-tests.mjs
  snapshots/
    sdk-0.4.0.tgz
    sdk-package-dist-client.js
    sdk-package-dist-signature.js
    sdk-package-dist-types.js
    sdk-package-dist-index.js
goat-flow-webhook-example/
  README.md
  example/
    README.md
    package.json
    fixture.mjs
    fixture.test.mjs
    harness.mjs
    child.mjs
    prepare-sdk.mjs
    run-tests.mjs
  snapshots/
    sdk-0.4.0.tgz
    sdk-package-dist-client.js
    sdk-package-dist-signature.js
    sdk-package-dist-types.js
    sdk-package-dist-index.js
goat-flow-webhook-example/
  README.md
  example/
    README.md
    package.json
    fixture.mjs
    fixture.test.mjs
    harness.mjs
    child.mjs
    prepare-sdk.mjs
    run-tests.mjs
  snapshots/
    sdk-0.4.0.tgz
    sdk-package-dist-client.js
    sdk-package-dist-signature.js
    sdk-package-dist-types.js
    sdk-package-dist-index.js

The verified Node runtime is v25.8.2, using built-in node:sqlite. The SDK itself declares Node 18 or later; that minimum does not establish compatibility with the newer SQLite API used by this fixture. From the directory containing the extracted goat-flow-webhook-example folder, enter its example subdirectory and run:

cd goat-flow-webhook-example/example
node run-tests.mjs
cd goat-flow-webhook-example/example
node run-tests.mjs
cd goat-flow-webhook-example/example
node run-tests.mjs

The runner verifies the SHA-256 of the adjacent SDK 0.4.0 archive and extracts the actual published package. The server SDK reference covers its methods and version requirements. The runner does not run npm installation or package lifecycle scripts. Keep the archive under the adjacent snapshots directory when moving the example. The client factory permits only the local mock, and all test servers bind to 127.0.0.1; there is no order-creation or transfer call.

The completed run passed 20 tests with no failures or skips. The runner writes test-results.json inside that example directory, recording the commands, executable, Node runtime, separate SQLite metadata and SQL-engine observations, timestamps, exit codes and output. The tests compare extracted SDK files with the included inspected-release snapshots, exercise its real status method and API signing, and verify the expected raw-byte, missing-field and mismatch failures. They also execute receipt and outbox rollback, file reopening, hard child exits, ambiguous-effect recovery and cross-process contention.

Those results establish the generated fixture's behavior under its injected failures. They do not establish a real merchant's event schema, transfer validity, GOAT redelivery behavior, Card fulfillment or a fulfillment provider's idempotency. The initial restricted run could not open loopback listeners; it was rerun with local-listener permission. A similarly restricted environment needs that permission to exercise the HTTP cases, not a connection to a payment service.

Before adapting the code to live traffic, replace both synthetic adapters with verified contracts for the selected deployment and rail. Obtain actual enabled-event payload samples, implement their identifier extraction, retain authoritative creation terms and supply the required recipient/transfer evidence. Test unknown shapes and missing facts without weakening the purchase gate. Separate endpoint secrets from API credentials and keep production secrets out of fixture files and logs.

Operationally, establish worker scheduling, retry ownership, held-receipt retention and alerting, a safe downstream effect-recovery contract, and confirmed worker coordination. The public references used here do not establish a redelivery count/backoff, a manual-redelivery interface or a webhook secret-rotation overlap period. Obtain those details before relying on them; API-key rotation behavior does not answer webhook-secret rotation.

The integration is complete only when each interruption has an owner: a committed receipt after acknowledgement, one purchase-operation claim after authorization, and either a recoverable external result or an explicit reconciliation hold after an uncertain effect. That is the boundary the local example demonstrates, without turning a successful test run into a claim of live payment execution.

[01]

AI Knowledge base

More Articles

More Articles

More Articles