Skip to main content
developers/donations-sdk/server-sdks/typescript-node
Developer9 min read

TypeScript / Node SDK

Server-side TypeScript SDK for the Donations Embed API. Native fetch, zero runtime dependencies, ESM and CJS, runs on Node 18.17+ and Bun.

TypeScript / Node SDK

The TypeScript / Node SDK is a typed, zero-dependency client for the Donations Embed Customer SDK API plus a constant-time HMAC-SHA256 webhook verifier. It uses Node's built-in fetch and crypto, ships as both ESM and CJS, and runs on Node 18.17 or newer (also Bun and Deno with a Node-compat shim).

Install

npm install @1st-flock/donations-embed-node
# or
pnpm add @1st-flock/donations-embed-node
# or
yarn add @1st-flock/donations-embed-node

Requires Node 18.17 or newer. No other runtime dependencies.

Initialise the client

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

const client = new Client({
  apiKey: process.env.FLOCK_SECRET_KEY!,
});

The constructor validates the key prefix; passing a publishable key (pk_…) or a malformed string throws synchronously with a clear error message.

Constructor options

OptionTypeDefaultNotes
apiKeystring(required)sk_live_… or sk_test_…. Publishable keys are rejected.
baseUrlstringhttps://api.1stflock.comOverride for the sandbox cluster (https://api.sandbox.1stflock.com) or for self-hosted environments.
fetchfunctionglobalThis.fetchInject a custom fetch implementation (test mocks, alternate runtimes).
retriesnumber3Retries on 5xx + transient network errors. The original attempt is separate.
timeoutnumber30000Per-request timeout in milliseconds.
loggerobjectno-opAnything with a debug(message, ...args) method (pino, winston, console).

Sandbox

Pass a sandbox key — the SDK routes to the sandbox cluster automatically based on the _test_ prefix.

const client = new Client({
  apiKey: process.env.FLOCK_SECRET_KEY!, // sk_test_…
});

If you ever need to override the base URL explicitly (for example to hit a self-hosted instance), pass baseUrl:

const client = new Client({
  apiKey: process.env.FLOCK_SECRET_KEY!,
  baseUrl: "https://api.sandbox.1stflock.com",
});

List donations

Cursor-paginated. The page size caps at 200 with a default of 50. Iterate by passing the previous response's next_cursor until it is null.

let cursor: string | undefined;
do {
  const page = await client.listDonations({ cursor, limit: 200 });
  for (const donation of page.donations) {
    console.log(donation.confirmation_id, donation.gross_amount_cents, donation.status);
  }
  cursor = page.next_cursor ?? undefined;
} while (cursor);

Filter by fund or donor email:

const buildingFundOnly = await client.listDonations({
  fundId: "6f1b9b54-0f0b-4d1d-9c8a-9a7e5d4d0001",
});

const samsHistory = await client.listDonations({
  donorEmail: "sam@example.com",
});

Get a single donation

const donation = await client.getDonation("8a1b9c4d-1234-4321-9abc-def012345678");
console.log(donation.confirmation_id, donation.donor_email);

Returns 404 if the donation is not visible to the caller (including cross-organisation requests). The SDK raises NotFoundError in that case.

Refund a donation

Idempotent on Idempotency-Key. Replays return the existing refund record without re-contacting the gateway.

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

const client = new Client({ apiKey: process.env.FLOCK_SECRET_KEY! });

// Full refund
const refund = await client.refundDonation(
  "8a1b9c4d-1234-4321-9abc-def012345678",
  {},
  { idempotencyKey: idempotencyKey("refund") },
);
console.log(refund.refund_amount_cents, refund.processor_refund_id);

// Partial refund — $10.00
const partial = await client.refundDonation(
  "8a1b9c4d-1234-4321-9abc-def012345678",
  { amountCents: 1000, reason: "Donor requested adjustment" },
  { idempotencyKey: idempotencyKey("refund") },
);

