Skip to content

Donations API ​

Record donations, look up receipts, read a campaign's public donor wall, and read a donor's own giving history.

One money unit: integer cents

Every amount in this API is an integer number of cents. amount_cents: 5000 is $50.00, on every endpoint, in requests and responses alike. It is the unit Stripe uses, so nothing is converted between the API and the payment processor.

Fractional values are rejected: amount_cents: 50.00 is a 400, not a rounding.

Breaking change if you integrated before this

POST /donations used to take DOLLARS while POST /payments/create-intent took cents, and both called the field amount. The fields have been renamed as well as re-denominated:

WasNow
amount (dollars, POST /donations)amount_cents
amount (cents, POST /payments/create-intent)amount_cents
tip_amounttip_amount_cents
goal_amount (POST /fundraisers)goal_amount_cents

The rename is deliberate. Re-denominating amount in place would have meant a client still sending 50 for a $50 donation recording 50 cents, silently, with a 201. With the rename, an un-updated client gets a validation error naming the missing required field.

How a donation is actually taken ​

The money path is the Payments API, not POST /donations:

  1. POST /payments/create-intent — creates the Stripe PaymentIntent and, at the same moment, a pending row in donations. Amounts here are integer cents, and every field is snake_case.
  2. The browser confirms the PaymentIntent with Stripe.js using the returned client_secret.
  3. POST /payments/confirm — verifies the PaymentIntent with Stripe and settles the row to paid.

fundraiser_id, never fundraiserId

POST /payments/create-intent reads fundraiser_id (snake_case). A camelCase fundraiserId is not read at all: the PaymentIntent is then created without a campaign, and the money lands on the platform balance with no campaign attribution and no transfer to the creator. The request still returns 200, so nothing tells you it happened.

The response is snake_case for the same reason — client_secret, payment_intent_id, amount_cents, stripe_fee_cents, net_amount_cents. There is no clientSecret, and no unsuffixed amount.

javascript
const API_BASE = 'https://api.fundlyhub.org/api/v1';

// 1. Create the intent — ALL AMOUNTS IN INTEGER CENTS
const intentRes = await fetch(`${API_BASE}/payments/create-intent`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  credentials: 'include',            // optional — links the gift to a signed-in donor
  body: JSON.stringify({
    amount_cents: 5000,              // $50.00 donation, in cents
    currency: 'usd',
    fundraiser_id: '123e4567-e89b-12d3-a456-426614174000',  // snake_case!
    tip_amount_cents: 500,           // $5.00 platform tip, in cents (default 0)
    donor_email: 'jane@example.com',
    donor_name: 'Jane Smith',
    is_anonymous: false,
    recaptcha_token: token           // or the x-recaptcha-token header
  })
});

const { client_secret, payment_intent_id, amount_cents, stripe_fee_cents, net_amount_cents } =
  await intentRes.json();

// 2. …confirm client_secret with Stripe.js…

// 3. Settle the donation row
const confirmRes = await fetch(`${API_BASE}/payments/confirm`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  credentials: 'include',
  body: JSON.stringify({ payment_intent_id })   // NOT paymentIntentId
});

const { data } = await confirmRes.json();   // the settled donation row

amount_cents, stripe_fee_cents and net_amount_cents in the create-intent response are all integer cents: amount_cents is the total Stripe will charge (amount_cents + tip_amount_cents). POST /payments/confirm responds { data: <donation row> } with campaign_title, campaign_slug, card_brand, card_last4 and payment_method_type folded in. It is idempotent — calling it twice returns the already-paid row.

Failure modes worth handling on create-intent: 400 (amount below Stripe's $0.50 floor, a non-integer amount, or fees consuming the whole donation), 404 (unknown fundraiser_id), 409 (the campaign is not active, or was deleted).

Full parameter reference: POST /payments/create-intent · POST /payments/confirm.

Create Donation ​

POST /api/v1/donations 🔒 Session + verified email

Records a donation row directly, in pending status. This is the bookkeeping endpoint — it does not move money and does not talk to Stripe. Payment capture is the Payments API flow above, and payment_status is only ever advanced to paid / refunded by the Stripe-verified confirm path and the Stripe webhook.

The route requires an authenticated, email-verified session, is gated by the features.donations flag, and enforces reCAPTCHA (action donation) — though, because the session is already email-verified, the token may be omitted; one that is sent is still verified. Any donor_user_id in the body is discarded — the donor is always the authenticated profile.

javascript
const API_BASE = 'https://api.fundlyhub.org/api/v1';

const response = await fetch(`${API_BASE}/donations`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  credentials: 'include',
  body: JSON.stringify({
    fundraiser_id: '123e4567-e89b-12d3-a456-426614174000', // required, uuid
    amount_cents: 5000,     // required, > 0 — integer CENTS ($50.00)
    currency: 'USD',        // defaults to USD
    tip_amount_cents: 500,  // optional platform tip in cents (default 0)
    donor_name: 'Jane Smith',
    donor_email: 'jane@example.com',
    is_anonymous: false,    // default false
    comment: 'Great cause! Keep up the excellent work.', // optional, max 1000 chars
    payment_provider: 'stripe',       // optional
    payment_intent_id: 'pi_3AbCdEf',  // optional
    recaptcha_token: token            // optional for a verified session; verified if sent
  })
});

const { data } = await response.json();
console.log('Donation created:', data.id, 'status:', data.payment_status);

The donor's note field is comment, not message — a message key is silently dropped by validation.

Returns 201 with { data: <donation row> }. Validation failures return 400 with { error: 'Validation error', details: [...] }.

Look up a receipt ​

GET /api/v1/donations/receipt/:receiptId

