Skip to content

Fundraisers API ​

Create, read, update and delete fundraising campaigns.

Money units

goal_amount_cents is integer cents — 5000000 means $50,000.00. total_raised_cents on a listing row is cents too, as is every other money field on this API.

The field was called goal_amount and held dollars. It was renamed rather than re-denominated in place, so that a client still sending goal_amount fails validation instead of quietly creating a campaign with a goal a hundred times too small.

List Fundraisers ​

GET /api/v1/fundraisers

Paginated list of campaigns. No authentication; the result is the same whoever asks. Only approved campaigns (active / ended) appear; draft, pending, rejected and paused never appear in a listing. Only the parameters below are accepted; any other query parameter is ignored.

Query parameters ​

ParameterNotes
statusactive, closed, ended (same as closed) or all. Default all. "Closed" means enum-ended or an active campaign whose end_date has passed. Any other value is a 400: { "error": "Invalid status", "allowed": ["active", "closed", "ended", "all"] }.
categoryCategory integer id, slug, or name — all three work, and the resolver also matches the singular form. Not a UUID: categories.id is an integer.
qFree-text search, at least 2 characters. Matches title, summary, story_html, category and location, plus the translated title and summary in every language; title matches rank first, then summary matches. % and _ are literal characters. The same matcher as GET /search.
sortraised for most raised first. Anything else is newest first.
is_projecttrue / false (1 / 0 also accepted). Splits projects from ordinary fundraisers. Anything else means no filter.
limit1–100, default 20. A larger value is treated as 100; a missing, zero, negative or non-numeric one as 20.
offset0 or more, default 0. A negative or non-numeric value is treated as 0.
langen, ru, uk or es. Overlays the translated title/summary/story when one exists. Without it, the reader's language is taken from the language cookie, then Accept-Language.
near<lat>,<lng> in decimal degrees (latitude −90..90, longitude −180..180), e.g. 38.5816,-121.4944. Only campaigns within radius_mi of the point, nearest first, then most raised; each row gains distance_mi. Anything else — malformed, out of range, empty, or sent twice — is a 400: { "error": "invalid_near" }. See Near a point.
radius_miMiles around near, 1–100, default 20. A larger value is treated as 100, a smaller one as 1, a non-numeric one as 20. Ignored without near.

The listing only ever returns public campaigns: a visibility parameter is ignored, and unlisted campaigns — reachable by link, deliberately not listed — never appear. There is no free-text location, min_goal or max_goal filter; filter by place with near.

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

const response = await fetch(`${API_BASE}/fundraisers?status=active&limit=20`);
const { data, pagination } = await response.json();

Response shape ​

json
{
  "data": [
    {
      "id": "123e4567-e89b-12d3-a456-426614174000",
      "title": "Help Local Food Bank",
      "slug": "help-local-food-bank",
      "summary": "Supporting families in need",
      "goal_amount_cents": 5000000,
      "currency": "USD",
      "category": "community",
      "category_name": "Community",
      "tags": ["community", "food"],
      "cover_image": "https://example.com/image.jpg",
      "status": "active",
      "visibility": "public",
      "location": "Austin, TX",
      "location_city": "Austin",
      "location_region": "TX",
      "location_country": "US",
      "location_lat": 30.27,
      "location_lng": -97.74,
      "end_date": "2026-12-31",
      "total_raised_cents": 1250000,
      "donor_count": "23",
      "created_at": "2026-01-15T10:30:00Z",
      "profiles": {
        "name": "John Doe",
        "avatar": "https://example.com/avatar.jpg",
        "email_verified": true
      }
    }
  ],
  "pagination": { "limit": 20, "offset": 0, "total": 137 }
}

Each row is a campaign card: the campaign's public fields plus category_name, total_raised_cents, donor_count and the nested profiles object (see FundraiserCard in the API reference). It carries no story body (story_html) and none of the owner-only fields; read the campaign by slug for those. There is no goal, raised, owner or category: { … } object — those field names do not exist.