If the network call times out or the connection drops, retry the same call with the same Idempotency-Key — the server returns the original record without contacting the gateway again.

List recurring schedules

const page = await client.listRecurring({ status: "active", limit: 100 });
for (const schedule of page.recurring) {
  console.log(schedule.donor_email, schedule.amount_cents, schedule.interval);
}

Cancel a recurring schedule

Idempotent. A second call returns already_cancelled: true without re-emitting a webhook.

const result = await client.cancelRecurring(
  "5b2c8e10-1ed0-4f0c-bb8a-1234567890ab",
  { idempotencyKey: idempotencyKey("cancel-recurring") },
);
console.log(result.status, result.already_cancelled);

Fire a synthetic webhook

Useful for end-to-end testing your webhook handler without making a real donation.

const result = await client.testWebhook("donation.completed");
console.log("Queued event id:", result.event_id);

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 during integration testing.

Verify a webhook

The verifier is synchronous and runs in constant time. It returns a parsed, discriminated event union on success; throws one of WebhookFormatError, WebhookTimestampError, or WebhookSignatureError on failure.

Pass the raw bytes the platform delivered. Re-serialising the JSON before verifying changes the bytes and the signature will not match.

Express middleware

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

const app = express();
const SECRET = process.env.FLOCK_WEBHOOK_SECRET!;

// express.raw() preserves the body bytes — req.body is a Buffer.
app.post(
  "/webhooks/flock",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.header("flock-signature");
    if (!signature) {
      res.status(400).send("missing flock-signature header");
      return;
    }

    let event: ReturnType<typeof verifyWebhook>;
    try {
      event = verifyWebhook(req.body as Buffer, signature, SECRET);
    } catch (err) {
      if (
        err instanceof WebhookSignatureError ||
        err instanceof WebhookTimestampError
      ) {
        // Always return 401 — never confirm which check failed.
        res.status(401).send("verification failed");
        return;
      }
      if (err instanceof WebhookFormatError) {
        res.status(400).send("malformed delivery");
        return;
      }
      throw err;
    }

    switch (event.type) {
      case "donation.completed":
        // event.data.object is typed: DonationEventObject
        await persistReceipt(event.data.object);
        break;
      case "donation.refunded":
        await reverseReceipt(event.data.object);
        break;
      case "key.frozen":
        // event.data.object is typed: KeyFrozenEventObject
        await alertOnCall(event.data.object);
        break;
    }

    // Always 2xx after a successful verify — the platform retries on non-2xx.
    res.status(204).end();
  },
);

Next.js App Router (app/api/webhooks/flock/route.ts)

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

const SECRET = process.env.FLOCK_WEBHOOK_SECRET!;

export async function POST(request: Request): Promise<Response> {
  const signature = request.headers.get("flock-signature");
  if (!signature) {
    return new NextResponse("missing flock-signature header", { status: 400 });
  }

  // Read the raw body as bytes — do NOT call request.json().
  const body = Buffer.from(await request.arrayBuffer());

  let event: ReturnType<typeof verifyWebhook>;
  try {
    event = verifyWebhook(body, signature, SECRET);
  } catch (err) {
    if (
      err instanceof WebhookSignatureError ||
      err instanceof WebhookTimestampError
    ) {
      return new NextResponse("verification failed", { status: 401 });
    }
    if (err instanceof WebhookFormatError) {
      return new NextResponse("malformed delivery", { status: 400 });
    }
    throw err;
  }

  switch (event.type) {
    case "donation.completed":
      await persistReceipt(event.data.object);
      break;
    case "donation.refunded":
      await reverseReceipt(event.data.object);
      break;
  }

  return new NextResponse(null, { status: 204 });
}

// IMPORTANT: opt out of body parsing so we can read the raw bytes.
export const dynamic = "force-dynamic";

Verify-only import

For consumers that want only the verifier and not the API client surface (smaller bundle, fewer module-load side effects), import from the dedicated subpath:

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

The verifier is a pure function — it has no module-load side effects and no network I/O.

Error handling

