Skip to main content
developers/donations-sdk/webhooks
Developer12 min read

Webhooks

Receive donation events server-side. Nine event types, HMAC-SHA256 signatures, replay protection, at-least-once delivery, and exponential-backoff retry across eight attempts.

Webhooks

Webhooks are how the Donations Embed pushes events to your server when something happens — a donation completes, a refund posts, a recurring schedule cancels, a key pair is automatically frozen by abuse protection. Nine event types, signed with HMAC-SHA256, delivered at-least-once with exponential backoff, and re-playable from the Hub for any failed delivery.

This page covers the event catalog, the signing scheme, the delivery model, and platform-agnostic recipes for verifying and dispatching events. For end-to-end SDK examples in TypeScript / Node, Python, and Go, see Server SDKs.

Configure your endpoint

In the Hub portal, open Donations Embed → Webhooks. Set:

  1. URL — an HTTPS endpoint you control. HTTP is rejected.
  2. Events — pick the event types you want delivered. You will only receive events you subscribed to.
  3. Enabled — must be true for any delivery to fire.

When you save, the Hub returns a signing secret exactly once. Store it immediately in your secrets manager (alongside your secret key); the Hub never reveals it again. To replace the secret, rotate the endpoint URL — the secret is regenerated whenever the URL changes.

You can also configure the endpoint programmatically via the PUT /v1/embed/webhooks API; see API Reference.

Event catalog

The platform delivers nine event types. Every event uses the same envelope:

{
  "id": "evt_KqR4w8K2zQ9m",
  "type": "donation.completed",
  "created": 1717881425,
  "live_mode": true,
  "data": {
    "object": { /* event-specific payload */ }
  }
}
FieldTypeDescription
idstringGlobally unique event ID. Echoed in the Flock-Event-Id HTTP header. Customers MUST dedupe by this value.
typestringEvent type (e.g. donation.completed).
createdint (epoch seconds)Unix epoch seconds at event creation.
live_modebooleantrue for live events, false for sandbox. Test deliveries from the SDK's testWebhook method include data.object.synthetic = true.
data.objectobjectEvent-specific payload (see each event below).

donation.created

Fires when a donor opens the embed and a session is created. The payload reflects the session, not a donation row (the donation does not exist yet — the donor has not paid). Correlate to the eventual donation.completed via the donation ID returned by the confirm step.

{
  "id": "evt_8K2zQ9mPqR4w",
  "type": "donation.created",
  "created": 1717881421,
  "live_mode": true,
  "data": {
    "object": {
      "session_id": "4f6d3a36-2a55-4f11-bdc8-2a2a4a95b001",
      "organization_id": "7c8d9e10-1234-4321-9abc-def012345678",
      "amount_cents": 5000,
      "fund_ids": ["6f1b9b54-0f0b-4d1d-9c8a-9a7e5d4d0001"]
    }
  }
}

donation.completed

Fires after the gateway confirms a successful charge. This is the canonical "money has moved" signal — receipt generation, ledger sync, and donor-facing thank-you emails should hang off this event.

{
  "id": "evt_KqR4w8K2zQ9m",
  "type": "donation.completed",
  "created": 1717881425,
  "live_mode": true,
  "data": {
    "object": {
      "id": "8a1b9c4d-1234-4321-9abc-def012345678",
      "confirmation_id": "don_8a1b9c4d",
      "donor": { "name": "Sam Donor", "email": "sam@example.com" },
      "gross_amount_cents": 5175,
      "fee_amount_cents": 175,
      "net_amount_cents": 5000,
      "payment_method": { "type": "card", "last4": "4242", "brand": "visa" },
      "processor_transaction_id": "txn_aaaaaaaa",
      "status": "completed",
      "created_at": "2026-04-25T18:30:25Z",
      "funds": [
        { "fund_id": "6f1b9b54-0f0b-4d1d-9c8a-9a7e5d4d0001", "amount_cents": 5000 }
      ]
    }
  }
}

donation.failed

Fires when the gateway declines the charge or a transient error causes the flow to terminate before completion. Same payload shape as donation.completed, with status: "failed".

donation.refunded

Fires after a successful refund (full or partial). The refund_amount_cents field reflects the amount actually returned to the donor; refund_transaction_id is the gateway-side identifier (vendor-neutral).

