Your Wefunder data,in your tools.
Connect your Wefunder portfolio, syndicate, or raise to the tools you already use.
Scoped access. Live webhooks. Human-approved writes.
Who this is for
Investors
Sync your investments and positions into a portfolio tracker. Follow companies. Pull the pitch, Form C disclosures, updates, and Q&A for any offering on Wefunder.
Syndicate leads
Run your syndicate from your own site or tools: members, deals to close, investor exports, statistics, and portfolio, all through one installed app.
Founders
See your campaign's investments as they land, sync them into your CRM or spreadsheet, and get a webhook the moment one arrives. Install from the portal, no OAuth to write.
Create an app, then make your first call
Almost everything in the API belongs to a user, a company, or a syndicate, and your app reaches one by being installed on it. An installation is the connection: your app, one user or company or syndicate, and the scopes they granted. It shows up under their Installed apps, routes their webhooks to you, and lasts until either side revokes it. A token is what you call with, and every token comes from an installation. Public deal data is the one exception: it needs no install at all.
Create an app
Register your app in the developer portal. You get a test
key (pk_test_ / sk_test_) and a live key (pk_live_ / sk_live_) the moment it exists.
Live keys work right away on companies and syndicates your own organization manages; app review
is only needed to be installed on someone else's.
Pick your first call
What you build decides which token you need. Three tracks; each ends in a request that returns something real. Founders syncing their own company have a shorter route that never leaves the portal: the recipe does the install and the token from there.
| You are building | First call | Track |
|---|---|---|
| Deal discovery, no Wefunder user involved | GET /explore | Explore the catalog |
| A view of an investor's own holdings | GET /portfolio | Read a user's portfolio |
| Your own company's investments in a CRM or spreadsheet | GET /investments | Sync investments to your CRM (portal-first, no OAuth) |
| Tooling for a company or syndicate you run or serve, from code | GET /investments, GET /syndicates/{id}/deals | Act for a company or syndicate |
Explore the catalog
No user, no install. Exchange your key for a read:public token and read what an anonymous
visitor sees on wefunder.com/explore. Private rounds are never included.
- cURL
- JavaScript
- Python
- Ruby
# Exchange your key for an app-only token
curl -X POST "https://api.wefunder.com/oauth/token" \
-d "grant_type=client_credentials" \
-d "client_id=pk_test_..." \
-d "client_secret=sk_test_..." \
-d "scope=read:public"
# List open offerings
curl "https://api.wefunder.com/explore" \
-H "Authorization: Bearer at_test_..."
import { Wefunder } from "@wefunder/sdk"; // npm install @wefunder/sdk
const wf = await Wefunder.fromClientCredentials({
clientId: process.env.WEFUNDER_CLIENT_ID,
clientSecret: process.env.WEFUNDER_CLIENT_SECRET,
scopes: ["read:public"],
});
for await (const offering of wf.offerings.all()) {
console.log(offering.id, offering.attributes?.company_name);
}
import os
from wefunder import Wefunder # pip install wefunder
wf = Wefunder.from_client_credentials(
client_id=os.environ["WEFUNDER_CLIENT_ID"],
client_secret=os.environ["WEFUNDER_CLIENT_SECRET"],
scopes=["read:public"],
)
for offering in wf.offerings.all():
print(offering.id, offering.attributes.company_name)
require "wefunder" # gem install wefunder
wf = Wefunder::Client.from_client_credentials(
client_id: ENV.fetch("WEFUNDER_CLIENT_ID"),
client_secret: ENV.fetch("WEFUNDER_CLIENT_SECRET"),
scopes: ["read:public"]
)
wf.offerings.all.each do |offering|
puts "#{offering.id} #{offering.attributes.company_name}"
end
This is the whole track. For filters, offering detail, and pagination see the Explore API guide. An app-only token cannot read anything tied to a user; for that, keep going.
Read a user's portfolio
The investor authorizes your app once through OAuth 2.0 Authorization Code with PKCE. That
consent screen is the installation on the user. There is nothing to call on /installations for
this case; the token you get back already reaches what that user can see, and only that.
- cURL
- JavaScript
- Python
- Ruby
# 1. Send the investor here (sandbox: oauth.wefunder-sandbox.com)
https://wefunder.com/oauth/authorize?
response_type=code&client_id=pk_live_...&redirect_uri=YOUR_REDIRECT_URI&
scope=read:investments&state=RANDOM_STATE&
code_challenge=BASE64URL_SHA256_OF_VERIFIER&code_challenge_method=S256
# 2. Exchange the code
curl -X POST "https://api.wefunder.com/oauth/token" \
-d "grant_type=authorization_code" -d "code=AUTHORIZATION_CODE" \
-d "redirect_uri=YOUR_REDIRECT_URI" -d "client_id=pk_live_..." \
-d "client_secret=sk_live_..." -d "code_verifier=YOUR_CODE_VERIFIER"
# 3. Read their holdings
curl "https://api.wefunder.com/portfolio" \
-H "Authorization: Bearer at_live_..."
import { Wefunder, generatePkce, createAuthorizationUrl, exchangeCode } from "@wefunder/sdk";
// 1. Send the investor to consent. Keep `state` and the PKCE verifier in their session.
// (A pk_test_ client id picks the sandbox authorize host for you.)
const pkce = generatePkce();
const state = crypto.randomUUID();
const authorizationUrl = createAuthorizationUrl({
clientId: process.env.WEFUNDER_CLIENT_ID,
redirectUri: REDIRECT_URI,
scopes: ["read:investments"],
state,
pkce,
});
// 2. On the callback, exchange the code (after checking `state` matches the session)
const tokens = await exchangeCode({
clientId: process.env.WEFUNDER_CLIENT_ID,
clientSecret: process.env.WEFUNDER_CLIENT_SECRET,
code,
redirectUri: REDIRECT_URI,
codeVerifier: pkce.codeVerifier,
});
// 3. Read their holdings. The SDK refreshes and rotates the tokens; persist every set it hands you.
const wf = new Wefunder({
tokens,
clientId: process.env.WEFUNDER_CLIENT_ID,
clientSecret: process.env.WEFUNDER_CLIENT_SECRET,
store: { save: (next) => persistTokens(next) },
});
const portfolio = await wf.portfolio.get();
console.log(portfolio.attributes);
import os, secrets
from wefunder import Wefunder, generate_pkce, create_authorization_url, exchange_code
# 1. Send the investor to consent. Keep `state` and the PKCE verifier in their session.
# (A pk_test_ client id picks the sandbox authorize host for you.)
pkce = generate_pkce()
state = secrets.token_urlsafe(32)
authorization_url = create_authorization_url(
client_id=os.environ["WEFUNDER_CLIENT_ID"],
redirect_uri=REDIRECT_URI,
scopes=["read:investments"],
state=state,
pkce=pkce,
)
# 2. On the callback, exchange the code (after checking `state` matches the session)
tokens = exchange_code(
client_id=os.environ["WEFUNDER_CLIENT_ID"],
client_secret=os.environ["WEFUNDER_CLIENT_SECRET"],
code=code,
redirect_uri=REDIRECT_URI,
code_verifier=pkce.code_verifier,
)
# 3. Read their holdings. The SDK refreshes and rotates the tokens; persist every set it hands you.
wf = Wefunder(
tokens=tokens,
client_id=os.environ["WEFUNDER_CLIENT_ID"],
client_secret=os.environ["WEFUNDER_CLIENT_SECRET"],
store=token_store, # any object with save(tokens)
)
portfolio = wf.portfolio.get()
print(portfolio.attributes)
require "securerandom"
require "wefunder"
# 1. Send the investor to consent. Keep `state` and the PKCE verifier in their session.
# (A pk_test_ client id picks the sandbox authorize host for you.)
pkce = Wefunder.generate_pkce
state = SecureRandom.urlsafe_base64(32)
authorization_url = Wefunder.create_authorization_url(
client_id: ENV.fetch("WEFUNDER_CLIENT_ID"),
redirect_uri: REDIRECT_URI,
scopes: ["read:investments"],
state: state,
pkce: pkce
)
# 2. On the callback, exchange the code (after checking `state` matches the session)
tokens = Wefunder.exchange_code(
client_id: ENV.fetch("WEFUNDER_CLIENT_ID"),
client_secret: ENV.fetch("WEFUNDER_CLIENT_SECRET"),
code: code,
redirect_uri: REDIRECT_URI,
code_verifier: pkce.code_verifier
)
# 3. Read their holdings. The SDK refreshes and rotates the tokens; persist every set it hands you.
wf = Wefunder::Client.new(
tokens: tokens,
client_id: ENV.fetch("WEFUNDER_CLIENT_ID"),
client_secret: ENV.fetch("WEFUNDER_CLIENT_SECRET"),
store: token_store # any object with save(tokens)
)
portfolio = wf.portfolio.get
puts portfolio.attributes
The access token lasts two hours; the refresh token renews it until the investor revokes your app
under Installed apps. GET /portfolio/positions lists each position with per-security detail.
Portfolio endpoints are beta: shapes may still change. See
Investor portfolio and
Authentication.
Act for a company or syndicate
Here the installation is explicit, and it mints a token that acts as the company or syndicate rather than as a person. This track is live-only today: installs and installation tokens are not yet available in sandbox, so use your live key on a company or syndicate your organization manages.
The code below is the syndicate case, installed from a user token. Syncing your own company?
You do not need any of it: Sync investments to your CRM installs
and mints the token from the portal with no OAuth, then calls GET /investments. Come back here
when you install on many companies from code; for a company the same calls take
target_type: "company", a company id, and read:investments.
Install your app on the company or syndicate
Two ways, same result. From the portal: pick the app in the sidebar, open its Installations
page, and press Install next to one of your own companies or syndicates; or copy the install
link there and send it to the founder or syndicate lead, who signs in, picks the target, and consents.
From code: with a user token holding read:installations write:installations (both: listing
needs the read scope and the write scope does not imply it), list the companies and syndicates
that user may install on, then install. The list holds only companies and syndicates, so it is
empty for a user who runs neither. Step by step, both ways: Install your app.
- cURL
- JavaScript
- Python
- Ruby
curl "https://api.wefunder.com/installations/eligible" \
-H "Authorization: Bearer $USER_TOKEN"
curl -X POST "https://api.wefunder.com/installations" \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"target_type":"syndicate","target_id":"SYNDICATE_ID","scopes":["read:syndicates"]}'
// wf holds the manager's user token (read:installations write:installations)
const targets = await wf.installations.eligibleTargets({ target_type: "syndicate" });
for (const target of targets) console.log(target.id, target.name, target.tier);
const install = await wf.installations.create({
target_type: "syndicate", target_id: "SYNDICATE_ID", scopes: ["read:syndicates"],
});
console.log(install.data.id, install.token.access_token); // the token appears here once
wf = Wefunder(access_token=USER_TOKEN) # read:installations write:installations
for target in wf.installations.eligible_targets("syndicate"):
print(target.id, target.name, target.tier)
install = wf.installations.create(
{"target_type": "syndicate", "target_id": "SYNDICATE_ID", "scopes": ["read:syndicates"]}
)
print(install.data.id, install.token.access_token) # the token appears here once
wf = Wefunder::Client.new(access_token: USER_TOKEN) # read:installations write:installations
wf.installations.eligible_targets(target_type: "syndicate").each do |target|
puts [target.id, target.name, target.tier].join(" ")
end
install = wf.installations.create(target_type: "syndicate", target_id: "SYNDICATE_ID",
scopes: ["read:syndicates"])
puts install.data.id, install.token.access_token # the token appears here once
Who may install: anyone who can edit the company, or a syndicate manager. An install never grants more than the installer holds.
Get the installation token
A programmatic install returns the token in its response, once. The install link does not
show a token. If your app has a setup URL, Wefunder sends the person there after consent with
the new installation_id; portal-created apps cannot set one yet, so plan on the fallback: list
your app's installations with the manager's user token, take the new install's id, and mint.
The credential is the response's token.access_token; the data object is the install itself.
- cURL
- JavaScript
- Python
- Ruby
# Find the active install on this syndicate (the list is newest-first and includes
# revoked rows and user installs, so match on target and status, never on position)
INSTALLATION_ID=$(curl -s "https://api.wefunder.com/installations" \
-H "Authorization: Bearer $USER_TOKEN" \
| jq -r '.data[] | select(.attributes.status == "active" and .attributes.target.id == "SYNDICATE_ID") | .id')
[ -n "$INSTALLATION_ID" ] || { echo "not installed on SYNDICATE_ID yet"; exit 1; }
# Mint its token; the credential is .token.access_token in the response
curl -X POST "https://api.wefunder.com/installations/$INSTALLATION_ID/tokens" \
-H "Authorization: Bearer $USER_TOKEN"
// Match the active install on this syndicate; the list is newest-first and also
// holds revoked rows and user installs, so never pick by position.
const installs = await wf.installations.list();
const install = installs.data.find(
(i) => i.attributes.status === "active" && i.attributes.target.id === "SYNDICATE_ID",
);
if (!install) throw new Error("not installed on SYNDICATE_ID yet");
const minted = await wf.installations.mintToken(install.id);
const INSTALLATION_TOKEN = minted.token.access_token; // store this; it is shown once
# Match the active install on this syndicate; the list is newest-first and also
# holds revoked rows and user installs, so never pick by position.
install = next((i for i in wf.installations.list().data
if i.attributes.status == "active" and i.attributes.target.id == "SYNDICATE_ID"), None)
if install is None:
raise SystemExit("not installed on SYNDICATE_ID yet")
minted = wf.installations.mint_token(install.id)
INSTALLATION_TOKEN = minted.token.access_token # store this; it is shown once
# Match the active install on this syndicate; the list is newest-first and also
# holds revoked rows and user installs, so never pick by position.
install = wf.installations.list.data.find do |i|
i.attributes.status == "active" && i.attributes.target.id == "SYNDICATE_ID"
end
abort("not installed on SYNDICATE_ID yet") unless install
minted = wf.installations.mint_token(install.id)
INSTALLATION_TOKEN = minted.token.access_token # store this; it is shown once
The token has no expiry and no refresh; revoking the install revokes it.
Make a request as the company or syndicate
- cURL
- JavaScript
- Python
- Ruby
curl "https://api.wefunder.com/syndicates/SYNDICATE_ID/deals" \
-H "Authorization: Bearer $INSTALLATION_TOKEN"
const asSyndicate = new Wefunder({ accessToken: INSTALLATION_TOKEN });
const deals = await asSyndicate.unwrap(
asSyndicate.raw.listSyndicateDeals({ path: { syndicate_id: "SYNDICATE_ID" } }),
);
for (const deal of deals.data) console.log(deal.id, deal.attributes.name);
from wefunder._generated.api.syndicate_deals import list_syndicate_deals
as_syndicate = Wefunder(access_token=INSTALLATION_TOKEN)
deals = as_syndicate.call(list_syndicate_deals, syndicate_id="SYNDICATE_ID")
for deal in deals.data:
print(deal.id, deal.attributes.name)
as_syndicate = Wefunder::Client.new(access_token: INSTALLATION_TOKEN)
deals = as_syndicate.wrap { as_syndicate.raw.syndicate_deals.list_syndicate_deals("SYNDICATE_ID") }
deals.data.each { |deal| puts [deal.id, deal.attributes.name].join(" ") }
For a company install the equivalent is GET /investments, which returns that company's
records and nothing else; Sync investments to your CRM
takes that call through backfill, cursor sync, and the webhook. Register a webhook endpoint
once and every installed company's or syndicate's events arrive there, signed. See
Installations and Webhooks.
Sandbox and live
Every app gets sandbox and live credentials the moment it's created. One API host serves both; the credential prefix selects the environment.
| Environment | Credentials | Access tokens | Authorize URL |
|---|---|---|---|
| Sandbox | pk_test_ / sk_test_ | at_test_ | https://oauth.wefunder-sandbox.com/oauth/authorize |
| Live | pk_live_ / sk_live_ | at_live_ | https://wefunder.com/oauth/authorize |
Sandbox is your own seeded realm with its own logins, and nothing you do there touches a real account. The explore and portfolio tracks work there today. Installations and their tokens are live-only for now, so the company and syndicate track runs against live keys on a company or syndicate your own organization manages. See Sandbox.
Documentation
Installations
Install on a company or syndicate, tiers and scopes, tokens, revoking.
Authentication
OAuth flows, scopes, token prefixes, best practices.
Sandbox
Isolated test data, seeded logins, and how to reset it.
Syndicate API
Members, deals, statistics, portfolio. Writes that matter are proposed and approved by a person.
Webhooks
Endpoints, events, signature verification, delivery log.
Error handling
Error envelope, request IDs, rate limits.
Partner SPV API
For platforms that want to create SPVs, invite investors through their own UX, and let Wefunder handle the contracts, escrow, compliance, and cap table. This surface is in invite-only preview: the endpoints appear in the API reference only for apps with preview access, and the request and response shapes may still change.
Request access
Run a company or a syndicate on Wefunder? You already have access: sign in at the developer portal with the account you use to manage it and create your first app. No waitlist.
Everyone else, tell us what you want to build. We review requests for API access and for the Partner SPV preview from this one form.