Every API call raises a typed exception on a non-2xx response. Catch the common base (ApiError) for blanket handling, or pivot on specific subclasses for domain logic.

import {
  Client,
  ApiError,
  AuthError,
  ConflictError,
  ForbiddenError,
  GoneError,
  NotFoundError,
  RateLimitError,
  ValidationError,
} from "@1st-flock/donations-embed-node";

const client = new Client({ apiKey: process.env.FLOCK_SECRET_KEY! });

try {
  const refund = await client.refundDonation(donationId, { amountCents: 1000 });
} catch (err) {
  if (err instanceof RateLimitError) {
    await sleep((err.retryAfterSeconds ?? 1) * 1000);
    // ... retry
  } else if (err instanceof ConflictError) {
    console.warn(`Donation ${donationId} already fully refunded`);
  } else if (err instanceof NotFoundError) {
    console.warn(`Donation ${donationId} not found`);
  } else if (err instanceof ApiError) {
    console.error(
      `Refund failed: status=${err.statusCode} code=${err.code} message=${err.message} requestId=${err.requestId}`,
    );
    throw err;
  } else {
    throw err;
  }
}

The full error hierarchy:

StatusClassWhen
400 / 422ValidationErrorThe request body or query parameters fail schema validation.
401AuthErrorMissing, malformed, or revoked secret key.
403ForbiddenErrorKey pair is frozen, or the caller lacks the required RBAC scope.
404NotFoundErrorResource does not exist or is not visible to the caller.
409ConflictErrorConflicting state (e.g. refund of an already-refunded donation).
410GoneErrorSession expired or terminally failed.
429RateLimitErrorPer-(key, IP) rate limit exceeded. Carries retryAfterSeconds.
502BadGatewayErrorUpstream payment gateway error.
503ServiceUnavailableErrorRequired platform configuration missing.
OtherApiErrorCatch-all base class; matches every status.

All concrete errors extend ApiError, which exposes:

  • statusCode: number — HTTP status
  • code: string — stable, machine-readable identifier (e.g. card_declined, validation_error)
  • message: string — human-readable description
  • details?: Record<string, unknown> — optional extra data
  • requestId?: string — server correlation header (x-request-id)

Retry behaviour and idempotency

The transport retries 5xx (500, 502, 503, 504) responses and transient network errors with exponential backoff and jitter. The default schedule is 3 retries with backoff factors of approximately 1 s, 2 s, 4 s plus jitter.

4xx responses are never auto-retried. They are caller errors that retrying will not fix.

429 responses are never auto-retried. The Retry-After header tells you how long to back off; the SDK surfaces it as RateLimitError.retryAfterSeconds so caller code can sleep and re-issue.

Mutating endpoints (refundDonation, cancelRecurring) accept an idempotencyKey option that is forwarded as the Idempotency-Key HTTP header. Replays return the original response without performing the side effect a second time. Use the idempotencyKey helper to generate a tagged UUID suitable for the header:

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

const key = idempotencyKey("refund"); // → "refund:1f2dab36-2a55-4f11-bdc8-2a2a4a95b001"
await client.refundDonation(id, params, { idempotencyKey: key });

The tag prefix groups related retries under the same observability label so log aggregation tools can collapse them.

Examples

The SDK ships three runnable examples in the examples/ directory of the npm package:

  • verify-webhook.ts — Express middleware that verifies an inbound delivery and dispatches per event type.
  • refund-donation.ts — CLI script that refunds a donation by ID with a fresh idempotency key.
  • stream-recurring.ts — Cursor-paginated dump of every recurring schedule, with optional status filter.

Run with node --import tsx examples/<script>.ts (or build first with tsc and run from dist/).

Cross-SDK compatibility

The webhook verifier is byte-compatible with the platform-side signer and with the Python and Go SDKs. Canonical fixtures in the SDK's test suite mirror the equivalent fixtures in the sibling SDKs so a signature produced by any one of them verifies under the others.

Next steps

Developer Portal