Money fields are real JSON numbers now

goal_amount_cents and total_raised_cents are integers, so they arrive unquoted and add correctly. They used to be Postgres NUMERIC columns, which the driver serialises as strings — total_raised + tip concatenated rather than adding, which is why older examples coerce with Number(...) first. COUNT values such as pagination.total are still strings.

Near a point ​

GET /api/v1/fundraisers?near=<lat>,<lng>&radius_mi=<miles> is the "Near you" list: active campaigns within 20 miles of the device (or of a city from GET /fundraisers/cities), nearest first.

javascript
const res = await fetch(`${API_BASE}/fundraisers?status=active&near=38.5816,-121.4944&radius_mi=20&limit=20`);
const { data, pagination } = await res.json();
// data[0].distance_mi === 1.4, data[0].location_city === 'Sacramento'
  • Filter. Only campaigns whose point is within the radius (great-circle distance). Every other parameter still applies — status, category, q, is_project — and so do the public-only rules. Pagination (limit, offset, pagination.total) counts the near set.
  • Order. By distance, nearest first, then by money raised, most first. It replaces sort=raised and the search rank.
  • distance_mi. Miles to the campaign's city, 1 decimal. Present only with near.

Where the points come from. location is free text. The server geocodes it (OpenStreetMap's Nominatim) when a campaign is created or its location is edited, in the background — a save never waits for it — and keeps only the city: location_city, location_region, location_country and the city's centroid as location_lat / location_lng, at most 2 decimals. A street address or postcode is reduced to its city's centroid; nothing finer than a city is ever stored or served. A location that is not a place, or names a whole state or country, has no point (nulls) and is never in a near list. Every card carries the five location_* fields, null until geocoded.

Cities with active fundraisers ​

GET /api/v1/fundraisers/cities

Every city with at least one active public campaign, for a city picker. No authentication; cached for up to 5 minutes.

json
{
  "data": [
    { "label": "Sacramento, CA", "city": "Sacramento", "region": "CA", "country": "US",
      "lat": 38.58, "lng": -121.49, "count": 12 },
    { "label": "Kyiv, Ukraine", "city": "Kyiv", "region": "Kyiv", "country": "UA",
      "lat": 50.45, "lng": 30.52, "count": 3 }
  ]
}
  • One row per geocoded city among active, public campaigns (the set ?status=active lists); count is how many.
  • lat / lng is a representative point for the city (the average of its campaigns' points, each of which is the city centroid): pass it as near.
  • label is "City, REGION" in the US and "City, Country" elsewhere.
  • Sorted by count, most first, then label.

Get a single Fundraiser ​

GET /api/v1/fundraisers/:id  ·  GET /api/v1/fundraisers/slug/:slug

Look up by UUID or by slug. Both return { data: <fundraiser row> }.

Only active, ended and paused campaigns are readable anonymously, and only when visibility is not private. unlisted stays readable — that visibility means "reachable by direct link, just not listed". Everything else (draft, pending, rejected, or any private campaign) returns 404 to anyone but the owner — 404 rather than 403, so the endpoint does not confirm that the id exists.

When the authenticated caller is the campaign owner, the response also carries the private fields they entered, such as beneficiary_contact, and a trustStatus object (aiDecision, trustScore, payoutHold, rejectionReasons, userMessages). Other viewers, signed in or not, never see them.

javascript
const API_BASE = 'https://api.fundlyhub.org/api/v1';
const slug = 'help-local-food-bank';

const response = await fetch(`${API_BASE}/fundraisers/slug/${slug}`);
if (!response.ok) {
  if (response.status === 404) console.error('Not found, or not publicly readable');
  throw new Error('Failed to fetch fundraiser');
}

const { data } = await response.json();
console.log('Goal:', data.goal_amount_cents / 100);   // cents -> dollars

Campaign statistics ​

GET /api/v1/fundraisers/:id/stats

Returns { data: { fundraiser_id, title, goal_amount_cents, total_raised_cents, total_tips_cents, donation_count, donor_count, percentage_funded } }, or 404 if the campaign has no stats row. Amounts are integer cents; percentage_funded is a percentage and is unaffected by the unit.

Check slug availability ​

GET /api/v1/fundraisers/check-slug/:slug

The slug must be at least 3 characters of lowercase letters, digits and hyphens — anything else is a 400 before the lookup runs. Pass ?exclude=<fundraiserId> when checking on behalf of an existing campaign.

Responds unwrapped (no data envelope): { "available": true }, or { "available": false, "suggestion": "help-local-food-bank-2" }.

Create Fundraiser ​

POST /api/v1/fundraisers 🔒 Session + verified email (drafts excepted)

Gated by the features.fundraiser_creation flag.

An account whose email is not yet verified may still create a campaign with status: "draft" sent explicitly, up to 5 drafts at a time; anything else is 403 EMAIL_NOT_VERIFIED (or 403 UNVERIFIED_DRAFT_LIMIT at the cap). See Drafts before email verification.

Required fields

title, slug and goal_amount_cents. That is the whole required set.

description, goal and category_id are not fields this API has — they are silently dropped by validation, so a create that sends them succeeds with an empty body and a failed goal. The long-form body is story_html, the short one is summary, the target is goal_amount_cents, and the category is category (a text value: id, slug or name).

owner_user_id is ignored if you send it — the server always sets the owner to the authenticated profile.

FieldTypeNotes
titlestringRequired. 3–200 characters.
slugstringRequired. 3–100 characters, ^[a-z0-9-]+$. A duplicate is a 409.
goal_amount_centsintegerRequired. Cents, > 0. 5000000 is $50,000.00.
summarystringUp to 500 characters.
story_htmlstringRich-text body; sanitized server-side.
currencystringDefaults to USD.
categorystringCategory id, slug or name.
tagsstring[]
cover_imagestringURL or relative path.
imagesstring[]Gallery.
video_urlstring
statusenumdraft, active, pending, paused, ended. rejected cannot be self-assigned. Send it explicitly — see the note below.
visibilityenumpublic (default), unlisted, private.
beneficiary_name, beneficiary_contactstring
locationstringFree text.
end_datestringA real calendar date (YYYY-MM-DD), empty string or null. 2026-02-30 is a 400.
org_iduuidThe caller must be a member of that organization, or the request is a 403.
typeenumpersonal (default), for_others, charity.
is_projectbooleanDefault false.
cover_image_focal_x, cover_image_focal_ynumber0–1. Auto-computed from cover_image when omitted.
milestonesobject[]Optional. title, description, targetAmount, dueDate (also accepted snake_case).
javascript
const API_BASE = 'https://api.fundlyhub.org/api/v1';

const response = await fetch(`${API_BASE}/fundraisers`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  credentials: 'include',
  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',
    story_html: '<p>Our local food bank serves 400 families a week…</p>',
    category: 'community',
    cover_image: 'https://example.com/image.jpg',
    location: 'Austin, TX',
    tags: ['community', 'food', 'families'],
    end_date: '2026-12-31'
  })
});

