Skip to main content

Sync Investments to Your CRM

Keep a company's Wefunder investments current in your CRM, spreadsheet, or email tool without anyone exporting a CSV. For founders syncing their own company, and for firms that help many founders raise, like marketing partners and agencies.

How it works. You install your app on a company once. The install gives you a token that reads that company and nothing else. You list the company's investments once, keep the cursor the last page returns, and from then on pull only what changed. A webhook tells you when to pull. Four steps, about thirty minutes to the first synced investment.

your app ──install on a company──▶ company token + cursor ──▶ GET /investments (list, then sync)
▲
investment.changed webhook ┘ "something changed on co_…; sync it"

Before you start​

What the sync gives you, so the first run is not a surprise:

  • Current state, not history. The first pull returns each investment as it is now, one record per investment. Webhooks are not replayed for events before your endpoint existed.
  • Open and recently closed rounds only. An investment is tracked while its round is open or closed within the last 90 days; a testing-the-waters reservation while its page is open or a linked live round is inside that window. Rounds that had aged out before the feed existed never enter it. For those, import the founder dashboard's Investor CSV once and keep the rows; Mapping into your CRM shows how the sync leaves them alone.
  • Investments, not contacts. Each record is one investment (inv_…). The person is investor.id (usr_…), the same id across every investment they make with any company, so one investor can hold several records. Your CRM contact is the investor; the deal or row is the investment.
  • Your own company, or a client's. Installing on a company you edit needs no review. Installing on companies outside your developer organization needs app review first, and investor-PII review separately if those installs should carry names and emails. Step 1 covers both.
  • Names and emails are a scope. Without read:investors:pii a record carries investor.id but no name, email, or address. PII says who may grant it, and names the one case (testing the waters without full service) where email is withheld even with the scope.
  • Totals. amounts.raised_cents is this investment's share of its offering's public raised total. Summing it over one offering's records reproduces that offering's figure, and GET /offerings/{id}/stats returns the same totals pre-aggregated. The ticker on a deal page is campaign-level and can include sibling Reg D offerings, so a correct single-offering sum can be lower than the page without anything missing. committed_cents is what the investor committed; it and raised_cents legitimately disagree on recent, unpaid investments.

Step 1: Create the app​

In the developer portal, open Create application and pick Sync a company's investments at the top of the form. That preset fills in everything this guide needs, and the form shows exactly what it will request before you press Create:

read:investments read:companies read:installations write:installations read:webhooks write:webhooks
ScopeWhy
read:investmentsRead investment records; receive investment.changed
read:companiesRead the company and its rounds; also what the lifecycle investment.* events are gated on
read:installations, write:installationsInstall on a company and mint its token
read:webhooks, write:webhooksCreate and manage your webhook endpoint

The preset asks two things the scopes alone do not settle:

  • Whose investments will it sync? Your own company, or companies you work with. The answer changes nothing in the app; it tells you which reviews to plan for. Your own installs need no review of any kind. Installs by founders outside your developer organization need app review first (so their install link works), and investor-PII review separately if their installs should carry names and emails. Both are requested from the app's page after it exists, and neither blocks creating the app or installing on your own company.
  • Include investor names and emails? Ticking it adds read:investors:pii. Your own install on a company you edit carries it with no review; see PII before you install anything for a client.

Two things the preset decides for you, so you do not have to:

  • Client type: confidential. A server-side key with a secret. This route mints tokens from the portal, which a public (PKCE) key cannot do.
  • Redirect URI: none. Nobody is sent through OAuth on this route. Installs happen on the app's Installations page or through its install link, and tokens are minted there, so there is no callback server to run and no URL to invent. These keys can't use the authorization-code flow until you add a redirect URI from the app page.

Installs and minted tokens always use the live key, so you test this route on live against your own company; the sandbox mints no company-owned tokens, so this route cannot complete there (the full line is in What works in sandbox). The test key of a callback-less app can only mint a read:public token through client credentials; it cannot reach investments.

