Authentication
FundlyHub identities live in AWS Cognito. The API does not issue its own JWTs: browser sessions and native apps use Cognito-issued tokens, and scripts use FundlyHub API keys.
There are two ways to authenticate a request, and which one you want depends on what you are building:
| You are… | Use | Presented as |
|---|---|---|
| A browser app on a FundlyHub origin | Cognito session cookies | Automatic, credentials: 'include' |
| A script, CLI, server, or third-party integration | An API key | Authorization: Bearer fh_live_… |
| A native or mobile app signing its own users in | A Cognito ID token, obtained from Cognito directly | Authorization: Bearer <id token> — see Native & mobile clients |
All of them resolve to the same account, so every authenticated endpoint accepts any of them.
Base URLs
| Environment | URL |
|---|---|
| Production | https://api.fundlyhub.org/api/v1 |
| Staging | https://api.staging.fundlyhub.org/api/v1 |
How a token is presented
A request carries one of two kinds of credential:
- Session cookies — set by sign-in, sent automatically by a browser on a FundlyHub origin.
Authorization: Bearer <token>, where the token is either:- an API key (
fh_live_…) — refused once it is revoked or expired; or - a Cognito ID token. Send the ID token, not the access token: a Cognito access token is refused with
403 Invalid token.
- an API key (
GET /api/v1/me/capabilities HTTP/1.1
Host: api.fundlyhub.org
Authorization: Bearer fh_live_1a2b3c…Send one kind of credential
A request that carries both FundlyHub session cookies and an Authorization header is authenticated by the session cookies. In a browser context where FundlyHub cookies may be present, send the header from a context that has none.
Third-party and programmatic access
API keys are the supported path for non-browser clients
FundlyHub does have an API-key mechanism, and it is the intended way for scripts, CLIs, servers and third-party integrations to authenticate. Keys are created and revoked by the account that owns them — there is no separate developer-app registration, client id/secret, or OAuth authorization-code flow for third parties.
How an API key differs from a user access token
| Cognito access/ID token | API key (fh_live_…) | |
|---|---|---|
| Issued by | AWS Cognito, on sign-in | The FundlyHub API, on request |
| Format | Signed JWT | Opaque random string, fh_live_ + 64 hex chars |
| Lifetime | 1 hour, then refreshed | No expiry unless you set one |
| Revocation | Sign out / refresh-token revocation | DELETE /api-keys/:id, effective immediately |
| Obtainable without a browser | Not through this API — only from Cognito directly | Yes |
| Renewal needed | Yes, hourly | No |
The decisive practical difference is the last two rows. POST /cognito/signin returns the tokens only as httpOnly Set-Cookie headers — the JSON body contains the user profile and expiresIn, and no token strings at all. POST /cognito/refresh likewise reads the refresh token from the refresh_token cookie and will not accept one from a body or header. A non-browser client therefore cannot obtain or renew a Cognito bearer token through the public API without scraping Set-Cookie and maintaining a cookie jar. An API key has neither problem. A native app that signs its own users in talks to Cognito directly instead — the hosted UI with PKCE, or InitiateAuth — and sends the ID token; see Native & mobile clients.
The session cookies are also set with SameSite=Lax, so they are not sent on cross-site requests from another origin. Cookie auth is a first-party mechanism.
What an API key does not do
A key carries its owner's full authority
Every key currently carries the scope *; scopes are reserved for future use. A key acts as its owner for every endpoint that accepts bearer auth, and inherits whatever roles and permissions that account holds.
Treat a key as equivalent to the account password. Give it to nothing you would not sign in on, scope it by creating it under a purpose-built account rather than an administrator's, and set expires_at when you can.
Create a key
POST /api/v1/api-keys 🔒 Requires Authentication
curl -X POST https://api.fundlyhub.org/api/v1/api-keys \
-b cookies.txt \
-H 'Content-Type: application/json' \
-d '{ "name": "reporting-job", "expires_at": "2027-01-01T00:00:00Z" }'| Field | Required | Notes |
|---|---|---|
name | Yes | Non-empty, 100 characters or fewer. 400 otherwise. |
expires_at | No | Timestamp. Omit or send null for a key that does not expire. |
{
"message": "API key created successfully. Store this key securely — it will not be shown again.",
"api_key": "fh_live_1a2b3c…",
"key": {
"id": "…",
"name": "reporting-job",
"prefix": "fh_live_1a2b",
"scopes": ["*"],
"expires_at": "2027-01-01T00:00:00.000Z",
"created_at": "…"
}
}The full key is returned exactly once
FundlyHub does not keep the key in retrievable form. The api_key field in this 201 response is the only time you can read the full value — if you lose it, revoke the key and create another. Every later response returns prefix only.
List and revoke keys
| Method | Endpoint | Notes |
|---|---|---|
GET | /api-keys | Active keys for the caller. Add ?include_revoked=true to include revoked ones. |
DELETE | /api-keys/:id | Revokes immediately. 404 if the key does not exist or was already revoked. |
The list response returns id, name, prefix, scopes, last_used_at, expires_at, revoked_at and created_at per key, wrapped in data, with a total count. last_used_at is updated on every successful authentication, which makes it the quickest way to spot a key nothing uses any more.
Verify a key works
GET /me/capabilities is the simplest authenticated probe: it needs no permission, and it returns the roles and permissions the key actually resolves to.
curl -H "Authorization: Bearer $FUNDLYHUB_API_KEY" \
https://api.fundlyhub.org/api/v1/me/capabilitiesGET /cognito/me will not work with a bearer token
That endpoint accepts cookie sessions only and answers 401 to a bearer client. Use /me/capabilities instead.
Browser sign-in flow
Sign up
POST /api/v1/cognito/signup
const API_BASE = 'https://api.fundlyhub.org/api/v1';
const response = await fetch(`${API_BASE}/cognito/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
email: 'user@example.com',
password: 'SecureP@ssw0rd!',
name: 'John Doe'
})
});email and password are required; name is optional. Without one the account has no name (name is null) until the person sets it; it is never taken from the email address. A verification code is sent to the address.
ambassadorInviteToken is optional: the token from an ambassador invitation. When it matches the address the account is created with, the ambassador role is granted as part of sign-up.
Password rules come from the Cognito user pool
The API does not impose its own complexity rules — it forwards the password to Cognito and maps a rejection to 400 "Password does not meet requirements". Read the exact policy from your pool configuration rather than assuming a minimum length here.
An address that already has a profile returns 409 with code EMAIL_ALREADY_REGISTERED, whether or not it has been through Cognito. Existing accounts migrate by signing in, not by signing up again.
Confirm
POST /api/v1/cognito/confirm with email and the emailed code. A wrong code returns 400 "Invalid verification code"; an expired one returns 400 "Verification code expired".
Sign in
POST /api/v1/cognito/signin
const response = await fetch(`${API_BASE}/cognito/signin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
email: 'user@example.com',
password: 'SecureP@ssw0rd!'
})
});
const { user, expiresIn } = await response.json();On success the API sets the session cookies and returns:
{
"message": "Signin successful",
"user": { "id": "…", "email": "…", "name": "…", "avatar": null,
"role": "…", "email_verified": true, "profile_slug": "…" },
"expiresIn": 3600
}If Cognito responds with a challenge instead, the body is { "challengeRequired": true, "challengeName": "…", "session": "…" } and no cookies are set.
Cookies that get set
| Cookie | Lifetime | Contents |
|---|---|---|
id_token | 1 hour | Cognito ID token |
access_token | 1 hour | Cognito access token |
refresh_token | 90 days | Cognito refresh token |
All three are httpOnly, SameSite=Lax, Path=/, and Secure in production. JavaScript cannot read them.
Email verification
A new address has to be verified. The verification email links to GET /api/v1/cognito/verify-email?token=…, which marks the address verified and redirects to the website (/?verification=invalid when the token is bad or expired). Links are valid for 24 hours; requesting a new one replaces the old.
POST /api/v1/cognito/resend-verification with { "email": "…" } sends a fresh link. It always answers 200 { "message": "If this email exists, a verification link has been sent." }, so it cannot be used to discover accounts, and it is limited to 5 requests a minute.
Until the address is verified, routes behind the verified-email gate — publishing a campaign, POST /donations, registering an organization, commenting, posting updates, reporting and others — answer:
{
"error": "Email verification required",
"message": "Please verify your email address to perform this action.",
"code": "EMAIL_NOT_VERIFIED",
"action": "verify_email"
}Saving and editing campaign drafts is the exception — see Drafts before email verification.
Adaptive CAPTCHA on sign-up and sign-in
Sign-up and sign-in are not unconditionally CAPTCHA-protected. A risk engine scores each attempt by IP and email, and only then may demand one.
- Low risk — the request proceeds with no CAPTCHA.
- Elevated risk, no
captchaTokensent —429with{ "requiresCaptcha": true, "reason": "…" }. Solve a reCAPTCHA and retry. - Elevated risk,
captchaTokensent and it fails —403with{ "captchaFailed": true }.
Send the solved token as a captchaToken field in the same JSON body as email and password. A client that never handles the requiresCaptcha response will appear to be permanently rate-limited once it trips the risk threshold.
Google and Apple SSO (OAuth 2.0)
GET /api/v1/cognito/oauth/:provider — redirect the browser to it. :provider is google or apple; anything else is 400.
// `redirect` is a path on the FundlyHub site; anything else is replaced by `/`.
window.location.href = `${API_BASE}/cognito/oauth/google?redirect=${encodeURIComponent('/donor')}`;Cognito's hosted UI handles the provider handshake and returns to GET /api/v1/cognito/oauth/callback, which sets the same session cookies and redirects back to the app, at the redirect path. Navigate to the start URL in the browser being signed in: it sets a short-lived cookie that the callback requires, so a sign-in finished in another browser, after about ten minutes, or after a newer one was started is refused and lands on /auth?error=state_mismatch without a session. Nothing token-shaped is exposed to your code, which is why a native app runs the hosted-UI flow itself, with PKCE — see Native & mobile clients.
This is user sign-in, not third-party delegated authorization
This flow federates a user's Google identity into Cognito so they can sign in. It is not an OAuth flow for a third-party application to obtain delegated access to somebody else's FundlyHub account, and there is no consent screen, client registration, or scope negotiation for that. Third-party integrations use an API key issued by the account being integrated.
Current user
GET /api/v1/cognito/me 🔒 Cookie authentication only
const response = await fetch(`${API_BASE}/cognito/me`, { credentials: 'include' });
const { user } = await response.json();The profile is wrapped in a user key — response.json().name is undefined; you want (await response.json()).user.name.
If the short-lived session has expired but the refresh cookie is still valid, this endpoint renews the session and sets new cookies before answering. If that fails it clears the cookies and returns 401.
Refreshing the session
POST /api/v1/cognito/refresh
await fetch(`${API_BASE}/cognito/refresh`, { method: 'POST', credentials: 'include' });Reads the refresh_token cookie, mints a new access_token and id_token, and returns { "message": "Tokens refreshed", "expiresIn": 3600 }. There is no way to pass the refresh token in the body or a header.
401 and 503 mean different things here
401 "Session expired, please sign in again" means Cognito rejected the refresh token — the cookies are cleared and the user must sign in again.
503 "Auth service temporarily unavailable, please retry" means the call to Cognito itself failed. The cookies are deliberately kept, so retry with backoff rather than bouncing the user to a login screen.
Sign out
POST /api/v1/cognito/logout performs a Cognito global sign-out where possible and clears the session cookies. It answers 200 even if the Cognito call fails, so the client can always treat it as terminal.
Signing out does not revoke API keys. Revoke those with DELETE /api-keys/:id.
Password reset
| Method | Endpoint | Body |
|---|---|---|
POST | /cognito/forgot-password | email |
POST | /cognito/reset-password | email, code, newPassword |
POST | /cognito/change-password 🔒 | Requires authentication |
All four are on the auth rate limiter (10 a minute per IP).
Response codes
| Code | Meaning |
|---|---|
200 | Success |
400 | Validation error, invalid email format, bad or expired verification code, or a password Cognito refused |
401 | Invalid credentials, missing token, expired session, or an invalid/revoked API key |
403 | Email not verified, password reset required, or a submitted CAPTCHA that failed |
409 | Email already registered |
429 | Rate limited, or a CAPTCHA is now required — check for requiresCaptcha before backing off |
500 | Server error |
503 | Cognito unreachable on refresh; retry with backoff, cookies are preserved |
401 and 403 are not interchangeable
A missing or unparseable token gives 401. A token that is present but fails verification gives 403 "Invalid token". A permission failure on an authenticated request also gives 403 — see Roles & Permissions.