Skip to content

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:

EnvironmentAPI base URL
Productionhttps://api.fundlyhub.org/api/v1
Staginghttps://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:

FileURL
JSONhttps://docs.fundlyhub.org/openapi.json
YAMLhttps://docs.fundlyhub.org/openapi.yaml
Postman collectionhttps://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 ​

http
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 Authorization header is 401; a token that is present but invalid or expired is 403 { "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 default HTTPCookieStorage, 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):

  1. Generate a code_verifier and its S256 code_challenge.

  2. 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>. Use identity_provider=SignInWithApple for Apple. For Google, the website also adds prompt=select_account so the user can switch accounts.

  3. On the redirect, check state, then exchange the code:

    http
    POST 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_token and expires_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:

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

  1. 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 answer 403 EMAIL_NOT_VERIFIED — except saving campaign drafts, which is allowed up to 5.

  2. Sign in one of two ways:

    • With Cognito directly, InitiateAuth with AuthFlow: USER_PASSWORD_AUTH and 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 httpOnly Set-Cookie headers (id_token and access_token for one hour, refresh_token for 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/signin and /cognito/signup sit behind a risk engine: under elevated risk they answer 429 { "requiresCaptcha": true } until the request carries a reCAPTCHA token as captchaToken. Cognito's own InitiateAuth has no such gate.

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 withNotes
With Cognito (hosted UI or InitiateAuth)Cognito: InitiateAuth with REFRESH_TOKEN_AUTH, or POST https://auth.fundlyhub.org/oauth2/token with grant_type=refresh_tokenReturns a new ID and access token; keep the refresh token.
Through POST /cognito/signin (cookies)POST /cognito/refreshReads 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 tokenAPI cookie session
How you get itFrom Cognito (hosted UI, InitiateAuth)POST /cognito/signin
Google / AppleYes, hosted UI + PKCENo — the cookie flow is browser-only
RefreshAgainst CognitoPOST /cognito/refresh
GET /cognito/meSend the tokens as cookiesWorks as-is
Recommended for an appYesWorkable 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/capabilities answers the user's roles and permissions; use it to decide what to show, as the website does. See Account.
  • GET /notifications answers 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.

StyleRequestResponseUsed 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 pageGET /me/campaign-updates (next_cursor), GET /users/:id/activity (nextCursor, hasMore)
  • Most endpoints clamp limit to 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) and GET /organizations/me.
  • On some older list endpoints pagination.total arrives as a string. Parse it rather than assuming a number.

Errors ​

Errors are JSON with an error string, and often more beside it:

json
{
  "error": "Email verification required",
  "message": "Please verify your email address to perform this action.",
  "code": "EMAIL_NOT_VERIFIED",
  "action": "verify_email"
}
FieldWhen
errorAlways. Short; for some routes it is a machine-style token (invalid_user_id, forbidden).
messageOften. Human-readable.
codeWhen the client is expected to branch: EMAIL_NOT_VERIFIED, UNVERIFIED_DRAFT_LIMIT, ACCOUNT_TOO_NEW, EMAIL_ALREADY_REGISTERED, invalid_range, …
actionWhat to offer the user, e.g. verify_email.
required_permissionOn a permission 403.
detailsValidation issues, on routes that validate with a schema.
feature_keyOn a 403 from a feature flag that is off.
retryAfterOn 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, PUT and DELETE are safe to retry. Likes are PUT/DELETE on a sub-resource for exactly this reason, and mark-read endpoints land on the same state however often they are sent.
  • POST creates are not idempotent. A retried POST /fundraisers, POST /fundraisers/:id/comments or POST /projects/:id/updates after 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-intent creates 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 on POST /payments/create-intent and POST /donations. A guest — or an account whose email is not verified yet — still needs a token (recaptcha_token in the body, or the x-recaptcha-token header): missing is 400 "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 is 403.

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:

  1. A signed-in session whose email is verified. Send the Bearer token and no captcha fields. Nothing else to do.
  2. 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 ​

  1. keyId = try await DCAppAttestService.shared.generateKey(). Keep keyId (Keychain). It is standard base64 of 32 bytes.
  2. 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.
  3. 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).
  4. POST /app-attest/attest with { "key_id": keyId, "attestation": attestation.base64EncodedString(), "challenge": challenge } → 204.

Every donation request: sign the body ​

  1. POST /app-attest/challenge → a fresh challenge (one per request, single use, 5 minutes).
  2. Build the JSON body with "app_attest_challenge": challenge added to the usual fields, and serialise it once to bodyData.
  3. clientDataHash = SHA256(bodyData), which is SHA-256 over the exact bytes you will send. assertion = try await DCAppAttestService.shared.generateAssertion(keyId, clientDataHash: clientDataHash).
  4. Send bodyData unchanged (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.

ResponseMeaningWhat the app does
2xxAccepted, with no captcha—
403 code: APP_ATTEST_KEY_UNKNOWNKey not registered (new server, wiped data, other environment)Generate and attest a new key, then retry
403 code: APP_ATTEST_CHALLENGE_INVALIDChallenge unknown, expired or already usedFetch a new challenge, sign again
403 code: APP_ATTEST_INVALIDBad signature, counter not increasing, malformedRetry once with a new challenge. If it fails again, ask the person to sign in
400 reCAPTCHA token requiredApp Attest is off on the server (headers ignored), or no headers sentAsk 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.

Built with VitePress