Fixed at creation

The name and redirect URI of an app cannot be changed after it is created, and scopes can only be added (never removed) while the app has not been through third-party review. A scope added later reaches a company only through a re-install there, with new tokens; see Changing scopes.

Anyone who edits a company or manages a syndicate on Wefunder can enter the portal. Creating the app gives you a live client (pk_live_ / sk_live_). The secret is shown once, and the screen that shows it offers Go to Installations, which is Step 2.

Step 2: Install on a company​

An install is how a company says yes to your app; the token in Step 3 comes from it. Installing needs someone who can edit the company. In the developer portal, pick your app and open Installations.

The Installations page: your own company under "Install on your own company or syndicate" (already installed here), the "Send someone the install link" box, and the "Installed on" list with Mint token

Developer portal → your app → Installations. Your own company is listed at the top with an Install button (it reads Installed once done); the link for a founder is in the middle; installs and their Mint token buttons are at the bottom.

  • Your own company: under Install on your own company or syndicate, press Install next to it and confirm on the consent page.
  • A client's company: under Send someone the install link, choose A company, Copy link, and send it to the founder (or anyone on their team with edit access). They sign in, pick their company, and consent. If you are already an editor on their team, open the link yourself. Until your app is approved, only people in your developer organization can use the link; submit for review from the app's page so any editor can.

Either way the install appears under Installed on. It carries your app's scopes intersected with what the installer's role can grant; read the scopes shown there rather than assuming. Repeat this step for every company; nothing else in the guide changes per company. The full rules are in Install your app.

Step 3: Get the company token​

The token that reads a company is minted from its installation and is owned by the company, not by you. The quick way: on the install's row under Installed on, press Mint token. It is shown once; copy it.

From a server instead, mint a personal token first: on the same Installations page, the first row under Install on your own company or syndicate is You; press Mint token. It acts as you with the app's declared scopes, which include read:installations write:installations read:webhooks write:webhooks. (This app has no redirect URI, so Authorization Code is not an option for it; the personal token replaces that step.) Then:

# Find the install (one entry per company you have installed on)
curl -H "Authorization: Bearer $USER_TOKEN" https://api.wefunder.com/installations
# -> { "data": [ { "id": "inst_7Hk2...", "type": "installation",
# "attributes": { "target": { "type": "company", "id": "co_aB3xQ9k2", "name": "..." },
# "tier": "editor", "scopes": ["read:investments", "read:companies", ...], "status": "active" } } ] }

# Mint the company token
curl -X POST -H "Authorization: Bearer $USER_TOKEN" https://api.wefunder.com/installations/inst_7Hk2.../tokens
# -> 201 { "data": { ...the installation... }, "token": { "access_token": "at_live_...", ... } }

Store the access_token per company as COMPANY_TOKEN, alongside the cursor you will get in Step 4. It does not expire; it stops working the moment the install is revoked.

The company ID the Step 4 samples pass as company_id is attributes.target.id on the install (co_aB3xQ9k2 above). The portal's Installations page shows the company's name, not its id, so copy it from this response, or from the company field of any record the token returns. A company token already reads only its company, so company_id narrows nothing there; the samples keep it so each request says which company it is for.

Step 4: Backfill, then stay current​

Backfill once. List the company's investments, follow next_cursor while has_more, and save the last page's next_cursor. That cursor is positioned at the moment the listing started, so anything that changed while you were paging arrives on your first sync.

Then sync on every nudge. Pass the saved cursor and you get only the records that changed, one per investment, already collapsed to current state. Upsert them and save the new cursor, even when the page is empty. Run it when a webhook arrives and on a 30-minute timer as a safety net, and run one sync per company at a time.

Run the complete Next.js example

