Building a Native or Mobile Client
What an iOS, Android or desktop app needs to know that a browser app gets for free: how to sign a user in and keep them signed in, where the machine-readable spec is, and the conventions for paging, errors, rate limits and retries.
Everything here applies to both environments:
| Environment | API base URL |
|---|---|
| Production | https://api.fundlyhub.org/api/v1 |
| Staging | https://api.staging.fundlyhub.org/api/v1 |
A native client sends no Origin header, and the API's CORS policy lets requests without one through, so no allow-listing is needed.
The machine-readable spec
The complete OpenAPI 3.0 description of every endpoint the API serves is published on this site, next to the pages you are reading:
| File | URL |
|---|---|
| JSON | https://docs.fundlyhub.org/openapi.json |
| YAML | https://docs.fundlyhub.org/openapi.yaml |
| Postman collection | https://docs.fundlyhub.org/fundlyhub-api.postman_collection.json |
The YAML is the source; the JSON is generated from it on every build. A CI check fails if the router serves a route the spec does not document, or the spec documents one the router does not serve, so the spec is the complete list. Feed it to a code generator (for example swift-openapi-generator) or read it directly. Every operation has a stable operationId, which is also its page in the reference.
Signing users in
The API does not issue tokens of its own. Identities live in AWS Cognito; the API verifies Cognito-signed tokens and maps the token's sub to a FundlyHub profile. A native app gets its tokens from Cognito directly and sends them to the API as a bearer token.
What the API accepts
GET /api/v1/me/capabilities HTTP/1.1
Host: api.fundlyhub.org
Authorization: Bearer <Cognito ID token>- Send the ID token, not the access token. A Cognito access token is answered
403 Invalid token. - The ID token must have been issued to FundlyHub's app client — the same client the website uses. A token from another app client is refused.
- A missing or malformed
Authorizationheader is401; a token that is present but invalid or expired is403 { "error": "Invalid token" }. Treat both as "refresh, then retry once" (below). - If a request also carries FundlyHub session cookies, the cookies are used and the header is ignored. A native HTTP stack with a shared cookie store (
URLSession's defaultHTTPCookieStorage, for one) can pick cookies up from a sign-in through the API; use an ephemeral session or clear them if you authenticate with the header.
Google and Apple: the hosted UI with PKCE
Use Cognito's hosted UI, authorization-code grant with PKCE, in the system browser sheet (ASWebAuthenticationSession on iOS, Custom Tabs on Android):
Generate a
code_verifierand its S256code_challenge.Open
https://auth.fundlyhub.org/oauth2/authorize?response_type=code&client_id=<client id>&redirect_uri=<your app's redirect URI>&scope=openid+email+profile&identity_provider=Google&code_challenge=<challenge>&code_challenge_method=S256&state=<random>. Useidentity_provider=SignInWithApplefor Apple. For Google, the website also addsprompt=select_accountso the user can switch accounts.On the redirect, check
state, then exchange the code:httpPOST https://auth.fundlyhub.org/oauth2/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code&client_id=<client id>&code=<code>&redirect_uri=<same URI>&code_verifier=<verifier>The answer has
id_token,access_token,refresh_tokenandexpires_in.
FundlyHub's app client is a public client — the API's own code exchange sends only client_id, no secret — which is why PKCE is the protection here. Your app's redirect URI has to be registered on that app client. The client's configuration lives in the Cognito console, not in this repository, so ask the FundlyHub team to register the URI (and for the client id; the CLI's config carries the production default).
Do not drive GET /cognito/oauth/:provider from an app. That is the website's flow: it ends by setting httpOnly cookies inside the browser sheet and redirecting to a web page, and no token reaches your code.
If a native (email and password) account exists at the same address and was never verified, Cognito refuses to link the Google or Apple identity into it; the redirect carries an error_description containing UNVERIFIED_ACCOUNT_EXISTS. Ask the user to verify that account's email first, or to sign in with the password.
Create the profile after the first social sign-in
A first Google or Apple sign-in creates a Cognito user but no FundlyHub profile. Until the profile exists, writes that need one fail. The website creates it in its OAuth callback; a native client calls GET /cognito/me once after the first sign-in:
GET /api/v1/cognito/me HTTP/1.1
Host: api.fundlyhub.org
Cookie: id_token=<ID token>; access_token=<access token>/cognito/me reads cookies only — it ignores Authorization — so send the two tokens as a Cookie header. It provisions the profile when there is none (Google and Apple addresses count as verified) and answers { user } with the profile id, name, avatar, email_verified and auth_provider. It is safe to call on every launch.
Email and password
Sign up through the API:
POST /cognito/signup{ "email", "password", "name"? }. This creates the Cognito user, confirms it, creates the profile and sends FundlyHub's verification email, so the user can sign in immediately. Until they click the link, routes behind the verified-email gate answer403 EMAIL_NOT_VERIFIED— except saving campaign drafts, which is allowed up to 5.Sign in one of two ways:
- With Cognito directly,
InitiateAuthwithAuthFlow: USER_PASSWORD_AUTHand FundlyHub's client id (no secret hash). You get the three tokens and use the bearer flow above. This is what the FundlyHub CLI does. - Through the API,
POST /cognito/signin{ "email", "password" }. The tokens come back only as httpOnlySet-Cookieheaders (id_tokenandaccess_tokenfor one hour,refresh_tokenfor 90 days), never in the body. A native client can run on that cookie session — keep the cookies in its store and send them back — but it then refreshes through the API (below), not through Cognito.
POST /cognito/signinand/cognito/signupsit behind a risk engine: under elevated risk they answer429 { "requiresCaptcha": true }until the request carries a reCAPTCHA token ascaptchaToken. Cognito's ownInitiateAuthhas no such gate.- With Cognito directly,
The sign-up page sends an ambassador invitation as ambassadorInviteToken; after a Google or Apple sign-up, redeem it with POST /ambassador-invites/redeem instead.
Staying signed in
ID and access tokens last one hour.
| You signed in… | Refresh with | Notes |
|---|---|---|
With Cognito (hosted UI or InitiateAuth) | Cognito: InitiateAuth with REFRESH_TOKEN_AUTH, or POST https://auth.fundlyhub.org/oauth2/token with grant_type=refresh_token | Returns a new ID and access token; keep the refresh token. |
Through POST /cognito/signin (cookies) | POST /cognito/refresh | Reads the refresh_token cookie only. 401 means sign in again; 503 means Cognito was unreachable — keep the cookies and retry with backoff. |
Refresh before exp, or on a 401/403 from the API, then retry the request once. The refresh token's validity is set on the Cognito app client.
Signing out: with Cognito tokens, call Cognito's GlobalSignOut (or the hosted UI's /logout) and discard the tokens. With cookies, POST /cognito/logout signs out globally and clears them.
Cookies or bearer — which to use
| Bearer ID token | API cookie session | |
|---|---|---|
| How you get it | From Cognito (hosted UI, InitiateAuth) | POST /cognito/signin |
| Google / Apple | Yes, hosted UI + PKCE | No — the cookie flow is browser-only |
| Refresh | Against Cognito | POST /cognito/refresh |
GET /cognito/me | Send the tokens as cookies | Works as-is |
| Recommended for an app | Yes | Workable for email-only apps |
API keys (fh_live_…) are not for end-user apps. They carry their owner's full authority and are meant for scripts and servers — see Authentication.
After sign-in
GET /me/capabilitiesanswers the user's roles and permissions; use it to decide what to show, as the website does. See Account.GET /notificationsanswers both badge counts (unreadCount,updatesUnreadCount) in one request; read state is server-side, so it is shared with the website. See Notifications.- Campaign drafts can be saved before the email is verified: Drafts before email verification.
Language
Send the user's language with ?lang=en|ru|uk|es or Accept-Language. Campaign text, updates and search cards are overlaid with a translation when one exists. With neither, campaign content comes back in the language it was written in, and error messages in English.
Pagination
Lists page in one of three styles. Each operation's reference page says which it uses.
| Style | Request | Response | Used by, for example |
|---|---|---|---|
| Offset | ?limit=&offset= | { data, pagination: { limit, offset, total } } | GET /fundraisers, GET /organizations, GET /fundraisers/:id/comments, a campaign's donor wall |
| Page | ?page=&limit= (1-based) | { data, pagination: { page, limit, total, totalPages } } | GET /donor/me/donations |
| Cursor | ?cursor= | the next cursor, null on the last page | GET /me/campaign-updates (next_cursor), GET /users/:id/activity (nextCursor, hasMore) |
- Most endpoints clamp
limitto their maximum rather than refusing it; read the page size back from the response. - A cursor is opaque. Pass back exactly what you were given; a cursor the endpoint did not issue is a
400, never a silent first page. - Some lists are not paginated and answer a bare array — for example a campaign's update feed (
GET /projects/:fundraiserId/updates) andGET /organizations/me. - On some older list endpoints
pagination.totalarrives as a string. Parse it rather than assuming a number.
Errors
Errors are JSON with an error string, and often more beside it:
{
"error": "Email verification required",
"message": "Please verify your email address to perform this action.",
"code": "EMAIL_NOT_VERIFIED",
"action": "verify_email"
}| Field | When |
|---|---|
error | Always. Short; for some routes it is a machine-style token (invalid_user_id, forbidden). |
message | Often. Human-readable. |
code | When the client is expected to branch: EMAIL_NOT_VERIFIED, UNVERIFIED_DRAFT_LIMIT, ACCOUNT_TOO_NEW, EMAIL_ALREADY_REGISTERED, invalid_range, … |
action | What to offer the user, e.g. verify_email. |
required_permission | On a permission 403. |
details | Validation issues, on routes that validate with a schema. |
feature_key | On a 403 from a feature flag that is off. |
retryAfter | On a 429, in seconds. |
Branch on code, never on the wording of error or message. Every response also carries an X-Request-ID header (send your own and it is echoed back); include it when reporting a problem.
Status codes follow the usual meanings, with three specifics: 401 is "no credentials", 403 is "credentials present but invalid, or not allowed", and an endpoint that hides whether something exists answers 404 for both "missing" and "not yours" (organization admin slugs, likes on hidden content). A 500 from an unexpected failure may not have a JSON body, so check the content type before parsing.
Rate limits
Limits are per minute, mostly per IP. Every limited response carries RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset (seconds from now) and RateLimit-Policy; a 429 adds Retry-After. Back off on Retry-After.
On mobile networks many users share one IP behind carrier NAT, and the per-IP buckets are shared with them — including the "authenticated" bucket, which is currently keyed by IP only. Expect a 429 before your own app has made the nominal number of requests, retry after the header's delay with jitter, and do not poll in tight loops. Full table: Rate Limits.
Retries and idempotency
The API has no Idempotency-Key header. Retry safety comes from how each endpoint is shaped:
GET,PUTandDELETEare safe to retry. Likes arePUT/DELETEon a sub-resource for exactly this reason, and mark-read endpoints land on the same state however often they are sent.POSTcreates are not idempotent. A retriedPOST /fundraisers,POST /fundraisers/:id/commentsorPOST /projects/:id/updatesafter a dropped response can create a second row. Before retrying, read back (for example the user's drafts or the comment list) and only resend if the first write did not land.- Payments are keyed by the Stripe PaymentIntent.
POST /payments/create-intentcreates the intent and a pending donation row; after Stripe confirms the payment on the device,POST /payments/confirm{ "payment_intent_id": "…" }upgrades that row to paid. Confirm is idempotent — for an intent that is already recorded as paid it returns the same row — so retry confirm, never create-intent, and keep one intent per checkout. reCAPTCHA: a native app cannot produce a reCAPTCHA v3 token, so a request from a signed-in session (Bearer token) whose email is verified may omit it onPOST /payments/create-intentandPOST /donations. A guest — or an account whose email is not verified yet — still needs a token (recaptcha_tokenin the body, or thex-recaptcha-tokenheader): missing is400 "reCAPTCHA token required", so ask the person to sign in (or verify their email) before donating. If a token is sent it is still checked, and below the score threshold is403.
Donating without reCAPTCHA (iOS)
POST /payments/create-intent and POST /donations normally need a reCAPTCHA v3 token, which a native app cannot produce. Two things replace it:
- A signed-in session whose email is verified. Send the Bearer token and no captcha fields. Nothing else to do.
- Apple App Attest, for guests (or anyone). The app proves it is a genuine FundlyHub build with a Secure Enclave key that Apple has certified. The steps are below.
Without either, a guest gets 400 "reCAPTCHA token required".
Once per install: attest a key
keyId = try await DCAppAttestService.shared.generateKey(). KeepkeyId(Keychain). It is standard base64 of 32 bytes.POST /app-attest/challenge(no body) →200 { "challenge": "<43 chars>", "expires_in_seconds": 300 }. The challenge is base64url text. Use the string as is and do not decode it.clientDataHash = SHA256(Data(challenge.utf8)), which is SHA-256 over the UTF-8 bytes of the challenge string.attestation = try await DCAppAttestService.shared.attestKey(keyId, clientDataHash: clientDataHash).POST /app-attest/attestwith{ "key_id": keyId, "attestation": attestation.base64EncodedString(), "challenge": challenge }→204.
Every donation request: sign the body
POST /app-attest/challenge→ a freshchallenge(one per request, single use, 5 minutes).- Build the JSON body with
"app_attest_challenge": challengeadded to the usual fields, and serialise it once tobodyData. clientDataHash = SHA256(bodyData), which is SHA-256 over the exact bytes you will send.assertion = try await DCAppAttestService.shared.generateAssertion(keyId, clientDataHash: clientDataHash).- Send
bodyDataunchanged (Content-Type: application/json) with:X-App-Attest-Key-Id: <keyId>X-App-Attest-Assertion: <assertion.base64EncodedString()>
The server recomputes SHA256(raw body), verifies the signature over SHA256(authenticatorData ‖ clientDataHash) with the key it stored, checks the App ID hash and that the counter went up, and spends the challenge. Re-encoding the body after signing breaks the signature, even if only the key order or whitespace changes.
| Response | Meaning | What the app does |
|---|---|---|
2xx | Accepted, with no captcha | — |
403 code: APP_ATTEST_KEY_UNKNOWN | Key not registered (new server, wiped data, other environment) | Generate and attest a new key, then retry |
403 code: APP_ATTEST_CHALLENGE_INVALID | Challenge unknown, expired or already used | Fetch a new challenge, sign again |
403 code: APP_ATTEST_INVALID | Bad signature, counter not increasing, malformed | Retry once with a new challenge. If it fails again, ask the person to sign in |
400 reCAPTCHA token required | App Attest is off on the server (headers ignored), or no headers sent | Ask the person to sign in |
503 code: APP_ATTEST_DISABLED from /app-attest/* | Not configured on this server (no Team ID or bundle id) | Ask the person to sign in |
An invalid assertion never blocks a request that passes another way. A signed-in, verified user still gets through.
Related
- Authentication — the full sign-in, cookie and API-key reference
- Account (/me) · Notifications & Campaign Updates · Comments, Updates & Likes
- Rate Limits · Interactive Explorer