Skip to content

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:

EnvironmentBase URL
Productionhttps://api.fundlyhub.org/api/v1
Staginghttps://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.

bash
curl "https://api.fundlyhub.org/api/v1/fundraisers?status=active&limit=5" | jq .
javascript
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.

json
{
  "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:

FieldMeaning
goal_amount_centsThe target, in integer cents (5000000 is $50,000.00). Not goal, and no longer goal_amount.
total_raised_centsRaised so far, in integer cents. Not raised. Joined from campaign stats; 0 when there are no donations.
donor_countUnique donors, as a number.
slugURL slug — use it with GET /fundraisers/slug/:slug.
currencyISO code, defaults to USD.
statusOne of draft, active, pending, paused, ended, rejected.
category_nameResolved category display name.
profilesOwner summary (name, avatar, email_verified), or null.

Paging and filtering ​

ParameterDefaultNotes
limit20Page size
offset0Page offset — this API pages by offset, not page number
statusnoneactive on a public request also excludes campaigns whose end_date has passed
categorynoneAccepts a category id, slug, or name
is_projectnonetrue for projects only, false for campaigns only; omit for both
langnoneReturns 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:

bash
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:

bash
export FUNDLYHUB_API_KEY='fh_live_…'

curl -H "Authorization: Bearer $FUNDLYHUB_API_KEY" \
  https://api.fundlyhub.org/api/v1/me/capabilities

A 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).

javascript
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);
FieldRequiredNotes
titleYes3–200 characters
slugYes3–100 characters, lowercase letters, digits and hyphens only
goal_amount_centsYesPositive integer, in cents. 5000000 is $50,000.00.
summaryNoUp to 500 characters
story_htmlNoLong-form description
currencyNoDefaults to USD
categoryNoCategory id, slug, or name
cover_imageNoURL
statusNodraft, active, pending, paused, ended
visibilityNopublic, unlisted, private — defaults to public
end_dateNoDate string
org_idNoAttach 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:

bash
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.

EndpointAmount fieldUnit
POST /payments/create-intentamount_cents, tip_amount_centsInteger cents
POST /donationsamount_cents, tip_amount_centsInteger cents

POST /payments/create-intent ​

json
{
  "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 ​

json
{
  "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.

javascript
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' }, … ]
}
CodeMeaning
400Validation error — Zod failures come back with a details array naming each bad field
401No credentials, or an invalid/revoked API key
403Authenticated but not allowed — missing permission, unverified email (code: EMAIL_NOT_VERIFIED), a disabled feature flag, or a publish gate with blockers
404Not found
409Conflict — e.g. an email or slug already in use
429Rate limited — read RateLimit-Reset (seconds, not a timestamp)
500Server error

Next steps ​

Built with VitePress