@wefunder/sdk (beta)
Official TypeScript SDK for the Wefunder API.
Beta. The package is
0.x— breaking changes are possible while we stabilize. Feedback welcome.
npm install @wefunder/sdkNode 20+ (uses the global fetch). ESM and CommonJS both supported.
Scope & versioning
- Surface: this release covers the stable + beta public API (offerings, investments, campaigns, syndicates, intents, attribution). Preview-only endpoints (the partner SPV / sandbox-simulation surface) are intentionally not included yet.
- API version: the SDK sends
Wefunder-Version: 2025-01-15on every request, forward-compatible with Wefunder's dated-version model. The API does not resolve this header yet, so version pinning is not enforced server-side until that ships — the header is correct in shape and will start taking effect transparently.
Quickstart (server-to-server)
The fastest path: a client_credentials grant with a sandbox token, no user redirect.
import { Wefunder } from "@wefunder/sdk";
const wf = await Wefunder.fromClientCredentials({
clientId: process.env.WEFUNDER_CLIENT_ID!,
clientSecret: process.env.WEFUNDER_CLIENT_SECRET!,
scopes: ["read:public"],
});
// A client_credentials token can only hold `read:public` — it acts as your app,
// with no user. So it can browse public offerings, but NOT user-scoped data.
const page = await wf.offerings.list();
console.log(`${page.data?.length} offerings`);
wf.users.me()won't work withclient_credentials./users/merequiresread:profile, a user-context scope — calling it with aclient_credentialstoken throwsWefunderError(403 insufficient_scope). To read user data, use theauthorization_code+ PKCE flow below and requestread:profile.
Authentication
The SDK supports both OAuth 2.0 grants the API offers.
client_credentials (server-side)
Wefunder.fromClientCredentials({ clientId, clientSecret, scopes }) — see above.
These tokens are short-lived and have no refresh token, but the client keeps the
grant inputs and auto-re-mints on expiry or a 401 — so a long-lived server can
hold one wf and never hand-roll token recovery.
authorization_code + PKCE (acting on behalf of a user)
import { generatePkce, createAuthorizationUrl, exchangeCode, Wefunder } from "@wefunder/sdk";
// 1. Before redirecting, generate PKCE + a state token and stash them in the session.
const pkce = generatePkce();
const url = createAuthorizationUrl({
clientId, redirectUri, scopes: ["read:investments"], state, pkce,
});
// redirect the user to `url`
// 2. On the callback, exchange the code (+ verifier) for tokens.
const tokens = await exchangeCode({
clientId, code, redirectUri, codeVerifier: pkce.codeVerifier,
});
// 3. Build a client. Pass clientId so it can auto-refresh on expiry.
const wf = new Wefunder({ tokens, clientId, onTokenRefresh: (t) => saveToDb(t) });Refresh tokens rotate — persist every refresh
Wefunder rotates refresh tokens: each refresh returns a new refresh token and
invalidates the old one. The SDK refreshes automatically (proactively before expiry,
and on a 401), coalescing concurrent refreshes into one. You just have to persist
the rotated token so it survives a restart:
const wf = new Wefunder({
tokens,
clientId,
store: {
load: () => db.loadTokens(),
save: (t) => db.saveTokens(t), // called on every rotation
},
});Hosts (advanced)
OAuth uses two hosts, independently overridable:
- authorize host — the browser consent redirect (
createAuthorizationUrl). Defaults tohttps://wefunder.com/oauth. - token host —
/token+ refresh (fromClientCredentials,exchangeCode, refresh). Defaults tohttps://wefunder.com/oauthtoday; it will move tohttps://api.wefunder.com/oauthwhen Wefunder's edge gateway ships. Override viatokenBaseUrl(or set both at once withoauthBaseUrl).
The API base is WefunderOptions.baseUrl (default https://api.wefunder.com/api/v2). When Wefunder ships version-free URLs, the canonical base drops /api/v2; the current path stays as a back-compat alias, so no change is required on your side.
Pagination
List endpoints auto-paginate. The cursor is opaque — you never construct it. List methods take the endpoint's documented query params, and they're preserved across pages.
// Stream lazily (one page fetched at a time):
for await (const inv of wf.investments.all()) {
console.log(inv.id);
}
// Query params are forwarded — e.g. sort the offerings browser (sort is preserved
// on every page):
for await (const offering of wf.offerings.all({ sort: "most_raised" })) {
console.log(offering.id);
}
// Or collect everything:
const all = await wf.investments.collect();
// Or drive pages yourself (gives you `meta`):
const page = await wf.offerings.list({ sort: "newest" });
console.log(page.data, page.meta?.next_cursor);Errors
Failed requests throw WefunderError with the fields from the API's error envelope,
including the request_id (read from the response body) — quote it in support tickets.
import { WefunderError } from "@wefunder/sdk";
try {
await wf.syndicates.get(123);
} catch (err) {
if (err instanceof WefunderError) {
console.error(err.status, err.type, err.message, err.requestId);
}
}Idempotent GETs are retried automatically on transient 5xx/network errors and on
429 (honoring X-RateLimit-Reset). Writes are never auto-retried.
Webhooks
Verify and parse webhook deliveries. Pass the raw request body (not a re-serialized object) and the headers:
import { constructEvent } from "@wefunder/sdk";
app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = constructEvent(req.body.toString("utf8"), req.headers, process.env.WEBHOOK_SECRET!);
} catch {
return res.status(400).send("invalid signature");
}
// event.event, event.deliveryId, event.data
res.sendStatus(200);
});Escape hatch: wf.raw
Ergonomic namespaces cover the common GA resources. Every generated operation is also
available, pre-bound, under wf.raw. Raw ops return the low-level { data, error, response } result; wrap them in wf.unwrap(...) to get the same typed-error +
envelope handling the namespaces use (a WefunderError with request_id on failure):
const members = await wf.unwrap(wf.raw.listSyndicateMembers({ path: { syndicate_id: 1 } }));
// Or handle the raw result yourself:
const res = await wf.raw.listSyndicateMembers({ path: { syndicate_id: 1 } });Development
npm install
npm run generate # regenerate src/generated from spec/openapi.yaml
npm run typecheck
npm test # hermetic unit tests (no network)
npm run test:e2e # live sandbox E2E — needs WEFUNDER_CLIENT_ID/SECRET (or a .env); auto-skips otherwise
npm run buildThe live E2E hits api.wefunder.com with a sandbox app's client_credentials. Put
the credentials in a gitignored .env (WEFUNDER_CLIENT_ID= / WEFUNDER_CLIENT_SECRET=).
The typed layer in src/generated/ is produced by @hey-api/openapi-ts from
spec/openapi.yaml and is never hand-edited. The hand-written shell in src/ wraps it.
Syncing the spec (maintainers)
spec/openapi.yaml is a vendored copy of the public tier (stable + beta) of the
canonical Wefunder swagger. Preview/internal operations are excluded by design. To
refresh it from a local wefunder checkout:
WEFUNDER_REPO=/path/to/wefunder npm run sync-spec
npm run generate
git add spec src/generated # commit both togethersync-spec delegates filtering to the wefunder repo's own build-filtered-spec.js,
so the public-tier definition can't drift between the two repos. CI's
generated code matches spec job verifies src/generated matches the committed spec;
it cannot reach the private canonical swagger, so run sync-spec before cutting a
release. (npm test stays hermetic.)