Skip to main content

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 when data is empty: it moves forward as the ledger does. meta.published_through is when the newest change this page can reflect was published.
  • Apply records in cursor order. Upsert by id, whatever reason says: changed, recomputed, the retired reprojected and investor_deactivated are all records to store. reason is a hint for your notification rule (a Slack post may skip recomputed); it never licenses skipping the upsert. See Record fields.
  • observed_at is 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_id or investor_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. status cannot be combined with sync at all (400: a record leaving the status would never be delivered; filter client-side). With offering_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​

FieldNotes
idInvestment external id (inv_…). Stable for the life of the investment.
visibleFounder 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).
statusreserved (testing-the-waters reservation), active (committed, not yet executed), executed (confirmed), canceled, converted (rolled into another round; see converted_to).
converted_to / converted_fromIds of the successor / predecessor when an investment was rolled into another round.
offering, companyExternal ids (ofr_…, co_…).
groupThe founder dashboard's investment group label, e.g. CONFIRMED, IS READY, NO PAYMENT YET, WEFUNDER CASH.
amountsInteger 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_centsWhat 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_priceDecimal 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_whitelistingThe 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_entityAlways present.
observed_at, cursor, reasonSync 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_usernameOn 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.

Was this page helpful?