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
| Option | Type | Default | Notes |
|---|---|---|---|
apiKey | string | (required) | sk_live_… or sk_test_…. Publishable keys are rejected. |
baseUrl | string | https://api.1stflock.com | Override for the sandbox cluster (https://api.sandbox.1stflock.com) or for self-hosted environments. |
fetch | function | globalThis.fetch | Inject a custom fetch implementation (test mocks, alternate runtimes). |
retries | number | 3 | Retries on 5xx + transient network errors. The original attempt is separate. |
timeout | number | 30000 | Per-request timeout in milliseconds. |
logger | object | no-op | Anything 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:
| Status | Class | When |
|---|---|---|
| 400 / 422 | ValidationError | The request body or query parameters fail schema validation. |
| 401 | AuthError | Missing, malformed, or revoked secret key. |
| 403 | ForbiddenError | Key pair is frozen, or the caller lacks the required RBAC scope. |
| 404 | NotFoundError | Resource does not exist or is not visible to the caller. |
| 409 | ConflictError | Conflicting state (e.g. refund of an already-refunded donation). |
| 410 | GoneError | Session expired or terminally failed. |
| 429 | RateLimitError | Per-(key, IP) rate limit exceeded. Carries retryAfterSeconds. |
| 502 | BadGatewayError | Upstream payment gateway error. |
| 503 | ServiceUnavailableError | Required platform configuration missing. |
| Other | ApiError | Catch-all base class; matches every status. |
All concrete errors extend ApiError, which exposes:
statusCode: number— HTTP statuscode: string— stable, machine-readable identifier (e.g.card_declined,validation_error)message: string— human-readable descriptiondetails?: Record<string, unknown>— optional extra datarequestId?: 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
- Webhooks — event catalog, signing, retries, replay protection.
- API Reference — every endpoint, every request and response shape.
- Compliance & Security — PCI scope and customer obligations.