The snippets below show the integration contract and leave store, withLock, upsert, and replace to you. investment-sync-nextjs in the wefunder-examples repository is a runnable Next.js app that supplies them: finding and installing on companies, a token and cursor store per company, this sync loop with tombstone, deactivated-investor, and 410 handling, the signed webhook receiver, and a Slack money feed. Setup is in its README: copy .env.example to .env.local with the values from your app's portal page, then npm install and npm run dev; npm test runs its tests without touching the API.

Before you run it:

  • It is a reference example, not a production integration. Its README lists what it simplifies: state is one JSON blob where production keeps a row per company; its per-company lock spans instances only on Upstash or Redis (the local JSON-file fallback covers one process); and the dashboard sits behind HTTP basic auth with one shared password.
  • Its app needs a redirect URI. The example connects a staff user through Authorization Code with PKCE, so the callback-less preset from Step 1 cannot run it. Create its app through Something else with http://localhost:3000/api/wefunder/oauth/callback as the redirect URI and the Step 1 scopes plus read:profile: the staff-user flow asks for read:installations write:installations read:profile (lib/env.ts), and an OAuth request may only name scopes the app was registered with, so a registration without read:profile fails with invalid_scope before any code is issued. The .env.example lists every variable.
  • The repository is private during the beta. If the links above show a sign-in page or a 404, email [email protected] with your GitHub username to request access.

The SDK tabs need the client installed: npm install @wefunder/sdk, pip install wefunder, or gem install wefunder (SDKs). The cURL tab needs only the token.

One checkpoint per run. The samples advance cursor on every page but persist it once, after the last page, so a crash mid-run replays the whole run from the previously saved cursor. That replay is safe: every record is upserted on its id and a tombstone deletes by id, so a page applied twice changes nothing. What is never safe is a cursor saved ahead of the records it covers; those records are skipped for good. If you checkpoint per page instead, write that page's records and its next_cursor in one transaction. The go-live checklist states the same rule.

If the first page is empty, or has no emails
  • "data": [] with "mode": "bootstrap": the company has no tracked investments (see Before you start), or company_id names a company this token does not read, which returns only what the token can see there, usually nothing, rather than an error. Check attributes.target on the install, and compare the count you expect with the founder dashboard's Investors tab.
  • Records with investor.id but no investor.email or investor.name: the install's granted attributes.scopes lack read:investors:pii, whatever the app declares. PII says how to fix the install.
  • 401: read the error message before doing anything; both cases are type: "unauthorized". "The installation this token was issued for has been revoked" means the install itself is gone: a founder removed the app, so install again (Step 2) and mint a new token. Anything else ("Invalid or expired token") is the credential, not the install: a missing or mistyped bearer, or a token revoked on its own. Confirm with GET /installations/{id} that the install is still active, then mint a new token for it (Step 3). Do not reinstall over an active install; that is a 409 already_installed.
import { Wefunder } from "@wefunder/sdk";

// Backfill and sync are the same loop: no cursor lists, a saved cursor syncs.
async function sync(companyId) {
await withLock(companyId, async () => {
const { token, cursor: saved } = await store.company(companyId);
const wf = new Wefunder({ accessToken: token });
let cursor = saved;
try {
do {
const page = await wf.investments.list({ company_id: companyId, cursor, per_page: 100 });
for (const record of page.data ?? []) await upsert(companyId, record);
cursor = page.meta.next_cursor; // every page, including the last
if (!page.meta.has_more) break;
} while (cursor);
await store.saveCursor(companyId, cursor); // even when nothing changed
} catch (err) {
if (err.status === 410) return replace(companyId); // cursor older than ~90 days: list again, replace API-managed rows only
throw err;
}
});
}

