Quick Start
Everything on this page has been checked against the running API. Copy-paste it as written.
1. Base URL
Every path in these docs is relative to a versioned base:
| Environment | Base URL |
|---|---|
| Production | https://api.fundlyhub.org/api/v1 |
| Staging | https://api.staging.fundlyhub.org/api/v1 |
Build against staging first — it is the same code with disposable data.
2. Your first request
Listing campaigns needs no credentials at all.
curl "https://api.fundlyhub.org/api/v1/fundraisers?status=active&limit=5" | jq .const API_BASE = 'https://api.fundlyhub.org/api/v1';
const response = await fetch(`${API_BASE}/fundraisers?status=active&limit=5`);
const { data, pagination } = await response.json();
for (const f of data) {
// Every money field is integer cents — divide once, at display.
console.log(`${f.title}: $${f.total_raised_cents / 100} raised of $${f.goal_amount_cents / 100}`);
}
console.log(`${data.length} of ${pagination.total} campaigns`);The response is an object, not an array
GET /fundraisers returns { data, pagination }. Calling .forEach or .map on the parsed body directly throws — read data first.
{
"data": [ { "id": "…", "title": "…", "goal_amount_cents": 5000000, "total_raised_cents": 123450 } ],
"pagination": { "limit": 5, "offset": 0, "total": 213 }
}Money fields are integer cents, and real JSON numbers
goal_amount_cents and total_raised_cents are integers: 5000000 is $50,000.00. Add and compare them directly, and divide by 100 only where you render.
They used to be Postgres numeric columns serialised as strings, so total_raised + goal_amount concatenated rather than added and a progress bar computed that way rendered nonsense. That is no longer possible — but if you have code doing Number(...) defensively around these fields, it is now harmless rather than necessary.
Fields you will actually find on a campaign
The listing returns every public field of the campaign plus a few joined extras. The names that most often trip people up:
| Field | Meaning |
|---|---|
goal_amount_cents | The target, in integer cents (5000000 is $50,000.00). Not goal, and no longer goal_amount. |
total_raised_cents | Raised so far, in integer cents. Not raised. Joined from campaign stats; 0 when there are no donations. |
donor_count | Unique donors, as a number. |
slug | URL slug — use it with GET /fundraisers/slug/:slug. |
currency | ISO code, defaults to USD. |
status | One of draft, active, pending, paused, ended, rejected. |
category_name | Resolved category display name. |
profiles | Owner summary (name, avatar, email_verified), or null. |
Paging and filtering
| Parameter | Default | Notes |
|---|---|---|
limit | 20 | Page size |
offset | 0 | Page offset — this API pages by offset, not page number |
status | none | active on a public request also excludes campaigns whose end_date has passed |
category | none | Accepts a category id, slug, or name |
is_project | none | true for projects only, false for campaigns only; omit for both |
lang | none | Returns translated title/summary where a translation exists |
3. Authenticate
For a script, CLI, or server integration, use an API key. Create one from a signed-in session, then send it as a bearer token:
curl -X POST https://api.fundlyhub.org/api/v1/api-keys \
-b cookies.txt \
-H 'Content-Type: application/json' \
-d '{ "name": "my-integration" }'The api_key field in that 201 response is shown once. Store it, then use it:
export FUNDLYHUB_API_KEY='fh_live_…'
curl -H "Authorization: Bearer $FUNDLYHUB_API_KEY" \
https://api.fundlyhub.org/api/v1/me/capabilitiesA 200 with your roles and permissions means the key works. A 401 means it is invalid, expired, or revoked.
Do not reach for /cognito/me to test a key
GET /cognito/me reads session cookies and ignores the Authorization header entirely, so it returns 401 for every bearer client no matter how valid the key is. /me/capabilities is the bearer-friendly identity probe.
Browser apps use Cognito session cookies instead — sign in with POST /cognito/signin and send credentials: 'include' on every later request. Full details, including how API keys differ from user tokens, are in the Authentication guide.
4. Create a campaign
POST /fundraisers 🔒 Requires authentication and a verified email address — except for drafts: an account that has not verified its email yet may still save up to 5 campaigns with status: 'draft' (details).
const response = await fetch(`${API_BASE}/fundraisers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.FUNDLYHUB_API_KEY}`
},
body: JSON.stringify({
title: 'Help Support Local Food Bank',
slug: 'help-support-local-food-bank',
goal_amount_cents: 5000000, // $50,000.00, in cents
summary: 'Raising funds to support families in need',
status: 'draft'
})
});
const { data } = await response.json();
console.log('Created:', data.id);| Field | Required | Notes |
|---|---|---|
title | Yes | 3–200 characters |
slug | Yes | 3–100 characters, lowercase letters, digits and hyphens only |
goal_amount_cents | Yes | Positive integer, in cents. 5000000 is $50,000.00. |
summary | No | Up to 500 characters |
story_html | No | Long-form description |
currency | No | Defaults to USD |
category | No | Category id, slug, or name |
cover_image | No | URL |
status | No | draft, active, pending, paused, ended |
visibility | No | public, unlisted, private — defaults to public |
end_date | No | Date string |
org_id | No | Attach to an organization you are a member of |
slug is required, and description/goal/category_id are not real fields
A body of { title, description, goal, category_id } fails with 400 Validation error — the field names are summary, goal_amount_cents, and category, and a missing slug is rejected on its own. Check availability first with GET /fundraisers/check-slug/:slug.
Omitting status runs the publish checks
The publish gate reads a missing status as active, so a create call without it runs the full publish checks: profile verification readiness, at least one image, and an automated content assessment. Any failure returns 403 with a blockers array — and an account with an unverified email is refused outright. (A body that passes is then stored as a draft, the schema default, so omitting status gets you neither a quick draft nor a publish.)
Send status: 'draft' explicitly while you are building the integration, and active once the account and the campaign body are complete.
Owner responses wrap the campaign in data. Creation returns 201.
5. List a user's campaigns
There is no /fundraisers/me. A profile's campaigns — owned and endorsed — come from:
curl "https://api.fundlyhub.org/api/v1/users/<id-or-slug>/campaigns?limit=20"It accepts either a profile id or a profile slug, and returns { "data": [ … ] }. It is a public endpoint, but when you authenticate as that profile it also includes your private and unpublished statuses. A private profile returns an empty list to everybody but its owner.
6. Money: one unit, integer cents
Every amount this API takes or returns is an integer number of cents. amount_cents: 5000 is $50.00, on both money-taking endpoints and everywhere else. It is the unit Stripe uses, so nothing is converted in between.
| Endpoint | Amount field | Unit |
|---|---|---|
POST /payments/create-intent | amount_cents, tip_amount_cents | Integer cents |
POST /donations | amount_cents, tip_amount_cents | Integer cents |
POST /payments/create-intent
{
"fundraiser_id": "a1b2c3d4-…",
"amount_cents": 5000,
"tip_amount_cents": 500,
"currency": "usd"
}amount_cents: 5000 is $50.00. Non-integers are rejected with 400, so "50.00" and 50.5 will not reach Stripe. Below Stripe's minimum charge the request fails with a 400 naming the minimum.
The response carries client_secret, payment_intent_id, amount_cents (the total charged), stripe_fee_cents and net_amount_cents.
POST /donations
{
"fundraiser_id": "a1b2c3d4-…",
"amount_cents": 5000,
"tip_amount_cents": 500,
"currency": "USD"
}Same unit, same shape. This endpoint records a row without moving money; fundraiser_id must be a UUID and a 201 returns the created row under data with payment_status: "pending".
If you integrated before the unit change
POST /donations used to take dollars — amount: 50 for a $50 donation — while /payments/create-intent took cents, and both called the field amount. It was the single most common integration bug on this API.
Both now take amount_cents. The field was renamed rather than just re-denominated, deliberately: had amount simply changed meaning, a client still sending 50 would have recorded a 50-cent donation with a 201 and no error anywhere. A request missing amount_cents fails validation instead, and names the field.
The same applies to tip_amount → tip_amount_cents and, on POST /fundraisers, goal_amount → goal_amount_cents.
Both endpoints require a reCAPTCHA v3 token in production
/payments/create-intent and /donations sit behind reCAPTCHA. Send the solved token as recaptcha_token in the body or as an x-recaptcha-token header; without it the response is 400 "reCAPTCHA token required". The one exception is a signed-in session whose email is verified, which may omit the token (a token that is sent is still verified).
That makes these two endpoints browser-widget endpoints in practice — a headless server-to-server client has no way to mint a valid token. POST /donations additionally requires an authenticated caller with a verified email address.
7. Handle errors
Errors are JSON with an error key, and often a message with detail.
const response = await fetch(`${API_BASE}/fundraisers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.FUNDLYHUB_API_KEY}`
},
body: JSON.stringify({ title: 'Too short a body' })
});
if (!response.ok) {
const err = await response.json();
console.error(response.status, err.error, err.details ?? err.message ?? '');
// 400 Validation error [ { path: ['slug'], message: 'Required' }, … ]
}| Code | Meaning |
|---|---|
400 | Validation error — Zod failures come back with a details array naming each bad field |
401 | No credentials, or an invalid/revoked API key |
403 | Authenticated but not allowed — missing permission, unverified email (code: EMAIL_NOT_VERIFIED), a disabled feature flag, or a publish gate with blockers |
404 | Not found |
409 | Conflict — e.g. an email or slug already in use |
429 | Rate limited — read RateLimit-Reset (seconds, not a timestamp) |
500 | Server error |
Next steps
- Authentication — API keys, Cognito sessions, refresh, and CAPTCHA
- Roles & Permissions — what your key is allowed to do
- Account (/me) — everything that belongs to the signed-in account
- Fundraisers API — the full campaign surface
- Donations API — donations and receipts
- Rate Limits — headers, budgets, and backoff
- JavaScript examples and cURL examples