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:
| Was | Now |
|---|---|
amount (dollars, POST /donations) | amount_cents |
amount (cents, POST /payments/create-intent) | amount_cents |
tip_amount | tip_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:
POST /payments/create-intent— creates the Stripe PaymentIntent and, at the same moment, apendingrow indonations. Amounts here are integer cents, and every field is snake_case.- The browser confirms the PaymentIntent with Stripe.js using the returned
client_secret. POST /payments/confirm— verifies the PaymentIntent with Stripe and settles the row topaid.
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.
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 rowamount_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.
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.
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 -> dollarsEmail 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.
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.
| Status | Meaning |
|---|---|
200 | { success: true, message: … } — SES accepted the message synchronously. |
202 | { success: true, queued: true, … } — queued, the background job will send it. |
400 | Missing or malformed receipt_id / recipient_email. |
404 | No such receipt, or that donation was never paid (one answer for both, on purpose). |
429 | This donation has already been emailed the maximum number of times. |
502 | The email provider rejected the message. |
A note with the gift
| Endpoint | Auth | Purpose |
|---|---|---|
POST /donations/receipt/:receiptId/note | The 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/note | Public | The 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.
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 onlyThe recent-gifts feed
GET /api/v1/donations/recent
Newest paid gifts platform-wide, for the landing-page hero. Unauthenticated.
| Query | Default | Max | Notes |
|---|---|---|---|
limit | 12 | 24 | Unscoped mode. |
slugs | — | 6 slugs | Comma-separated campaign slugs; switches to per-campaign mode. |
perCampaign | 3 | 8 | Rows 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/*:
| Endpoint | Purpose |
|---|---|
GET /donor/me/summary | Aggregate 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/donations | Paginated 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-statement | Year-end giving summary. Requires year and format=pdf or format=csv; anything else is a 400. |
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 created200— Success202— Accepted / queued (receipt email)400— Validation error401— Authentication required403— reCAPTCHA verification failed404— Not found409— Campaign is not accepting donations429— Rate limit exceeded500— Server error