{
  "id": "evt_R9mPqR4w8K2z",
  "type": "donation.refunded",
  "created": 1717881521,
  "live_mode": true,
  "data": {
    "object": {
      "id": "8a1b9c4d-1234-4321-9abc-def012345678",
      "confirmation_id": "don_8a1b9c4d",
      "donor": { "name": "Sam Donor", "email": "sam@example.com" },
      "gross_amount_cents": 5175,
      "fee_amount_cents": 175,
      "net_amount_cents": 5000,
      "payment_method": { "type": "card", "last4": "4242", "brand": "visa" },
      "processor_transaction_id": "txn_aaaaaaaa",
      "status": "refunded",
      "created_at": "2026-04-25T18:30:25Z",
      "funds": [
        { "fund_id": "6f1b9b54-0f0b-4d1d-9c8a-9a7e5d4d0001", "amount_cents": 5000 }
      ],
      "refund_amount_cents": 5175,
      "refund_transaction_id": "ref_8a1b9c4d"
    }
  }
}

recurring.activated

Fires when a new recurring schedule is created and the first charge succeeds. The data.object payload is the recurring schedule (id, donor, amount, interval, status, processor_subscription_id). To pair the activation with the underlying first charge, look up the donation row via client.listDonations({ recurringId: schedule.id }) (or persist the donation_id from your own confirm-step handler).

{
  "id": "evt_recur_act_001",
  "type": "recurring.activated",
  "created": 1717881425,
  "live_mode": true,
  "data": {
    "object": {
      "id": "5b2c8e10-1ed0-4f0c-bb8a-1234567890ab",
      "donor": { "name": "Sam Donor", "email": "sam@example.com" },
      "amount_cents": 5000,
      "interval": "monthly",
      "interval_count": 1,
      "status": "active",
      "processor_subscription_id": "sub_xxxxxxxx",
      "created_at": "2026-04-25T18:30:25Z"
    }
  }
}

recurring.payment_succeeded

Fires after each successful periodic charge. Same data.object shape as recurring.activated (the recurring schedule). The per-cycle donation row is queryable via client.listDonations({ recurringId: schedule.id }) once the donation.completed event also fires for the same cycle.

recurring.payment_failed

Fires when a periodic charge fails. The gateway will retry on its own schedule; subsequent attempts may emit recurring.payment_succeeded or, after exhaustion, an additional recurring.payment_failed.

recurring.cancelled

Fires when a recurring schedule is cancelled — by the customer via the SDK or Hub, or by the donor via the embed.

{
  "id": "evt_zQ9mPqR4w8K2",
  "type": "recurring.cancelled",
  "created": 1717881621,
  "live_mode": true,
  "data": {
    "object": {
      "recurring_id": "5b2c8e10-1ed0-4f0c-bb8a-1234567890ab",
      "processor_subscription_id": "sub_xxxxxxxx"
    }
  }
}

key.frozen

Fires when a key pair is automatically frozen by card-testing detection. The customer should rotate the affected key, investigate the source of the abusive traffic, and request manual unfreezing from 1st Flock support after remediation. This event is emitted in addition to whatever event the abusive traffic itself triggered (e.g. repeated donation.failed).

{
  "id": "evt_kf_9mPqR4w8K2zQ",
  "type": "key.frozen",
  "created": 1717881721,
  "live_mode": true,
  "data": {
    "object": {
      "key_pair_id": "7c8d9e10-1234-4321-9abc-def012345678",
      "pk_prefix": "pk_live_a1b2c3d4",
      "reason": "card_testing_detected",
      "decline_count_window": 50,
      "window_seconds": 60,
      "frozen_at": "2026-04-25T18:35:00Z"
    }
  }
}

Signature verification

Every delivery carries a Flock-Signature header of the form:

t=1717881425,v1=cdb6dc9d2c7c8eaa5b8e1f2d8c9a7e5d4d00018a1b9c4d12344321
  • t is the Unix epoch second the platform produced the signature.
  • v1 is the lowercase hex HMAC-SHA256 of <t>.<raw_body> using your endpoint's signing secret.

To verify:

  1. Parse t and v1 out of the header.
  2. Reject if t is more than 5 minutes in the past or future (replay protection — see below).
  3. Compute expected = HMAC_SHA256(secret, t + "." + raw_body).
  4. Compare v1 against expected in constant time. Reject on mismatch.
  5. Parse the JSON body and dispatch.

