API Reference
Complete reference for every Donations Embed Customer SDK API endpoint. Authentication, request and response shapes, error envelopes, idempotency, and rate limits.
API Reference
This is the complete reference for the Donations Embed Customer SDK API — the server-to-server endpoints you call with your secret key. The browser embed and the Hub portal call additional cohorts of endpoints, but customer SDK code never touches them; this page documents only what you can call from your server.
For a more conversational introduction, see Server SDKs. For raw OpenAPI, see the published spec at https://api.1stflock.com/openapi.yaml.
Base URLs
| Environment | Base URL |
|---|---|
| Production | https://api.1stflock.com |
| Sandbox | https://api.sandbox.1stflock.com |
The SDK routes to the correct base URL automatically based on the secret key prefix (sk_live_ → production, sk_test_ → sandbox). If you are calling the API directly without the SDK, pick the base URL that matches your key prefix.
Authentication
All Customer SDK endpoints authenticate with your secret key over HTTP Authorization: Bearer:
Authorization: Bearer sk_test_REPLACE_ME
Mixing key prefixes (live secret against the sandbox URL, or vice versa) returns 401.
Idempotency
Mutating endpoints (POST /v1/embed/donations/{id}/refund, POST /v1/embed/recurring/{id}/cancel) accept an optional Idempotency-Key HTTP header. Replays return the original response without performing the side effect a second time:
Idempotency-Key: refund:1f2dab36-2a55-4f11-bdc8-2a2a4a95b001
Each SDK provides an idempotencyKey helper that generates a fresh tagged UUID; see your SDK's reference page for usage.
Pagination
List endpoints (GET /v1/embed/donations, GET /v1/embed/recurring) are cursor-paginated. Default page size is 50; the cap is 200.
Pass ?cursor=… (the previous response's next_cursor) and ?limit=… (1 to 200). Iterate until next_cursor is null.
Rate limits
| Cohort | Limit |
|---|---|
| Customer SDK (your secret key) | 1000 requests / minute / (key, source IP) |
When exceeded, the API returns 429 with a Retry-After header (seconds). Each SDK surfaces this as a RateLimitError with the value parsed into retryAfter / retry_after.
Error envelope
Every non-2xx response uses the same JSON envelope:
{
"code": "validation_error",
"message": "allocations must contain at least one item",
"details": {
"loc": ["body", "allocations"]
}
}
| Field | Type | Description |
|---|---|---|
code | string | Stable, machine-readable identifier (invalid_cursor, card_declined, validation_error, etc.). Safe to switch on. |
message | string | Human-readable description. Subject to wording changes. |
details | object (optional) | Error-specific extra data. Schema varies by code. |
The HTTP x-request-id response header carries a server-side correlation ID; include it when contacting support so we can trace the request quickly.
| HTTP status | Meaning |
|---|---|
| 400 | Bad request — malformed query parameters or body. |
| 401 | Missing or invalid credentials. |
| 403 | Credentials valid but action not allowed (frozen key, missing RBAC scope, origin not allowlisted). |
| 404 | Resource does not exist or is not visible to the caller. |
| 409 | Request conflicts with current resource state (e.g. refund of a fully-refunded donation). |
| 410 | Session expired or terminally failed; restart the flow. |
| 422 | Body fails schema validation. |
| 429 | Rate limit exceeded. |
| 502 | Upstream payment gateway error. |
| 503 | Required platform configuration missing or unavailable. |
Endpoints
List donations
GET /v1/embed/donations
Returns donations for the authenticated organisation, ordered (created_at DESC, id DESC). Cursor-paginated; page size capped at 200.
Query parameters:
| Name | Type | Default | Notes |
|---|---|---|---|
cursor | string | (none) | Opaque cursor returned in a previous response's next_cursor. |
limit | integer (1–200) | 50 | Page size. |
fund_id | UUID | (none) | Filter to donations with at least one allocation to this fund. |
donor_email | string | (none) | Filter to donations from a specific donor email. |
Response (200):
{
"donations": [
{
"id": "8a1b9c4d-1234-4321-9abc-def012345678",
"confirmation_id": "don_8a1b9c4d",
"donor_name": "Sam Donor",
"donor_email": "sam@example.com",
"gross_amount_cents": 5175,
"fee_amount_cents": 175,
"net_amount_cents": 5000,
"payment_method_type": "card",
"payment_method_last4": "4242",
"payment_method_brand": "visa",
"funds": [
{
"fund_id": "6f1b9b54-0f0b-4d1d-9c8a-9a7e5d4d0001",
"fund_name": "General",
"amount_cents": 5000
}
],
"recurring_id": null,
"refunded_amount_cents": 0,
"status": "completed",
"created_at": "2026-04-25T18:30:25Z"
}
],
"next_cursor": null
}
Possible errors: 400, 401, 403, 429.
Get a single donation
GET /v1/embed/donations/{donation_id}
Returns the full donation record. Cross-organisation access collapses to 404 so the response cannot confirm whether a UUID belongs to another tenant.
Path parameters:
| Name | Type | Notes |
|---|---|---|
donation_id | UUID | The donation's unique ID. |
Response (200): Same Donation shape as above.
Possible errors: 401, 403, 404, 429.
Refund a donation
POST /v1/embed/donations/{donation_id}/refund
Issues a full or partial refund. Idempotent on Idempotency-Key: a second call against an already-refunded donation returns the existing refund record without contacting the gateway again.
Path parameters:
| Name | Type | Notes |
|---|---|---|
donation_id | UUID | The donation's unique ID. |
Headers:
| Name | Required | Notes |
|---|---|---|
Idempotency-Key | recommended | Caller-supplied key. Replays return the original response. |
Request body:
{
"amount_cents": 1000,
"reason": "Donor requested adjustment"
}
| Field | Type | Default | Notes |
|---|---|---|---|
amount_cents | integer (≥ 1) | (full refund) | Refund amount in cents. Omit or set null for full. |
reason | string (≤ 500) | (none) | Free-text reason recorded in the audit log. |
Response (200):
{
"donation_id": "8a1b9c4d-1234-4321-9abc-def012345678",
"refund_amount_cents": 1000,
"refunded_at": "2026-04-25T18:35:00Z",
"processor_refund_id": "ref_8a1b9c4d"
}
Possible errors: 400, 401, 403, 404, 409, 422, 429, 502.
List recurring schedules
GET /v1/embed/recurring
Returns recurring donation schedules for the authenticated organisation, ordered (created_at DESC, id DESC).
Query parameters:
| Name | Type | Default | Notes |
|---|---|---|---|
cursor | string | (none) | Opaque cursor from a previous response. |
limit | integer (1–200) | 50 | Page size. |
status | enum: active / paused / cancelled | (none) | Filter by status. |
Response (200):
{
"recurring": [
{
"id": "5b2c8e10-1ed0-4f0c-bb8a-1234567890ab",
"donor_name": "Sam Donor",
"donor_email": "sam@example.com",
"amount_cents": 25000,
"interval": "monthly",
"interval_count": 1,
"status": "active",
"next_charge_date": "2026-05-25",
"created_at": "2026-04-25T18:30:25Z"
}
],
"next_cursor": null
}
Possible errors: 400, 401, 403, 429.
Cancel a recurring schedule
POST /v1/embed/recurring/{recurring_id}/cancel
Cancels the schedule on the gateway and marks it cancelled. Idempotent: a second call returns already_cancelled: true without re-emitting a recurring.cancelled webhook.
Path parameters:
| Name | Type | Notes |
|---|---|---|
recurring_id | UUID | The recurring schedule's unique ID. |
Headers:
| Name | Required | Notes |
|---|---|---|
Idempotency-Key | recommended | Replays return the original response. |
Response (200):
{
"id": "5b2c8e10-1ed0-4f0c-bb8a-1234567890ab",
"status": "cancelled"
}
On a replay:
{
"id": "5b2c8e10-1ed0-4f0c-bb8a-1234567890ab",
"status": "cancelled",
"already_cancelled": true
}
Possible errors: 401, 403, 404, 409, 429, 502.
Fire a synthetic webhook
POST /v1/embed/webhooks/test
Queues a synthetic event of the requested type for delivery to the organisation's enabled webhook endpoint. The payload's data.object.synthetic flag is true so customer code can branch on it and skip downstream processing during integration testing. Useful for end-to-end testing your webhook handler without making a real donation.
Request body:
{
"event_type": "donation.completed"
}
| Field | Type | Notes |
|---|---|---|
event_type | string | One of: donation.created, donation.completed, donation.failed, donation.refunded, recurring.activated, recurring.payment_succeeded, recurring.payment_failed, recurring.cancelled. |
The key.frozen event cannot be triggered via this endpoint — it is fired only by the automatic protection system in response to real declined-attempt bursts.
Response (200):
{
"event_id": "evt_test_a1b2c3d4e5f6",
"queued": true
}
Possible errors: 401, 403, 409 (no enabled webhook endpoint configured), 422, 429.
Webhook endpoint configuration
The Customer SDK API does not expose webhook endpoint configuration — that is configured via the Hub portal, or programmatically via Hub-cohort endpoints (PUT /v1/embed/webhooks). The Hub-cohort endpoints authenticate with a user session, not a secret key, so they are not callable from a server-to-server SDK context. If you need programmatic webhook configuration, see your account manager for the Hub API surface.
To inspect delivery history, the Hub portal's Donations Embed → Webhooks → Deliveries tab and the underlying GET /v1/embed/webhooks/deliveries endpoint return the same data.
Object reference
Donation
| Field | Type | Notes |
|---|---|---|
id | UUID | The donation's unique ID. |
confirmation_id | string | Human-readable identifier (e.g. don_8a1b9c4d). |
donor_name | string | |
donor_email | string | |
gross_amount_cents | integer | Amount actually charged including any fee coverage. |
fee_amount_cents | integer | Processing fee, if covered by the donor; 0 otherwise. |
net_amount_cents | integer | Amount that ends up in your fund(s). |
payment_method_type | enum | card or ach. |
payment_method_last4 | string | Last four digits of the card or bank account. |
payment_method_brand | string | null | Card brand (e.g. visa); null for ACH. |
funds | array of FundAllocation | Per-fund split of net_amount_cents. |
recurring_id | UUID | null | Linked recurring schedule, if this donation is a recurring cycle. |
refunded_amount_cents | integer | Total refunded, in cents. |
status | enum | pending, completed, failed, refunded, partial_refund. |
created_at | ISO 8601 timestamp |
FundAllocation
| Field | Type | Notes |
|---|---|---|
fund_id | UUID | |
fund_name | string | |
amount_cents | integer |
Recurring
| Field | Type | Notes |
|---|---|---|
id | UUID | |
donor_name | string | |
donor_email | string | |
amount_cents | integer | Amount of each cycle, in cents. |
interval | enum | weekly, monthly, annual. |
interval_count | integer | Number of intervals between cycles (1 = every interval; 2 = every other; etc.). |
status | enum | active, paused, cancelled. |
next_charge_date | ISO 8601 date | null | |
created_at | ISO 8601 timestamp |
Refund response
| Field | Type | Notes |
|---|---|---|
donation_id | UUID | |
refund_amount_cents | integer | Amount refunded in cents. |
refunded_at | ISO 8601 timestamp | |
processor_refund_id | string | Vendor-neutral refund identifier. |
Webhook test response
| Field | Type | Notes |
|---|---|---|
event_id | string | The synthetic event's ID. Matches the Flock-Event-Id header on the resulting delivery. |
queued | boolean | Always true on success. |
Sample flows
Sync the last hour of donations every minute
A simple poll-based sync. Since donations are also delivered via webhook, prefer the webhook flow if you can — but a polling fallback is useful for systems that cannot accept inbound webhooks.
import { Client } from "@1st-flock/donations-embed-node";
const client = new Client({ apiKey: process.env.FLOCK_SECRET_KEY! });
async function syncRecentDonations(sinceIso: string): Promise<void> {
let cursor: string | undefined;
do {
const page = await client.listDonations({ cursor, limit: 200 });
for (const donation of page.donations) {
if (donation.created_at < sinceIso) {
return; // Older than the window — done.
}
await upsertDonation(donation);
}
cursor = page.next_cursor ?? undefined;
} while (cursor);
}
setInterval(() => {
const since = new Date(Date.now() - 60 * 60 * 1000).toISOString();
syncRecentDonations(since);
}, 60_000);
import asyncio
from datetime import datetime, timedelta, timezone
from donations_embed import Client
async def sync_recent_donations(since: datetime) -> None:
async with Client(api_key=os.environ["FLOCK_SECRET_KEY"]) as client:
cursor: str | None = None
while True:
page = await client.list_donations(cursor=cursor, limit=200)
for donation in page.donations:
if donation.created_at < since:
return # Older than the window — done.
await upsert_donation(donation)
if page.next_cursor is None:
return
cursor = page.next_cursor
async def main() -> None:
while True:
since = datetime.now(timezone.utc) - timedelta(hours=1)
await sync_recent_donations(since)
await asyncio.sleep(60)
asyncio.run(main())
package main
import (
"context"
"log"
"os"
"time"
donationsembed "gitlab.com/1st-flock/donations/go/donations-embed"
)
func syncRecentDonations(ctx context.Context, c *donationsembed.Client, since time.Time) error {
var cursor string
for {
page, err := c.ListDonations(ctx, &donationsembed.ListDonationsParams{
Cursor: cursor,
Limit: 200,
})
if err != nil {
return err
}
for _, d := range page.Donations {
if d.CreatedAt.Before(since) {
return nil
}
if err := upsertDonation(ctx, d); err != nil {
return err
}
}
if page.NextCursor == nil {
return nil
}
cursor = *page.NextCursor
}
}
func main() {
c, err := donationsembed.New(os.Getenv("FLOCK_SECRET_KEY"))
if err != nil {
log.Fatal(err)
}
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for range ticker.C {
since := time.Now().UTC().Add(-time.Hour)
if err := syncRecentDonations(context.Background(), c, since); err != nil {
log.Printf("sync error: %v", err)
}
}
}
Refund with retry-on-timeout
A refund call that survives a network blip without double-refunding the donor.
import { Client, idempotencyKey, RateLimitError } from "@1st-flock/donations-embed-node";
const client = new Client({ apiKey: process.env.FLOCK_SECRET_KEY! });
async function refundWithRetry(donationId: string, amountCents: number) {
const key = idempotencyKey("refund");
for (let attempt = 1; attempt <= 5; attempt++) {
try {
return await client.refundDonation(
donationId,
{ amountCents },
{ idempotencyKey: key },
);
} catch (err) {
if (err instanceof RateLimitError) {
await sleep((err.retryAfterSeconds ?? 1) * 1000);
continue;
}
throw err;
}
}
throw new Error("retry budget exhausted");
}
import asyncio
from donations_embed import Client, RateLimitError, idempotency_key
async def refund_with_retry(donation_id: str, amount_cents: int):
key = idempotency_key("refund")
async with Client(api_key=os.environ["FLOCK_SECRET_KEY"]) as client:
for attempt in range(5):
try:
return await client.refund_donation(
donation_id,
amount_cents=amount_cents,
idempotency_key=key,
)
except RateLimitError as exc:
await asyncio.sleep(exc.retry_after or 1.0)
continue
raise RuntimeError("retry budget exhausted")
package main
import (
"context"
"errors"
"fmt"
"time"
donationsembed "gitlab.com/1st-flock/donations/go/donations-embed"
)
func refundWithRetry(ctx context.Context, c *donationsembed.Client, donationID string, amountCents int64) (*donationsembed.RefundResponse, error) {
key := donationsembed.IdempotencyKey("refund")
for attempt := 0; attempt < 5; attempt++ {
resp, err := c.RefundDonation(ctx, donationID,
&donationsembed.RefundParams{AmountCents: &amountCents},
donationsembed.WithIdempotencyKey(key),
)
if err == nil {
return resp, nil
}
var rl *donationsembed.RateLimitError
if errors.As(err, &rl) {
time.Sleep(rl.RetryAfter)
continue
}
return nil, err
}
return nil, fmt.Errorf("retry budget exhausted")
}
Next steps
- Server SDKs — typed clients in TypeScript / Node, Python, and Go.
- Webhooks — receive donation events server-side.
- Operations — versioning, deprecation, rate limits.