Investments: list, sync, retrieve
One read model, one rule: lists and sync serve published state; retrieve serves the current state.
GET /investments # list, or sync with ?cursor= / ?updated_since=
GET /investments/{id} # retrieve, derived now
GET /offerings/{offering_id}/stats # totals over the records you can list
Authorization: Bearer <token> # scope: read:investments (+ read:investors:pii for other investors' identity fields)
Who sees what
The token's audience is the union of two things, and every call serves exactly that union:
- Companies the token's user edits (a founder or team member with edit access): their founder-visible investment records.
- The user's own investments: every applied investment of theirs, whatever its status, with their own identity fields.
Filters (company_id, offering_id, investor_id, status) narrow within the audience. They
never widen it: filtering on a company you do not edit returns only your own investments there.
A token bound to a company or offering is limited to it.
1. List
Call with no cursor. You get the current records in the audience, ordered by investment, with
meta.mode: "bootstrap". Tombstones are not listed: a record absent from a full listing does not
exist for you. Follow meta.next_cursor while meta.has_more.
curl -H "Authorization: Bearer $TOKEN" \
"https://api.wefunder.com/investments?offering_id=ofr_Xk29&per_page=100"
{
"data": [
{
"id": "inv_8f3kQ2",
"visible": true,
"status": "active",
"converted_to": null,
"converted_from": null,
"offering": "ofr_Xk29",
"company": "co_abc123",
"investment_type": "DIRECT",
"offering_type": "Reg CF",
"group": "CONFIRMED",
"applied_at": "2026-09-01T17:04:11Z",
"amounts": { "committed_cents": 250000, "investment_size_cents": 250000, "in_escrow_cents": 250000, "raised_cents": 250000, "currency": "usd" },
"shares": "739.64",
"average_share_price": "3.38",
"needs_whitelisting": false,
"investor": { "id": "usr_Q7m1", "deactivated": false, "via_entity": false },
"blockers": [],
"contracts": [{ "name": "SAFE", "override_amount_cents": null, "early_bird": false }],
"observed_at": "2026-09-15T20:10:03Z",
"cursor": null,
"reason": null
}
],
"meta": { "mode": "bootstrap", "has_more": true, "next_cursor": "eyJ2IjoxLCJrIjoiYiIsImMiOjEyMDQsInAiOjE5OTgwNDR9", "page_count": 1, "published_through": "2026-09-15T20:12:00Z" }
}
Continue with the same filters on every page:
curl -H "Authorization: Bearer $TOKEN" \
"https://api.wefunder.com/investments?offering_id=ofr_Xk29&per_page=100&cursor=$NEXT_CURSOR"
The last page's next_cursor is a sync cursor positioned at the moment the listing started.
Anything that changed while you were paging is delivered on the next call, so a listing spanning
many requests is still consistent. Store that cursor together with the filters you listed with:
the cursor is a ledger position, not a saved query (decode the example above and you find only
{"v":1,"k":"b","c":1204,"p":1998044}), so a continuation that drops offering_id=ofr_Xk29 is a
different dataset, not the next page of this one.
2. Sync
Two ways to ask "what changed since". Both return a page with meta.mode: "delta": only the
investments whose published state changed, one record per investment (several changes collapse
into the current record), tombstones included.
By cursor (recommended): pass the stored cursor with the filters you listed with.
curl -H "Authorization: Bearer $TOKEN" \
"https://api.wefunder.com/investments?offering_id=ofr_Xk29&cursor=$CURSOR"
By time: updated_since=<ISO 8601>. The server starts one minute before your timestamp to
cover clock skew, so you may see a record you already hold; upserting it is harmless. The
response is a normal sync page: continue with its meta.next_cursor rather than computing a new
timestamp.
curl -H "Authorization: Bearer $TOKEN" \
"https://api.wefunder.com/investments?offering_id=ofr_Xk29&updated_since=2026-09-15T20:00:00Z"
- Always store
meta.next_cursor, even whendatais empty: it moves forward as the ledger does.meta.published_throughis when the newest change this page can reflect was published. - Apply records in
cursororder. Upsert byid, whateverreasonsays:changed,recomputed, the retiredreprojectedandinvestor_deactivatedare all records to store.reasonis a hint for your notification rule (a Slack post may skiprecomputed); it never licenses skipping the upsert. See Record fields. observed_atis when the payload you hold was observed; it is not a version and a change you read late carries the current record.- Changes are published within about ten minutes of the underlying event on the common paths; a small class of changes that depend on other investors' activity can take up to a day. Poll every few minutes.
- Keep filters constant across a sync, and send them on every request. The cursor encodes a
ledger position only, never
company_id,offering_idorinvestor_id; the server applies the filters of this request to the changes since that position. A sync that drops the filter it listed with returns changes from the whole audience. One that adds a filter it did not list with stops receiving changes for the records outside it, so those copies go stale silently.statuscannot be combined with sync at all (400: a record leaving the status would never be delivered; filter client-side). Withoffering_id, an investment moved to another offering is delivered once more as a tombstone under the old offering.
3. Tombstones and 410 Gone
A tombstone is a record with "visible": false and no payload fields: it carries the id
and the sync metadata every record carries (observed_at, cursor, reason) and nothing else,
no status, amounts, offering or investor. It means the investment is no longer visible to
you as a company editor (canceled, converted into an investment on another round, hidden, moved to
a company you do not edit, left the offering you filter on, or deleted). Delete your copy. On
the wire, complete:
{ "id": "inv_8f3kQ2", "visible": false, "observed_at": "2026-09-16T09:02:41Z", "cursor": "1998311", "reason": "changed" }
Your own canceled investment is not a tombstone: you receive the full record with
status: "canceled" and visible: false (visible is what the company sees), and you keep it.
So do not delete on visible: false alone; a tombstone is the absence of the payload, and
status is the field to test, since every full record has one and no tombstone does:
// A tombstone has no payload; a full record (including your own canceled one) always has `status`.
const isTombstone = (record) => record.visible === false && !("status" in record);
for (const record of page.data) {
if (isTombstone(record)) await remove(record.id); // gone for you: delete the copy
else await upsert(record); // includes your own `status: "canceled"`
}
Changes are retained for 90 days. A cursor or updated_since older than that returns
410 Gone. List again and replace your dataset from the result.
4. Retrieve (current)
GET /investments/{id} returns the same record shape derived now, so an investor who just
acted sees the change at once. Available for your own investments (any status) and for
founder-visible investments on companies you edit; anything else is 404.
{
"data": { "id": "inv_8f3kQ2", "visible": true, "status": "executed", "...": "...", "observed_at": "2026-09-15T20:14:09Z", "cursor": null, "reason": null },
"meta": { "source": "current", "published_observed_at": "2026-09-15T20:10:03Z", "published_matches": false }
}
published_matches: false means the sync stream has not caught up yet; it will within its
interval. Do not poll retrieve to detect changes; sync does that.
5. Offering totals
GET /offerings/{offering_id}/stats sums the founder-visible published records for the offering
by status, from the same rows the list serves, so total.committed_cents equals the sum over
GET /investments?offering_id=…. Requires edit access to the offering's company. total.raised_cents
is the offering's public raised figure rebuilt from the parts (amounts.raised_cents); the
campaign-level ticker on the deal page may add sibling Reg D rounds under Wefunder's roll-in policy.
{
"data": {
"offering": "ofr_Xk29", "company": "co_abc123", "currency": "usd",
"by_status": { "active": { "count": 12, "committed_cents": 4200000, "raised_cents": 4200000 }, "executed": { "count": 3, "committed_cents": 900000, "raised_cents": 900000 } },
"total": { "count": 15, "committed_cents": 5100000, "raised_cents": 5100000 }
},
"meta": { "source": "published", "published_through": "2026-09-15T20:12:00Z" }
}
Record fields
| Field | Notes |
|---|---|
id | Investment external id (inv_…). Stable for the life of the investment. |
visible | Founder visibility. A tombstone is false with no payload fields (only id, observed_at, cursor, reason); your own record can be false with every field present. Test for status, not visible, to tell them apart (see Tombstones). |
status | reserved (testing-the-waters reservation), active (committed, not yet executed), executed (confirmed), canceled, converted (rolled into another round; see converted_to). |
converted_to / converted_from | Ids of the successor / predecessor when an investment was rolled into another round. |
offering, company | External ids (ofr_…, co_…). |
group | The founder dashboard's investment group label, e.g. CONFIRMED, IS READY, NO PAYMENT YET, WEFUNDER CASH. |
amounts | Integer minor units plus currency (usd, eur). Never a decimal number of dollars. committed_cents is what the investor entered; investment_size_cents the priced total (whole shares); in_escrow_cents what has arrived. |
amounts.raised_cents | What this investment contributes to the offering's public raised figure (deal page, company card, directory header). Equals investment_size_cents once the investment is soft-confirmed, 0 until then. A large investment waiting on whitelisting shows committed_cents: 5000000, raised_cents: 0 with needs_whitelisting: true. Sum raised_cents, not committed_cents, to reproduce the site's number. |
shares, average_share_price | Decimal strings ("295", "3.38"), never JSON numbers. shares is what this investment buys at its committed amount; average_share_price is total share price ÷ shares, which with tiered pricing need not match any single price on the round. Through the SPV, shares is the company shares allotted to the investment via the vehicle. Both null on rounds without shares (SAFE, note, reservation) or when no shares are computed for the investment. Per investment, computed live: not the investor's post-funding holding. |
needs_whitelisting | The investment would be soft-confirmed but for its payment method. |
blockers[] | Open action items on the investment (key, founder-facing description). |
contracts[] | Contract templates on the investment with any override amount and early-bird flag. |
investor.id, investor.deactivated, investor.via_entity | Always present. |
observed_at, cursor, reason | Sync metadata, present on every record including tombstones. cursor and reason are null on list pages and on retrieve. |
reason (sync pages) | Every value is a record to upsert. changed: a partner-visible field moved. recomputed: only Wefunder's derivation moved (a new field, a rounding fix); sync it, don't notify. reprojected: the retired name for recomputed, still on rows published before the rename for the 90-day retention window; handle it as recomputed. investor_deactivated: the redaction row. A notification rule may skip derivation-only reasons; it must not suppress the upsert. See Events for the notification rule. |
investor.name, legal_name, email, address, bio, message, external_username | On other investors' records only with read:investors:pii (omitted, not null, otherwise). Always present on your own. |
Deactivated investors
When an investor's Wefunder account is deactivated (by the investor, or by Wefunder), every record of theirs is republished with the identity fields redacted and the record says so:
"investor": { "id": "usr_Q7m1", "deactivated": true, "via_entity": false, "name": "[deleted user]", "legal_name": "[deleted user]", "email": null, "address": { "line": null, "city": null, "state": null, "postal_code": null, "country": null }, "bio": null }
message and external_username become null too. The delta page that carries this republish has
reason: "investor_deactivated" (later republishes of the same record are ordinary changed), so a
sync log reads: the identity was there, then the account was deactivated. investor.id stays so
you can find your copy, and the investment itself persists with its ids, amounts, status and dates.
The founder's record that a commitment existed is intact; only the identity is gone.
Treat that record like any other update and overwrite what you hold; the prior identity fields
are no longer yours to keep. deactivated is false on every other record and is served with or
without the PII scope.
PII scope changes
read:investors:pii is granted per install:
an install carries it when the installer manages your organization or Wefunder has approved your
app for investor PII. If a token gains the scope (a new install made after approval), list again
to receive the identity fields for records you already hold. If the scope is downgraded or revoked,
you are obliged to purge the identity fields you hold.
Migrating from /companies/{company_id}/investments/changes
That endpoint keeps working unchanged and serves the company's records the same way
GET /investments?company_id={company_id} does; cursors are interchangeable. Differences when you
switch: the unified endpoint also returns the token user's own investments (with their identity
fields), accepts offering- and fund-bound tokens, drops meta.company_id and adds
meta.published_through.