if (!response.ok) {
  const error = await response.json();
  console.error(error.error, error.details ?? error.blockers ?? '');
  throw new Error('Failed to create fundraiser');
}

const { data } = await response.json();
console.log('Created:', data.id, data.status);   // 'draft' unless you asked for 'active'

Returns 201 with { data: <fundraiser row> }.

Send status explicitly

When status is omitted, the publish checks below run as though you had sent active — so an incomplete profile or a missing image is a 403 — but a body that passes them is stored with the schema default, draft, unless the review demotes it to pending. An unverified account is refused outright. Send status: "draft" to save work in progress, and status: "active" to publish.

Creating directly as active ​

Sending status: 'active' puts the campaign through the publish gate before it is created:

  • Incomplete profile verification → 403 with a blockers array.
  • No cover_image and no images → 403 with blockers: ['fundraiser_image_required'].
  • The automated reasonability and trust review can demote the campaign to status: 'pending' (manual review). This is not an error — you get a normal 201, and the returned row says pending. Always read data.status rather than assuming the status you asked for.

Identical creates from the same owner (same title and goal) within 60 seconds are treated as a double-submit: the existing row is returned instead of a second campaign being created.

Update Fundraiser ​

PATCH /api/v1/fundraisers/:id 🔒 Session + verified email (drafts excepted)

