Skip to content

Ambassador Program & Portal ​

Ambassadors are people who carry campaigns to new donors. Anyone signed in can share a personal /r/ referral link; the ambassador role adds a portal for reading what those links did, the right to endorse campaigns, and a place in the public ambassador directory.

This page covers the whole lifecycle: applying, being invited, accepting the invitation, endorsing, and reading the portal.

Reference sectionOperationsAbout
Endorsements5Ambassadors publicly vouching for other people's campaigns, and creators asking ambassadors to promote theirs.
Ambassadors14The ambassador programme: the public directory, invitations and applications, referral links and click tracking, and the ambassador portal under `/me/referrals/*`.

The role and its permissions ​

ambassador is a global role. It grants exactly two permissions:

PermissionMeans
view_own_referral_portalMay open the portal and read their own referral data (/me/referrals/*, /me/campaigns).
endorse_campaignsMay endorse a published campaign (POST/DELETE /fundraisers/:id/endorse).

The role is granted by FundlyHub: through an approved application on an existing account, a redeemed invitation, or directly. The profile's account_kind = 'ambassador' badge is display only — it grants nothing.

Applying ​

POST /ambassador-applications — public, optional auth, 5 requests a minute (the strict limiter).

FieldRules
fullName2–120 characters
emailA valid address, up to 254 characters
location2–160 characters
socialMediaOptional; a URL up to 500 characters, or ""
experience50–5,000 characters
motivation50–5,000 characters
bash
curl -X POST https://api.fundlyhub.org/api/v1/ambassador-applications \
  -H 'Content-Type: application/json' \
  -d '{
    "fullName": "Dana Ortiz",
    "email": "dana@example.com",
    "location": "Austin, TX",
    "socialMedia": "https://instagram.com/dana",
    "experience": "Five years organising neighbourhood food drives and school fundraisers.",
    "motivation": "I want the campaigns in my city to reach people outside our own circles."
  }'
StatusMeaning
201{ "received": true } — nothing else, deliberately.
400A field failed validation; error names it, e.g. "motivation: String must contain at least 50 character(s)".
409That address already holds the role, or already has an application in review.

The form works signed out. When a session is present, the application records who filed it, because a signed-in person can apply on behalf of another address.

FundlyHub reviews each application. Approval has two outcomes, decided at approval time by whether the applicant's address has an account:

  • No account — an invitation is created and emailed (next section). The role is granted when it is redeemed during registration.
  • Has an account — the role is granted directly and an acceptance email is sent. No invitation, because an existing address can never redeem one.

Invitations ​

An invitation is a deferred role grant: a single-use token bound to one email address, valid for 14 days. FundlyHub sends invitations; the invited person accepts one with:

EndpointAuthPurpose
POST /ambassador-invites/redeemSigned in{ "token": "…" } — accept your own invitation.

Redeeming. The invitation link opens the sign-up page with an ambassador_invite query parameter. There are two ways the token is spent:

  1. Email and password sign-up sends it as ambassadorInviteToken in the POST /cognito/signup body, and the role is granted as the account is created.
  2. Google or Apple sign-up never reaches /cognito/signup, so the client keeps the token and calls POST /ambassador-invites/redeem once signed in.

Either way the token is checked against the address the account was actually created with. A forwarded link redeems for nobody else.

POST /ambassador-invites/redeem answers 200 even when nothing is granted, because a client calls it whenever it holds a token, including right after the sign-up body already spent it:

json
{ "granted": true }
json
{ "granted": false, "reason": "used" }

reason is one of no_token, not_found, used, revoked, expired or email_mismatch. A missing session is 401; a missing token is 400.

Endorsing campaigns ​

EndpointAuthPurpose
POST /fundraisers/:id/endorseendorse_campaigns{ "note"?: "…" } (up to 280 characters). 201 with { endorsement, created: true } the first time; 200 with created: false when you edit the note.
DELETE /fundraisers/:id/endorseendorse_campaigns{ "revoked": true }.
GET /fundraisers/:id/endorsementsPublic{ endorsers, viewerHasEndorsed } — the campaign's Endorsed-by list.

You always endorse as yourself; there is no user id in the path. Only a campaign a visitor could already find can be endorsed — public, and live or ended. Refusals: 404 when the campaign does not exist, 409 when it is not endorsable or is your own. Both write endpoints use the authenticated limiter (100 a minute). Revoking keeps a record; endorsing again later is allowed.

An endorser of a campaign is anyone other than its organizer who either declared an endorsement here or sent at least one real (human-classified) visitor to it through their referral link. Both count, on every surface that shows endorsers.

Creators can ask ambassadors to share a campaign. These endpoints need no permission, only ownership of the campaign:

EndpointPurpose
GET /ambassadors/selectable?limit=The picker: ambassadors you can ask (default 24, max 60), with their referred totals. Authenticated because the totals are not public.
POST /fundraisers/:id/endorsement-requests{ "ambassadorIds": ["…"] } — at most 12 per request; requires a verified email. Ids that are not ambassadors, or are you, are dropped and returned in rejected.
GET /fundraisers/:id/endorsement-requestsThe requests made for your campaign.

Each asked ambassador gets one email with the campaign's share kit (they can turn these off with notify_endorsement_requests). Requests on a campaign that is not yet public wait, and are sent when it is approved.

EndpointAuthPurpose
POST /referrals/codesSigned in{ "fundraiser_id"?: "…" } — get or create your code for a campaign (or a profile-level code when omitted). Returns { code }; the share link is https://fundlyhub.org/r/<code>.
POST /referrals/clicksWebsite onlyCalled by the website when someone opens an /r/ link. Not for integrations. Rate-limited per caller, 120 a minute.

A donation is attributed to the last human (or unclassified) click on one of your links within 30 days, matched by referral code or visitor id. Bot and link-preview clicks are counted but never attributed. Self-referred gifts are listed in your portal but excluded from your totals.

The portal: /me/referrals/* ​

All seven endpoints need a signed-in caller with view_own_referral_portal, and every one is scoped to the session — there is no user id in the path.

EndpointWindowReturns (data)
GET /me/referrals/summaryYestotals (clicks by traffic type, distinct human visitors, driven gifts, raised_cents, tips_cents, distinct donors, refunds, human conversion rate, currencies), the three-step funnel, and a daily time_series.
GET /me/referrals/campaignsYesCampaigns you sent traffic to: clicks by type, distinct human visitors, driven gifts, raised_cents, last click.
GET /me/referrals/campaigns/{fundraiserId}No (all time)Your link on one campaign: impressions (visits not identified as a bot), clicks_by_traffic_type, distinct human visitors, driven gifts, distinct donors, funds_attributed (donation + tip per currency, cents), your referral_code / referral_url for it when one exists, and as_of. A campaign you may not open is 404. This is what the bar on a campaign page shows you.
GET /me/referrals/trafficYesby_source, by_referer, by_medium, traffic_type, plus Google Analytics geography and devices when available (ga_note says why when not).
GET /me/referrals/giftsYesThe attributed donations themselves. ?limit= (default 50, max 200), ?offset=. Also returns total and drivenTotal.
GET /me/referrals/standingYesYour rank, total_ambassadors, and the leaderboard (capped; truncated says so).
GET /me/campaignsNoCampaigns you own, with lifetime totals.

The from / to window ​

The five windowed endpoints take ?from= and ?to=, any date string Date parses (ISO-8601 is safest):

  • to defaults to now; from defaults to 28 days before to.
  • from must be before to.
  • from may not be earlier than 2020-01-01 — the floor that "All time" starts at. An earlier from is refused, not clamped, so a long range is never silently shortened.

Every windowed response echoes the window it used as range: { from, to }. A bad window is 400 with code: "invalid_range" and the reason in error ("from must be before to", "from must be on or after 2020-01-01", …).

bash
curl -H "Authorization: Bearer $FUNDLYHUB_API_KEY" \
  "https://api.fundlyhub.org/api/v1/me/referrals/summary?from=2026-09-01T00:00:00Z&to=2026-10-01T00:00:00Z"
json
{
  "data": {
    "totals": {
      "clicks_by_traffic_type": { "human": 412, "bot": 96, "unknown": 7 },
      "distinct_human_visitors": 301,
      "driven_gifts": 18,
      "raised_cents": 214500,
      "tips_cents": 21900,
      "distinct_donors": 17,
      "refunded_count": 0,
      "refunded_amount_cents": 0,
      "human_conversion_rate": 0.0598,
      "currencies": ["usd"]
    },
    "funnel": [ { "key": "clicks", "count": 515 }, { "key": "human_visitors", "count": 301 }, { "key": "driven_gifts", "count": 18 } ],
    "time_series": [ { "date": "2026-09-01", "clicks_human": 12, "clicks_bot": 3, "clicks_unknown": 0, "driven_gifts": 1, "raised_cents": 5000 } ]
  },
  "range": { "from": "2026-09-01T00:00:00.000Z", "to": "2026-10-01T00:00:00.000Z" }
}

Money is integer cents. When currencies has more than one entry, the totals add different currencies together, so say so rather than printing one figure.

/me/referrals/gifts never returns a donor's email address, and an anonymous gift's donor_name is null.

Public reads ​

EndpointAuthPurpose
GET /ambassadors?limit=PublicThe directory behind the home-page rail: name, city and money driven.
GET /platform/ambassadorsPublicEveryone holding the ambassador role, with their figures — the rail on FundlyHub's own profile (/@fundlyhub). It reads the role itself, so it includes ambassadors who have not driven a gift yet.
GET /users/:id/impactPublicAn ambassador's Impact block on their profile. (/ambassadors/:id/impact was removed.)
GET /ambassadors/:id/analyticsThe ambassador themselfThe full per-ambassador analytics.

Built with VitePress