The 5-minute timestamp tolerance prevents an attacker who captured a previously-valid delivery from replaying it months later. Honour the tolerance even if your clock is well-synced — the platform may legitimately deliver up to a few seconds of clock skew.

Pass the raw bytes the platform delivered. Re-serialising the JSON before verifying (e.g. by calling request.json() and then JSON.stringify()) changes the bytes and the signature will not match.

Reference implementations

The verifier is provided in every SDK and is byte-compatible across all three languages. Pick the language matching your stack:

import {
  verifyWebhook,
  WebhookFormatError,
  WebhookSignatureError,
  WebhookTimestampError,
} from "@1st-flock/donations-embed-node";

// rawBody must be a Buffer or Uint8Array of the bytes received over the wire.
const SECRET = process.env.FLOCK_WEBHOOK_SECRET!;

try {
  const event = verifyWebhook(rawBody, signatureHeader, SECRET);
  // event.type is a discriminated union; event.data.object is typed per type.
  console.log("verified:", event.type, event.id);
} catch (err) {
  if (
    err instanceof WebhookSignatureError ||
    err instanceof WebhookTimestampError
  ) {
    // Always 401 — never confirm which check failed.
    return { status: 401 };
  }
  if (err instanceof WebhookFormatError) {
    return { status: 400 };
  }
  throw err;
}
from donations_embed import (
    WebhookFormatError,
    WebhookSignatureError,
    WebhookTimestampError,
    verify_webhook,
)

# raw_body must be the bytes received over the wire.
SECRET = os.environ["FLOCK_WEBHOOK_SECRET"]

try:
    event = verify_webhook(raw_body, signature_header, SECRET)
    # event is a discriminated union; isinstance / match dispatches by type.
    print("verified:", event.type, event.id)
except (WebhookSignatureError, WebhookTimestampError, WebhookFormatError):
    # Always 401 — never confirm which check failed.
    raise HTTPException(status_code=401, detail="invalid signature") from None
import (
    "errors"
    donationsembed "gitlab.com/1st-flock/donations/go/donations-embed"
)

// rawBody must be the bytes received over the wire.
// Pass the signing secret as []byte (not string).
secret := []byte(os.Getenv("FLOCK_WEBHOOK_SECRET"))

evt, err := donationsembed.VerifyWebhook(rawBody, signatureHeader, secret)
if err != nil {
    if errors.Is(err, donationsembed.ErrSignatureMalformed) {
        http.Error(w, "malformed delivery", http.StatusBadRequest)
        return
    }
    // Always 401 for signature/timestamp failures — never confirm which check failed.
    // ErrSignatureMismatch + ErrTimestampOutOfRange both land here.
    http.Error(w, "verification failed", http.StatusUnauthorized)
    return
}
// evt.Type is one of donationsembed.Event*; dispatch with a switch.
fmt.Println("verified:", evt.Type, evt.ID)

If you are not using one of the supported SDKs, the verification logic is short enough to implement directly. The same algorithm in pseudocode:

parts = signature_header.split(",")
t = parts.find(p -> p.startswith("t=")).slice(2)
v1 = parts.find(p -> p.startswith("v1=")).slice(3)

if abs(now - t) > 300:  # 5-minute tolerance
    reject("timestamp out of tolerance")

expected = hex(hmac_sha256(secret, t + "." + raw_body))
if not constant_time_equals(expected, v1):
    reject("signature mismatch")

return parse_json(raw_body)

The constant-time comparison is critical — a naive == on strings leaks one bit of secret information per microsecond of timing variation, which is enough to recover a signature digest given enough requests.

Delivery model

Retry schedule

Webhook delivery is at-least-once. The platform considers any non-2xx HTTP response a failure and retries up to eight total attempts (1 initial + 7 retries) with exponential backoff and jitter:

AttemptDelay since previousCumulative time
1(initial)0 s
2~30 s~30 s
3~2 min~2.5 min
4~10 min~12.5 min
5~1 hour~1.2 hours
6~6 hours~7.2 hours
7~12 hours~19 hours
8~24 hours~43 hours

Each delay carries up to ±25% jitter. After the eighth failed attempt the delivery is marked failed and removed from the retry queue. You can replay it manually from Donations Embed → Webhooks → Deliveries in the Hub.

Idempotency on your side