The method is PATCH. An unverified account may edit a campaign that is still a draft, as long as the body does not send a non-draft status; publishing, submitting for review and editing anything already published need a verified email. Owner only. Accepts the same fields as create, all optional, except slug and owner_user_id, which cannot be changed. Returns { data: <updated row> }.

javascript
const response = await fetch(`${API_BASE}/fundraisers/${fundraiserId}`, {
  method: 'PATCH',
  headers: { 'Content-Type': 'application/json' },
  credentials: 'include',
  body: JSON.stringify({
    title: 'Updated: Help Support Local Food Bank',
    story_html: '<p>Updated story with more detail…</p>',
    goal_amount_cents: 7500000,
    status: 'active'
  })
});

Status transitions have their own rules:

  • status: 'active' runs the same publish gate as create. Here an AI reject is a hard 403 carrying rejectionReasons, suggestions and trustScore; a pending_review outcome quietly stores pending instead of active.
  • status: 'draft' on a campaign that has collected money is a 400 — you may pause or end it instead, but not hide it from donors holding receipts.
  • 403 if you are not the owner, 404 if the campaign does not exist.

Delete Fundraiser ​

DELETE /api/v1/fundraisers/:id 🔒 Requires authentication

Soft delete. Owner only, and refused with 400 once the campaign has received any money.

Success is 200, not 204, with an unwrapped body:

json
{
  "success": true,
  "campaignId": "123e4567-e89b-12d3-a456-426614174000",
  "slug": "help-local-food-bank",
  "deletedAt": "2026-09-04T11:02:13.441Z"
}
EndpointPurpose
GET /fundraisers/{fundraiserId}/donationsThe public donor wall — see Donations.
GET /fundraisers/:id/activityLifecycle timeline with actor identity. Owner only; returns { activity: [...] }.
POST /fundraisers/:id/assessRun the trust assessment before publishing. Owner only.
GET /locationsUS states with active campaigns, as { data: [{ code, name, count }] }, parsed from the free-text location. For a city-level picker use GET /fundraisers/cities and filter the listing with near.
GET /fundraisers/:id/endorsementsThe public Endorsed-by list, { endorsers, viewerHasEndorsed }. Endorsing and asking ambassadors to share are on Ambassadors.
POST /projects/:fundraiserId/updatesPost an update to supporters — see Notifications & campaign updates.
POST /fundraisers/:id/reportReport a campaign to the moderators (verified email).

There is no GET /fundraisers/me and no POST /fundraisers/stats batch endpoint. To list one user's campaigns use GET /users/:id/campaigns (accepts a UUID or a profile slug, returns { data: [...] }, and publishes nothing for a private profile unless the caller is its owner) — see User Profiles. For a single campaign's numbers use GET /fundraisers/:id/stats above.

Response Codes ​

  • 200 — Success
  • 201 — Created
  • 400 — Validation error, or a state rule (unpublishing / deleting a funded campaign)
  • 401 — Authentication required
  • 403 — Not the owner, publish gate blocked, not a member of the target org, or EMAIL_NOT_VERIFIED / UNVERIFIED_DRAFT_LIMIT
  • 404 — Not found, or not readable by this caller
  • 409 — Slug already exists, or an organization approval condition blocks it
  • 429 — Rate limit exceeded
  • 500 — Server error

Built with VitePress