Skip to content

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…UsePresented as
A browser app on a FundlyHub originCognito session cookiesAutomatic, credentials: 'include'
A script, CLI, server, or third-party integrationAn API keyAuthorization: Bearer fh_live_…
A native or mobile app signing its own users inA Cognito ID token, obtained from Cognito directlyAuthorization: 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 ​

EnvironmentURL
Productionhttps://api.fundlyhub.org/api/v1
Staginghttps://api.staging.fundlyhub.org/api/v1

How a token is presented ​

A request carries one of two kinds of credential:

  1. Session cookies — set by sign-in, sent automatically by a browser on a FundlyHub origin.
  2. 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.
http
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 tokenAPI key (fh_live_…)
Issued byAWS Cognito, on sign-inThe FundlyHub API, on request
FormatSigned JWTOpaque random string, fh_live_ + 64 hex chars
Lifetime1 hour, then refreshedNo expiry unless you set one
RevocationSign out / refresh-token revocationDELETE /api-keys/:id, effective immediately
Obtainable without a browserNot through this API — only from Cognito directlyYes
Renewal neededYes, hourlyNo

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

bash
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" }'
FieldRequiredNotes
nameYesNon-empty, 100 characters or fewer. 400 otherwise.
expires_atNoTimestamp. Omit or send null for a key that does not expire.
json
{
  "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 ​

MethodEndpointNotes
GET/api-keysActive keys for the caller. Add ?include_revoked=true to include revoked ones.
DELETE/api-keys/:idRevokes 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.

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

GET /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

javascript
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

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

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

CookieLifetimeContents
id_token1 hourCognito ID token
access_token1 hourCognito access token
refresh_token90 daysCognito 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:

json
{
  "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 captchaToken sent — 429 with { "requiresCaptcha": true, "reason": "…" }. Solve a reCAPTCHA and retry.
  • Elevated risk, captchaToken sent and it fails — 403 with { "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.

javascript
// `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

javascript
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

javascript
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 ​

MethodEndpointBody
POST/cognito/forgot-passwordemail
POST/cognito/reset-passwordemail, code, newPassword
POST/cognito/change-password 🔒Requires authentication

All four are on the auth rate limiter (10 a minute per IP).

Response codes ​

CodeMeaning
200Success
400Validation error, invalid email format, bad or expired verification code, or a password Cognito refused
401Invalid credentials, missing token, expired session, or an invalid/revoked API key
403Email not verified, password reset required, or a submitted CAPTCHA that failed
409Email already registered
429Rate limited, or a CAPTCHA is now required — check for requiresCaptcha before backing off
500Server error
503Cognito 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.

Built with VitePress