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
| Event | Fires when |
|---|---|
submission.completed | Settles in submitted status with actualCredits set. |
submission.failed | Settles in failed status. |
submission.cancelled | Settles in cancelled status. |
credits.low_balance | Balance drops below the per-tenant threshold (default 1000). data: { balance, threshold }. |
purchase.completed | An 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
| Header | Value |
|---|---|
X-GFN-Timestamp | Unix seconds. |
X-GFN-Signature | hex(hmac_sha256(webhook_secret, timestamp + "." + raw_body)) |
X-GFN-Event | Event type, for routing without parsing. |
X-GFN-Delivery | Per-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.
- Verify the signature before parsing/acting; reject with 401 on failure.
- Respond 2xx to acknowledge; any non-2xx triggers a retry.
- Dedupe on
X-GFN-Delivery— the same event can arrive more than once. - Finish within the per-attempt timeout (do heavy work async).
Reference:
docs/V1-API.md §3 (webhooks), in the repository. Envelope built by buildWebhookEnvelope in server/api/v1/webhooks/delivery.ts.