Three records need special handling when you upsert:

  • A record with "visible": false and no status is a tombstone: the investment is no longer visible to the company. It carries only id, visible, observed_at, cursor and reason. Delete your copy. Do not key on visible alone: a staff user's own canceled investment is a full record with visible: false and status: "canceled" (see Tombstones).
  • investor.deactivated: true arrives with identity fields redacted. Overwrite; never merge.
  • 410 Gone means your cursor is older than the retention window (about 90 days). List again without a cursor and replace the records the sync owns for that company, together with the cursor, in one write: a record absent from the fresh listing is gone, so stale tombstones leave no ghosts. Never touch rows that came from somewhere else (see below).

The webhook​

In the portal's Webhooks tab click Add endpoint, enter your HTTPS URL, and subscribe to investment.changed only. The signing secret (whsec_…) is shown once. The event carries no investor data, just which investment on which company changed. Verify the signature, store the delivery keyed on the envelope id plus installation.id, respond 2xx, and let a worker run sync for that company from the stored row.

The order matters. A 2xx tells Wefunder the delivery is done; it is never sent again. So the sync must be on disk before the response goes out, or a crash between the two loses the nudge until the 30-minute timer. If the write fails, let the exception propagate: a 5xx is retried with backoff for 72 hours. The write is also the dedupe. Insert one row per (id, installation.id) behind a unique index: a retry of a pair you already hold inserts nothing and is acknowledged again, while the same event for a second company your app is installed on is a different pair and is stored on its own. The row carries the company id from the envelope, and sync loads that company's token and cursor under its lock, so nothing from one installation's delivery reaches another company's records.

The samples use the inbox table as the queue, drained by a worker that calls sync and marks the row processed afterwards (a failed sync leaves the row for the next pass). If you enqueue a job instead, do it in the same database transaction as the insert; an enqueue to a separate broker after the insert can be lost between the two.

import { constructEventFromRequest, WebhookSignatureError } from "@wefunder/sdk";

export async function POST(req) { // Next.js, Hono, Workers; Express: express.raw() + constructEvent
const raw = await req.clone().text(); // the exact bytes; kept for the installation id below
let event;
try {
event = await constructEventFromRequest(req, process.env.WEFUNDER_WEBHOOK_SECRET);
} catch (err) {
if (err instanceof WebhookSignatureError) return new Response(err.message, { status: 400 });
throw err;
}
if (event.event === "investment.changed") {
// The SDK event object has no `installation`; read it from the verified raw body.
const installationId = JSON.parse(raw).installation.id;
// INSERT ... ON CONFLICT (event_id, installation_id) DO NOTHING. A repeat of this pair inserts
// nothing; the same event for another installation is its own row. A throw here is a 500 and
// Wefunder retries the delivery.
await inbox.insert({ eventId: event.id, installationId, companyId: event.data.company });
}
return new Response("ok"); // stored first, then acknowledged
}

// Worker, on a timer: one pass over unprocessed rows. sync() loads that company's token and
// cursor and runs under its lock; a throw leaves the row unprocessed for the next pass.
export async function drainInbox() {
for (const row of await inbox.claimUnprocessed({ limit: 50 })) {
await sync(row.companyId);
await inbox.markProcessed(row.id);
}
}
Scope trap

investment.changed is delivered to installs that carry read:investments. The lifecycle events (investment.created, investment.executed, investment.canceled, …) are delivered to installs that carry read:companies. Step 1 requested both. If you trimmed the scopes and an endpoint stays silent, this is why.

Mapping into your CRM​

Key everything on Wefunder ids. investor.id is one person across all their investments with any company; id is one investment. It is also the "Investment External ID" column of the founder dashboard's CSV, so records imported from CSVs in the past match by the same key.

Record fieldKlaviyoHubSpot
investor.id (usr_…)profile external idcontact wefunder_investor_id (custom, unique)
investor.email (PII)profile emailcontact email
investor.name, legal_name (PII)first/last namecontact name
id (inv_…)profile property latest_investment_id; event property if you emit eventsdeal wefunder_investment_id (custom, unique)
amounts.committed_centsevent $value ÷ 100deal amount ÷ 100
status, groupprofile property latest_investment_statusdeal stage
applied_atevent timedeal create date
offering (ofr_…)event propertydeal wefunder_round

