Authentication
The Wefunder API uses OAuth 2.0. Most integrations use the Authorization Code flow with PKCE: the token acts on behalf of a Wefunder user (an investor, a syndicate lead, or a founder) with exactly the scopes that user granted your app.
A Client Credentials flow is also available for server-to-server access with no user
present. Client-credentials tokens can only hold public scopes (read:public), which
cover the public deal catalog. They cannot read anyone's profile, investments, syndicate,
or company data. Use the Authorization Code flow for anything tied to a user.
Get your credentials
Create an application in the developer portal: choose
a name, the scopes you need, the client type, and a redirect URI if users will authorize through OAuth (required for a public client; a confidential client may leave it blank when its installs and tokens come from the portal, and an app with no scope ticked is public-catalog-only: read:public through client credentials, no callback, no user consent). The scopes you tick are the ones a user
can later grant through the Authorization Code flow. read:public is not in the picker
because every app gets it automatically; it is the only scope the Client Credentials
grant can use. See which grant gets which scopes.
Creating an app mints two OAuth clients at once, a test client and a live client, and shows both secrets on the next screen:
- The live secret (
sk_live_) is shown once. Only its hash is stored; if you lose it, rotate the key from the app page. - The test secret (
sk_test_) stays retrievable from the app page, so you can paste it into integration tests on another machine later.
Store the live secret before leaving that screen.
Who can enter the portal:
- Anyone who manages a company or a syndicate on Wefunder can create apps right away.
- Everyone else joins the waitlist and gets portal access on approval.
Your live key works immediately on the companies and syndicates your organization
manages, and the people who manage your organization (developer, admin, or owner) can
authorize your app for their own accounts right away. Until the app passes review, that is
also the limit: anyone else who opens your authorize link sees a page saying the app hasn't
been reviewed yet, and no token is issued — the code exchange and refresh are refused for
them too. Installing your app on someone else's company or syndicate likewise requires app
review. Submit for review from the app's page in the portal; once approved, anyone can
connect. Test (pk_test_) clients are not gated: sandbox accounts belong to your own realm.
Endpoints
| Purpose | URL |
|---|---|
Authorize, live (pk_live_ client) | https://wefunder.com/oauth/authorize |
Authorize, test (pk_test_ client) | https://oauth.wefunder-sandbox.com/oauth/authorize |
| Token exchange and refresh | https://api.wefunder.com/oauth/token |
| API base | https://api.wefunder.com |
There is one token endpoint for both environments. The credential prefix decides where the request goes. Resource URLs carry no version segment; see Versioning.
Authorization Code with PKCE
PKCE is required for every authorization request. Generate a code_verifier and its
S256 code_challenge per request.
Step 1: Redirect the user to authorize
https://wefunder.com/oauth/authorize?
response_type=code&
client_id=pk_live_YOUR_CLIENT_ID&
redirect_uri=YOUR_REDIRECT_URI&
scope=read:profile read:investments&
state=RANDOM_STATE&
code_challenge=BASE64URL_SHA256_OF_VERIFIER&
code_challenge_method=S256
After the user approves, Wefunder redirects to your redirect_uri with ?code=...&state=....
Check state before continuing. While your app is unreviewed, a user outside your
organization never reaches the consent screen or your redirect_uri (see above), and a
code or refresh token exchanged for such a user is refused with invalid_grant.
Step 2: Exchange the code for tokens
curl -X POST "https://api.wefunder.com/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=AUTHORIZATION_CODE" \
-d "redirect_uri=YOUR_REDIRECT_URI" \
-d "client_id=pk_live_YOUR_CLIENT_ID" \
-d "client_secret=sk_live_YOUR_CLIENT_SECRET" \
-d "code_verifier=YOUR_CODE_VERIFIER"
Public clients (browser or mobile apps) omit client_secret; PKCE is what protects them.
Response:
{
"access_token": "at_live_abc123...",
"token_type": "Bearer",
"expires_in": 7200,
"refresh_token": "rt_live_def456...",
"scope": "read:profile read:investments",
"created_at": 1705420800
}
Access tokens live for two hours. When one expires, or a request returns 401, exchange
the refresh token for a new pair:
curl -X POST "https://api.wefunder.com/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "refresh_token=rt_live_def456..." \
-d "client_id=pk_live_YOUR_CLIENT_ID" \
-d "client_secret=sk_live_YOUR_CLIENT_SECRET"
Public clients omit client_secret here too, exactly as in the code exchange.
Response:
{
"access_token": "at_live_ghi789...",
"token_type": "Bearer",
"expires_in": 7200,
"refresh_token": "rt_live_jkl012...",
"scope": "read:profile read:investments",
"created_at": 1705428000
}
Every refresh rotates the refresh token. The response carries a new refresh_token,
and the one you sent stops working the first time the new access token is used. Store the
new access token and the new refresh token together, in one write, before you make another
call. A client that keeps presenting the old refresh token is turned away with
invalid_grant, and the user has to authorize again.
Step 3: Call the API
curl "https://api.wefunder.com/users/me" \
-H "Authorization: Bearer at_live_abc123..."
Client Credentials (public catalog only)
curl -X POST "https://api.wefunder.com/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=pk_live_YOUR_CLIENT_ID" \
-d "client_secret=sk_live_YOUR_CLIENT_SECRET" \
-d "scope=read:public"
Requesting any non-public scope with this grant fails. The token can call GET /explore,
GET /offerings/{id}, GET /companies/{id} and the other public-catalog endpoints. A
client-credentials token lives 24 hours; cache it and re-mint on expiry or on the first 401.
Publishable key (public catalog, no token)
For public reads you don't need a token at all. Send the client id itself as the bearer:
curl "https://api.wefunder.com/companies/co_8Kd0aB3xQ9k2vF8mNp1zT5wY" \
-H "Authorization: Bearer pk_live_YOUR_CLIENT_ID"
The client id is not a secret, so this works from a browser, a phone, or a static page
(Show Your Raise on Your Own Site is one fetch). It reaches
exactly what a read:public token reaches: GET /explore, GET /offerings/{id},
GET /companies/{id}, GET /companies/search and the other public-catalog reads. Anything
else answers 403 insufficient_scope with a note that a publishable key reads public data only.
Cross-origin, GET requests to the API answer for any origin, so a page on any domain can
make these reads directly; there is no domain to register. Writes and every other method
answer cross-origin only from the origins of your app's registered redirect URIs.
Everything that applies to a token applies to the key: a pk_test_ key reaches your sandbox
realm and is refused on live, a deleted app's key answers 401, and a disabled app's key
answers 403. It is rate-limited per key (300 requests per 5 minutes) on top of the per-IP
limits; see Rate limiting. The client secret is a different
string and never belongs in a browser.
Credential and token prefixes
Every identifier tells you what it is and which environment it belongs to:
| Prefix | What it is |
|---|---|
pk_live_ / pk_test_ | Client ID. Also the publishable key: usable as a bearer for public reads |
sk_live_ / sk_test_ | Client secret |
at_live_ / at_test_ | Access token (Bearer) |
rt_live_ / rt_test_ | Refresh token |
_test_ identifiers belong to your sandbox realm. _live_
identifiers act on real Wefunder accounts.
Scopes
Apps request scopes at authorization time. The default scope is read:profile. See
Scopes for the full catalog and which scopes are self-serve
versus approval-gated.
Common failures
| Status | error.type | Cause |
|---|---|---|
| 401 | unauthorized | Token missing, invalid, or expired; or a publishable key that is unknown, archived, or the wrong mode for this cluster |
| 403 | insufficient_scope | Token valid, but it was not granted the scope this endpoint needs; or a publishable key sent to a non-public endpoint |
| 403 | forbidden | Token has the scope, but the user or app lacks access to that resource |
Every error body carries a request_id. See Error handling.
Security practices
- Keep
client_secretserver-side. Never ship it in a browser or mobile app; use PKCE for public clients. - Store secrets in a secrets manager. Rotate on staff changes and incidents.
- Log the
request_idfrom error bodies. It is the fastest path to a support answer. - Build against
_test_credentials first and switch to live only when ready.