Because delivery is at-least-once, the same event can arrive twice — for example if your endpoint took long enough to reply that the platform timed out and retried. Persist event.id (also exposed as the Flock-Event-Id HTTP header) and short-circuit re-deliveries:

const seen = await store.alreadyProcessed(event.id);
if (seen) {
  return { status: 204 };
}
await store.recordEvent(event.id);
// ... your handler
if await store.already_processed(event.id):
    return {"status": "ok"}
await store.record_event(event.id)
# ... your handler
processed, err := store.AlreadyProcessed(ctx, evt.ID)
if err != nil {
    http.Error(w, "store error", http.StatusInternalServerError)
    return
}
if processed {
    w.WriteHeader(http.StatusNoContent)
    return
}
if err := store.RecordEvent(ctx, evt.ID); err != nil {
    http.Error(w, "store error", http.StatusInternalServerError)
    return
}
// ... your handler

A simple "INSERT IGNORE / ON CONFLICT DO NOTHING" against a unique index on event_id is enough — your handler should be a no-op on duplicate inserts.

Response budget

Your endpoint must respond within 10 seconds. Anything longer counts as a failure and triggers a retry. If your processing is heavy (sending emails, syncing to a third-party system), do the verify-and-acknowledge synchronously and enqueue the heavy work for an async worker:

// Verify + enqueue + 204 — all under 10 s
app.post("/webhooks/flock", express.raw({ type: "application/json" }), (req, res) => {
  const event = verifyWebhook(req.body as Buffer, req.header("flock-signature")!, SECRET);
  await queue.enqueue("flock-event", { id: event.id, raw: req.body });
  res.status(204).end();
});

Replay protection

The 5-minute timestamp tolerance is the primary defence against replay attacks. An attacker who captured a previously-valid delivery cannot replay it five minutes later because the timestamp will be outside the tolerance window.

The verifier also:

  • Compares signatures in constant time (no early-exit on first mismatch).
  • Treats every verification failure (format error, timestamp error, signature error) as a single "verification failed" outcome to the calling endpoint. Internal logs distinguish them, but the HTTP response is always the same 401. This denies an attacker any signal about which check failed.

If you absolutely must lengthen the tolerance window (for example, your endpoint sits behind a queue with multi-minute backpressure), buffer the delivery into a fast endpoint and process from the queue. Do not extend the tolerance window in your verifier — the 5-minute default is a security setting, not a tunable.

Testing locally

Two tools you will need:

  1. A way to expose your local development server to the public internet (the platform's webhook delivery cannot reach localhost).
  2. A way to fire a synthetic event without making a real donation.

For #1, run a tunnel from your dev machine:

# Pick any tunnel that gives you an HTTPS URL pointing at localhost:8080
ssh -R 80:localhost:8080 serveo.net
# or
cloudflared tunnel --url http://localhost:8080

Once you have a public URL, configure it in Donations Embed → Webhooks in the Hub.

For #2, the SDK ships a testWebhook (or equivalent) helper that fires a synthetic event of any type to your configured endpoint. Your endpoint receives a normal-looking event whose data.object.synthetic flag is set to true so your handler can branch on it and skip downstream processing.

import { Client } from "@1st-flock/donations-embed-node";

const client = new Client({ apiKey: process.env.FLOCK_SECRET_KEY! });
const result = await client.testWebhook("donation.completed");
console.log("Queued event id:", result.event_id);
from donations_embed import Client

async with Client(api_key=os.environ["FLOCK_SECRET_KEY"]) as client:
    result = await client.test_webhook(event_type="donation.completed")
    print("Queued event id:", result.event_id)
result, err := c.TestWebhook(ctx, "donation.completed")
if err != nil {
    log.Fatal(err)
}
fmt.Println("Queued event id:", result.EventID)

The synthetic event is signed with your real signing secret so it exercises the full verify-and-dispatch path without making a real donation.

Inspect deliveries

The Hub portal's Donations Embed → Webhooks → Deliveries tab lists every delivery attempt your endpoint has received in the last 30 days. Each row shows:

  • Event ID and type
  • Attempt number (1–8)
  • Status code returned by your endpoint
  • Latency (ms)
  • Next retry timestamp (if not yet succeeded)

Click a row to view the full request and response payload, copy the raw bytes the platform sent, and replay the delivery manually if your handler had a bug at the time it originally fired.

The same data is also available programmatically via GET /v1/embed/webhooks/deliveries.

Next steps

Developer Portal