Mark what the sync owns. Store a provenance flag (for example source = "wefunder_api") on every record and contact the sync writes, and keep the sync's fields in their own properties. Two things depend on it: a 410 replacement deletes only rows the sync owns, so investments you imported from CSVs for rounds the feed does not cover survive; and a later sync never overwrites your notes, tasks, or campaign history. Money is integer cents with amounts.currency.

Klaviyo events are immutable; the record is not

Klaviyo deduplicates events with the same profile, metric, and unique_id and keeps the first one. An investment's amount and status change over its life, and nothing on the record is a stable version to key on (observed_at moves whenever the record is re-observed; the sync contract says it is not a version). Keep Klaviyo to mutable profile properties and let HubSpot deals carry per-investment state. If you want an event per change, write the event with its own unique_id to your outbox in the same transaction as the record update, then deliver from the outbox.

PII​

FieldWithout read:investors:piiWith it
amounts, status, group, applied_at, offering, sharesyesyes
investor.id, investor.deactivated, investor.via_entityyesyes
investor.name, legal_name, email, address, bio, message, external_usernameomittedyes

Declare the scope on your app, then grant it at install. Whether an install may carry it is decided from who installs: if you manage the app's developer organization and edit the company (your own app on your own company), the install carries it with no review. If people outside your organization will install the app on their companies, request PII review from the app's page first; until Wefunder approves it, their installs succeed without the scope. Grants are frozen at install, so an install made before the app declared the scope (or before approval) cannot gain it later. If you installed first: revoke the install (DELETE /installations/{id}), install again, repeat Step 3 to mint a new token (revoking an install revokes every token minted for it), then repeat the backfill. On testing-the-waters raises without full service, email is withheld even with the scope.

Go-live checklist​

  • POST /webhook_endpoints/{id}/test reaches your handler and the signature verifies.
  • next_cursor is persisted after every run, including the last page of the backfill and empty sync pages, in the same transaction as the records it covers (per page, if you checkpoint per page) and never ahead of them.
  • Tombstones delete, deactivated investors overwrite, 410 replaces only the rows the sync owns (provenance flag set), never CSV-imported history.
  • The handler stores the delivery (keyed on id + installation.id) before it responds, a worker syncs from the stored rows, and syncs are serialized per company. Delivering the same event twice does one sync; delivering it for two installed companies does two.
  • Each company's granted attributes.scopes (from the install) include what you need, and one known investment was compared with its row in the founder dashboard.
  • A 401 on a company token marks that company disconnected. Founders can remove your app from Settings → Installed apps; webhooks for that company stop too.
  • You know the retry policy. Two clocks run independently: each delivery that does not get a 2xx is retried with exponential backoff (30 seconds, doubling, capped at 6 hours) for 72 hours, then dead-lettered, kept, and resendable from the Deliveries section of the Webhooks tab. Separately, an endpoint where every delivery has failed for 24 hours gets a warning email, and one where every delivery has failed for 3 days is auto-disabled; no new deliveries are queued until you re-enable it with POST /webhook_endpoints/{id}/reenable, and re-enabling does not resend what dead-lettered. Full policy: Failures, retries, and auto-disable.

Next steps​

  • Investments: list, sync, retrieve for every record field and sync rule.
  • Installations for tiers, minting, and revocation.
  • Handling Webhooks for endpoint management and the event catalog.
  • SDKs for the JavaScript, Python, and Ruby clients used above.
  • Reference implementation: investment-sync-nextjs in wefunder-examples is this guide as a running Next.js app on @wefunder/sdk: install, backfill, cursor sync, signed webhooks, a money feed through an outbox, and the founder CSV rebuilt from the mirror. Copy it with npx degit Wefunder/wefunder-examples/investment-sync-nextjs my-integration; setup, limits, and access are in the Step 4 tip.