Receive & verify webhooks

GoFundNode POSTs state changes to your configured URL. Each delivery is HMAC-signed and at-least-once.

The envelope

Every webhook shares the envelope { event, eventId, occurredAt, data }. The data object is event-specific — it is not the GET /v1/submissions/:id response.

{
  "event":      "submission.completed",
  "eventId":    "evt_84621",
  "occurredAt": "2026-05-22T18:01:41Z",
  "data": {
    "submissionId":    "9a1f…",
    "status":          "submitted",
    "outcome":         "success",
    "failureReason":   null,
    "ledger":          { "node_seconds": 92, "actions": 26, "…": "…" },
    "actualCredits":   111.6,
    "refundedCredits": 142.6
  }
}

Event types

EventFires when
submission.completedSettles in submitted status with actualCredits set.
submission.failedSettles in failed status.
submission.cancelledSettles in cancelled status.
credits.low_balanceBalance drops below the per-tenant threshold (default 1000). data: { balance, threshold }.
purchase.completedAn inbound credit purchase confirmed. Fires only when the inbound Solana Pay USDC continuity rail (ADR-047) is explicitly enabled for your deployment — off by default, not the GA customer rail; see Credits. data: { source, reference, token, credits, txSignature, network }token, txSignature, and network are continuity-rail fields.

Headers on every webhook POST

HeaderValue
X-GFN-TimestampUnix seconds.
X-GFN-Signaturehex(hmac_sha256(webhook_secret, timestamp + "." + raw_body))
X-GFN-EventEvent type, for routing without parsing.
X-GFN-DeliveryPer-delivery UUID — your dedupe key. Stable across retries of the same delivery.
Webhooks are signed with the webhook secret, NOT the inbound HMAC secret. Using the wrong secret fails verification every time.

Verify with the SDK (Fastify)

Raw-body gotcha. If you let your framework parse JSON before you verify, the re-serialized bytes (whitespace, key order) differ from what GoFundNode signed, and verification fails 100% of the time. Capture the raw body string before parsing.
import Fastify from "fastify";
import { verifyWebhook, type WebhookEvent } from "@gofundnode/vendor-client";

const app = Fastify();

// Capture the raw body string for HMAC verification.
app.addContentTypeParser("application/json", { parseAs: "string" }, (req, body, done) => {
  (req as any).rawBody = body;
  try { done(null, body.length ? JSON.parse(body) : {}); }
  catch (e) { done(e as Error, undefined); }
});

app.post("/api/gfn/webhook", async (req, reply) => {
  const verified = verifyWebhook({
    rawBody: (req as any).rawBody,
    headers: {
      timestamp: req.headers["x-gfn-timestamp"],
      signature: req.headers["x-gfn-signature"],
    },
    webhookSecret: process.env.GFN_WEBHOOK_SECRET!,
  });
  if (!verified.ok) return reply.code(401).send({ error: verified.reason });

  const event = req.body as WebhookEvent;       // { event, eventId, occurredAt, data }
  const deliveryId = req.headers["x-gfn-delivery"];

  // Dedupe on X-GFN-Delivery BEFORE any side effect (at-least-once delivery).
  if (await alreadyHandled(deliveryId)) return reply.send({ received: true });

  switch (event.event) {                        // narrows event.data by type
    case "submission.completed":
    case "submission.failed":
    case "submission.cancelled":
      await onSettled(event.data);              // { submissionId, outcome, actualCredits, … }
      break;
    case "credits.low_balance":
      await onLowBalance(event.data);           // { balance, threshold }
      break;
    case "purchase.completed":
      await onPurchase(event.data);             // { reference, credits, … }
      break;
    default:
      break;                                    // frozen-additive: ignore unknown types
  }

  await markHandled(deliveryId);
  return reply.send({ received: true });        // reply 2xx fast
});

Express: use express.raw({ type: "application/json" }) on the webhook route and call verifyWebhook with req.body.toString("utf8"). Runnable: examples/webhook-handler.ts.

SDK 0.2.1 type fix. Earlier SDK versions typed the envelope as { id, type, createdAt, tenantApiId } with submission.created/dispatched/filled/submitted/settled event names. Those never matched the wire — the wire was always { event, eventId, occurredAt, data }. The SDK type is now correct; the wire did not change.

Retry policy

A 2xx ends the retry chain. 4xx (except 408/429) marks the delivery permanently failed. 5xx / network / 408 / 429 retry with exponential backoff up to GFN_WEBHOOK_MAX_RETRIES (default 8) attempts over ~8 hours: 30s, 1m, 5m, 15m, 45m, 2h, 4h, 8h. Per-attempt timeout defaults to 10s.

Reference: docs/V1-API.md §3 (webhooks), in the repository. Envelope built by buildWebhookEnvelope in server/api/v1/webhooks/delivery.ts.
← Submit a submission Error taxonomy →