Public receipt lookup keyed by the Stripe payment-intent id (pi_…) or the stored receipt_id — both high-entropy capability tokens, so this stays unauthenticated for guest donors. Returns an explicit field allowlist (no donor PII beyond what the receipt needs, and no donor_user_id or payment_provider).

Returned fields: id, fundraiser_id, amount_cents, net_amount_cents, fee_amount_cents, tip_amount_cents, currency, donor_name, donor_email, is_anonymous, payment_status, payment_method_type, card_brand, card_last4, receipt_id, created_at, plus fundraiser: { title, slug }. Every money field is integer cents.

javascript
const API_BASE = 'https://api.fundlyhub.org/api/v1';
const receiptId = 'pi_3AbCdEf...';

const response = await fetch(`${API_BASE}/donations/receipt/${receiptId}`);
const { data } = await response.json();
console.log('Amount:', data.amount_cents / 100, data.currency);   // cents -> dollars

Email a receipt ​

POST /api/v1/donations/receipt/email

Mails a donor their own receipt. Optional auth — guest donors have no session. Rate-limited to 5 requests/minute.

The body is exactly two fields: receipt_id and recipient_email. There is no receiptId, no email, and no receipt_data — every figure in the mail is read from the donation named by receipt_id, so nothing you send reaches the message body. Sending the wrong key names returns 400 Missing receipt_id or recipient_email.

javascript
await fetch(`${API_BASE}/donations/receipt/email`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    receipt_id: 'pi_3AbCdEf...',
    recipient_email: 'jane@example.com'
  })
});

The recipient is deliberately free-form ("email it to my accountant"), so the number of sends per donation is capped instead — the 6th send for one donation returns 429.

StatusMeaning
200{ success: true, message: … } — SES accepted the message synchronously.
202{ success: true, queued: true, … } — queued, the background job will send it.
400Missing or malformed receipt_id / recipient_email.
404No such receipt, or that donation was never paid (one answer for both, on purpose).
429This donation has already been emailed the maximum number of times.
502The email provider rejected the message.

A note with the gift ​

EndpointAuthPurpose
POST /donations/receipt/:receiptId/noteThe receipt id (optional session){ "content": "…" } (up to 2,000 characters) adds or replaces the donor's public note; { "retract": true } removes it. At most 3 writes per donation; 5 requests a minute; flag features.comments.
GET /donations/receipt/:receiptId/notePublicThe current note.

Like the receipt lookup, the receipt id is the credential, because most donors have no account. "No such receipt" and "that gift was never paid" are the same 404.

A campaign's donor wall ​

GET /api/v1/fundraisers/:fundraiserId/donations

Public list of a campaign's donations. Supports ?limit (default 20) and ?offset (default 0). Only paid donations are returned — the pending rows created at PaymentIntent time never appear — and anonymous donors are redacted (donor_name and donor_avatar come back null).

The amount_cents on each row is the net figure in cents (donation minus the Stripe fee), so the wall totals agree with what the creator actually receives. Donor email and payment_intent_id are never included.

javascript
const fundraiserId = '123e4567-e89b-12d3-a456-426614174000';
const response = await fetch(
  `${API_BASE}/fundraisers/${fundraiserId}/donations?limit=10`
);
const { data, pagination } = await response.json();
// data[i]: id, fundraiser_id, donor_name, donor_avatar, amount_cents,
//          currency, tip_amount_cents, payment_status, comment,
//          is_anonymous, created_at
// pagination: { limit, offset, total }   ← total counts paid rows only

The recent-gifts feed ​

GET /api/v1/donations/recent

Newest paid gifts platform-wide, for the landing-page hero. Unauthenticated.

QueryDefaultMaxNotes
limit1224Unscoped mode.
slugs—6 slugsComma-separated campaign slugs; switches to per-campaign mode.
perCampaign38Rows per campaign, per-campaign mode only.

Returns { data: [...] }. Each row carries id, amount_cents (net cents), currency, created_at, comment, donor_name, donor_avatar, donor_city, gift_ordinal, fundraiser_slug, fundraiser_title and fundraiser_image_url. Anonymous gifts return null for the donor name, avatar and city. Only active / ended, non-deleted, public campaigns are eligible — unlisted campaigns are excluded here even though they appear in direct-link reads.

A donor's own giving history ​

Authenticated donors read their own history under /donor/me/*:

EndpointPurpose
GET /donor/me/summaryAggregate giving stats. Returns camelCase fields at the top level (no data wrapper): lifetimeAmountCents, donationCount, supportedFundraisers, lastDonationAt. Note the Cents suffix — this one really is cents.
GET /donor/me/donationsPaginated giving history. Query: page, limit, year, status, fundraiserId (camelCase, unlike the path param on the donor wall). Returns { data, pagination: { page, limit, total, totalPages } }.
GET /donor/me/annual-statementYear-end giving summary. Requires year and format=pdf or format=csv; anything else is a 400.
javascript
const response = await fetch(
  `${API_BASE}/donor/me/donations?page=1&limit=20&year=2026`,
  { credentials: 'include' }
);
const { data, pagination } = await response.json();

Removed endpoints

GET /donations/:id and PATCH /donations/:id/status were removed for security. The raw single-donation read leaked donor PII and ignored is_anonymous; the status PATCH had no auth and trusted a client-supplied payment status. Status transitions are owned exclusively by the Stripe-verified POST /payments/confirm path and the Stripe webhook. Donors use /donor/me/* and the receipt-token route above.

Response Codes ​

  • 201 — Donation created
  • 200 — Success
  • 202 — Accepted / queued (receipt email)
  • 400 — Validation error
  • 401 — Authentication required
  • 403 — reCAPTCHA verification failed
  • 404 — Not found
  • 409 — Campaign is not accepting donations
  • 429 — Rate limit exceeded
  • 500 — Server error

Built with VitePress