Webhook Events
Every webhook delivery uses the same envelope:
{
"id": "evt_…",
"event": "investment.created",
"created_at": "2026-09-14T18:00:00Z",
"mode": "live",
"installation": { "id": "inst_…", "target": { "type": "company", "id": "co_…" } },
"data": {}
}
idis unique per event. A delivery can arrive more than once; useidtogether withinstallation.idto ignore repeats.installationis the installation this delivery was produced for.target.typeiscompany,syndicate, oruser, andtarget.idis that record's id (co_…,syn_…,usr_…). An endpoint registered for the whole app receives one delivery per installation an event concerns, each with its owninstallationand its owndata; an endpoint created for one installation only ever carries that installation. Every event exceptreferral_cohorts.publishedis about something an app is installed on, so those deliveries carryinstallation(a signed test delivery to an app-wide endpoint carries a synthetic one; see Setup). The installation events are about an installation too: theirinstallationnames the one that was created or revoked — oninstallation.revoked, an installation that no longer exists.referral_cohorts.publishedis about your app itself: it reaches app-wide endpoints only and carries noinstallationblock (see Referral events).modeistestfor events that happened in the sandbox, andliveotherwise. It says where the event happened, not whether the envelope is a test: a test send to a live endpoint arrives withmode: live.- Test envelopes are the ones
POST /webhook_endpoints/{id}/testsends. They go through the same projection, signature and transport as a real delivery, and they are marked in two places:idstarts withevt_test_, and for an endpoint registered for the whole appinstallationis a synthetic block,{ "id": "inst_test_…", "target": null, "test": true }, because no single installation produced it. An endpoint bound to one installation gets that installation's real block instead (notestkey), since that is the only identity it will ever carry.dataholds obviously fake records (inv_example…,ofr_example…,usr_example). Nothing is recorded for a test: no delivery row in your history, no effect on endpoint health. Key your "is this a test?" check oninstallation.test === trueor theevt_test_prefix, never onmode, and have the handler skip CRM, funding and notification side effects for those envelopes so a test never creates a production record. - Events are facts about one moment. Retries can deliver them out of order, so order investment events by
data.occurred_at(ISO 8601 with microseconds), not by when they arrive.
Investment events
Delivered to apps installed on the company that received the investment (requires read:companies), and to apps a user connected to their own account for that user's investments (requires read:investments).
Every investment event's data carries:
id, the investment (inv_…)statusoffering(ofr_…)amounts.committedinvestor.id, the investor (usr_…), stable across eventsinvestor.nameinvestor_totals, for apps installed on the company only
investor.email is included only when the company's installation holds read:investors:pii. Every event below except investment.executed also carries occurred_at.
investor_totals describes the investor across all of the company's offerings in the same raise, at the moment of the event, so an app that tracks people rather than investments never has to add them up:
active_investment_count, how many active investments or reservations the investor holdstotal_committed, their sum, in the same units asamounts.committedlatest_applied_at, when the most recent of them was made, or nullinvestor_status,activewhile any remain,none_activewhen none do
Apps a user connected to their own account do not receive it. The totals are true as of occurred_at; a retry can deliver an older event after a newer one, so keep the totals from the event with the latest occurred_at.
Amounts are whole units, not cents
amounts.committed, previous_amounts.committed and investor_totals.total_committed are JSON integers in whole units of the offering's currency: US dollars on a US offering, euros on an ECSP offering. The event carries no currency key; take the currency from the offering, or from the investment's record on GET /investments/{id}. The values are never cents and never decimal strings. The same investment appears in minor units everywhere else in the API: the sync record behind investment.changed and GET /investments carries amounts.committed_cents with amounts.currency.
| Surface | A $250 investment |
|---|---|
investment.created → data.amounts.committed | 250 |
investment.amount_changed after the investor raises it to $500 → data.amounts.committed / data.previous_amounts.committed | 500 / 250 |
data.investor_totals.total_committed (this and the investor's other active investments in the raise) | 250, in the same whole units |
GET /investments/{id} → amounts.committed_cents, amounts.currency | 25000, "usd" |
Storing a lifecycle amount in a column you fill from committed_cents elsewhere, or multiplying it by 100 "to be safe", are the two 100× mistakes; pick one unit for your own records and convert at the edge. The test event for an investment event carries 250 (and 500 / 250 for investment.amount_changed), so a handler that logs $25,000 from it has the conversion backwards.
Nullability: amounts.committed is null only on an investment.canceled produced by editing the amount to zero. previous_amounts.committed is never null: an edit to or from an empty amount is a cancellation or reinstatement, not an amount change. investor_totals.total_committed is 0, never null, when active_investment_count is 0.
For money that has to agree with Wefunder to the cent (CRM deal values, funding ledgers, reports), treat the lifecycle amounts as the nudge and sync from the canonical record: subscribe to investment.changed and pull GET /investments?cursor=… from your saved cursor, which carries cents, currency, what has arrived in escrow and what counts toward the raise. Use the lifecycle events for what they are good at: deciding, in real time, that something happened and whether a human should hear about it.
| Event | Description | data.status | Extra fields |
|---|---|---|---|
investment.created | An investment was created — an investor committed to an offering. In a Testing-the-Waters offering this is a reservation, delivered with status reserved. | active, or reserved for a reservation | |
investment.reinstated | A canceled investment or reservation was reinstated within the grace period — it is active (or reserved) again. | active or reserved | |
investment.canceled | An investment or reservation was canceled. Conversions are never cancellations — one that moved to a successor emits investment.converted instead. | canceled | canceled_at |
investment.converted | An investment converted into a successor investment — a reservation became a live investment, or the investment moved to a new offering or vehicle. | converted | converted_to, the successor (inv_…) |
investment.amount_changed | The amount of an investment or reservation changed. | active, reserved, or executed | previous_amounts.committed |
investment.executed | An investment was executed — contracts fully executed and funds released to the company. | executed |
converted_to is null when the successor could not be resolved at the moment of conversion; the event is still delivered. On an investment.canceled caused by editing the amount to zero, amounts.committed is null.
Investment sync events
Delivered to apps installed on the company (requires read:investments, the same grant as
GET /investments), and to apps a user connected to their own account for that user's investments.
| Event | Description |
|---|---|
investment.changed | The published record of an investment changed — a new investment, a payment landing, a signature, a cancellation, an investor correction. |
This event is thin on purpose. Deliveries retry out of order, so a full record in the payload
could land after a newer one. data carries only routing:
{ "id": "inv_8f3kQ2", "visible": true, "reason": "changed", "company": "co_abc123", "observed_at": "2026-09-15T20:10:03.412110Z",
"first": true, "fields": ["amounts", "applied_at", "group", "status", "..."] }
id: the investment. Fetch the current record withGET /investments/{id}, or run your sync from your saved cursor withGET /investments?cursor=…(see Investments: list, sync, retrieve). The event carries no cursor of its own: replacing your saved cursor with anything else would skip changes you have not received.visible: falsemeans the investment is no longer visible to the company (a tombstone in the sync stream); your own investments stay retrievable whatever the flag says.first: trueis the investment's first appearance in the stream: a new investment whenvisibleis true.fieldslists the top-level record fields that differ from the last copy we published (amounts,group,status,investor,blockers, …). Both are hints for deciding whether a human should hear about the change; always sync regardless.reason: "recomputed"means the investment's inputs did not change and the record moved only because Wefunder changed how it derives a field (a new field, a rounding fix). Sync it, but don't notify on it.changedis everything else;investor_deactivatedmarks the row where an investor's identity was redacted.- A high-signal Slack rule from these three fields: post when
first && visible("new investment: amount, group"), whenvisible: falsefor an id you hold ("canceled"), or whenfieldscontainsstatus,grouporamountsfor an id you hold ("payment landed", "executed", "amount changed"). Skipreason: "recomputed"and every otherfieldscombination. Over a week on eight companies that rule keeps about 4% of deliveries and drops the rest without losing a money event. Key "new" on the id being new to you, never on receiving an event: retries and reprojections redeliver old ids. - Delivery is at-least-once and unordered, like every webhook here: deduplicate by the envelope
idplusinstallation.id, and treat the event as a nudge to sync, not as the data. Deliveries start when your endpoint subscribes; earlier changes are not delivered retroactively — bootstrap with a list.
Offering events
Delivered to apps installed on the offering's company (requires read:companies).
| Event | Description |
|---|---|
offering.opened | An offering opened — accepting investments, or reservations for Testing-the-Waters offerings. |
offering.closing | An offering stopped accepting investments and its funds are awaiting disbursement. |
offering.closed | An offering's funds were disbursed — the raise is complete. |
offering.canceled | An offering was canceled and its investments refunded. |
Investment session events
Emitted by hosted investment sessions (beta). Delivered only to the app that created the session, through its install on the session's company (requires read:investment_sessions). Another app installed on the same company never receives them, just as it cannot read the session through the API.
| Event | Description |
|---|---|
investment_session.created | A hosted investment session was created for a recipient. |
investment_session.started | The recipient opened the investment session link and began the hosted flow. |
investment_session.completed | An investment session completed — the recipient's investment was created and confirmed. |
investment_session.expired | An investment session expired before the recipient completed it. |
investment_session.canceled | An investment session was canceled by the creating partner. |
Syndicate member events
Delivered to apps installed on the syndicate (requires read:syndicates, the same grant as
GET /syndicates/{id}/members). A syndicate installation is always manager-tier, so these events
carry the member's email the way the members endpoint does for a manager.
Every membership event's data carries syndicate (syn_…) and occurred_at. Retries can
deliver events out of order, so keep the state from the event with the latest occurred_at.
| Event | Description | data |
|---|---|---|
syndicate_member.invited | Someone was invited to join the syndicate — a manager added them and Wefunder sent the invitation email. | email, name, user (usr_… when the address already belongs to a Wefunder account, else null), invited_by.id / invited_by.name |
syndicate_member.reinvited | An outstanding invitation to join the syndicate was re-sent. | same as invited |
syndicate_member.applied | Someone asked to join the syndicate and is waiting for a manager to approve them. | id (mem_…), status: "pending", user.id / user.name / user.email, approved_by: null |
syndicate_member.approved | A manager approved someone's request to join the syndicate. | id, status: "approved", user, approved_by.id / approved_by.name |
syndicate_member.joined | Someone became a member of the syndicate — approved by a manager, accepted automatically, or joined from an invitation. | id, status: "member", user, approved_by (null when nobody approved — an automatic or invited join) |
syndicate_member.left | Someone is no longer a member of the syndicate. | id, status: "left" (they resigned), "removed" (a manager removed them), or "payment_failed" (their membership payment failed and they are waiting to re-invest), user, approved_by |
syndicate_member.declined | A request to join ended without membership. | id, status: "declined" (a manager declined it) or "withdrawn" (the applicant withdrew), user, approved_by |
{
"id": "mem_8f3kQ2",
"syndicate": "syn_abc123",
"status": "member",
"user": { "id": "usr_9t2x", "name": "Jane Example", "email": "[email protected]" },
"approved_by": { "id": "usr_m4n", "name": "Max Manager" },
"occurred_at": "2026-09-23T20:10:03.412110Z"
}
- A direct join with no invitation and no approval (an open syndicate that accepts accredited
investors automatically) arrives as a single
syndicate_member.joinedwithapproved_by: null. - In a fund syndicate the manager approves before the investment payment settles, so
approvedandjoinedare separate events and can be minutes or days apart.appliednever repeats for the same request; it repeats only when a rejected investor applies again. - Invitations are keyed by
emailbecause no membership exists yet. When the invitee later joins, thejoinedevent'suser.emailmatches, so a contact-keyed system can link the two. leftis the stop signal: a member can leave and come back, so ajoinedcan follow aleftfor the sameid.payment_failedmeans the manager's approval stands and the investor was asked to re-invest; a later successful payment arrives asjoinedagain.declinedcloses an application the wayjoinedwould have: after adeclined, the next event for thatidis a freshappliedif the person applies again. A member being removed isleft, neverdeclined.
Join session events
Delivered to apps installed on the syndicate (requires read:syndicates, the same grant as the
syndicate member events). A join session lets a partner invite someone from the partner's own site
and have that person become a Wefunder syndicate member — the person completes the hosted flow on
wefunder.com and the outcome arrives here. A join session makes someone a member of the syndicate;
it touches no deal.
Every join session event's data carries id (js_…), syndicate (syn_…), the current
status, and occurred_at. Retries can deliver events out of order, so keep the state from the
event with the latest occurred_at. user is the person's Wefunder account (usr_… id and name)
once they have signed in, and null before then (a session canceled or expired before anyone claimed
it).
| Event | Description | data |
|---|---|---|
join_session.pending_approval | Someone completed a join session and is waiting for a manager to approve them into the syndicate. | status: "pending_approval", user |
join_session.completed | The person became a member of the syndicate. | status: "joined", user |
join_session.rejected | A manager rejected a join session that was awaiting approval; the person did not become a member. | status: "rejected", user |
join_session.ineligible | The person was not eligible to join the syndicate. | status: "ineligible", ineligible_reason ("accreditation_required", "jurisdiction", "blocked", "club_unavailable", or "requires_investment"), user |
join_session.canceled | The join session was canceled before it completed. | status: "canceled", user (null if nobody had signed in) |
join_session.expired | The join session expired before the person completed it. | status: "expired", user (null if nobody had signed in) |
{
"id": "js_8f3kQ2",
"syndicate": "syn_abc123",
"status": "joined",
"ineligible_reason": null,
"user": { "id": "usr_9t2x", "name": "Jane Example" },
"theme": { "id": null, "version": null, "override_fingerprint": null },
"occurred_at": "2026-10-12T20:10:03.412110Z"
}
- A syndicate that requires manager approval emits
join_session.pending_approvalwhen the person finishes the flow, thenjoin_session.completedonce a manager approves them. A syndicate that auto-approves emitsjoin_session.completeddirectly. join_session.completedis the join: when it arrives, the person is a member, and you also receive the matchingsyndicate_member.joined. Key a contact-keyed system onuser.id.themeis the effective theme the session rendered (id,version,override_fingerprint). It isnullacross the board for hosted sessions, which render in Wefunder's own styling; the fields are populated for embedded sessions that pin a partner theme.
Installation events
Delivered to the app the installation belongs to, and to no one else. Both events are about your
own installation record, so neither needs a read scope: any installation receives them.
installation.created reaches your app-wide endpoints and any endpoint bound to the new
installation; installation.revoked reaches your app-wide endpoints only, because an endpoint
bound to the revoked installation stops with it (its queued deliveries are canceled). The
envelope's installation names the installation the event is about — on revoked, one that no
longer exists — so the id + installation.id dedupe key works unchanged.
| Event | Description | data |
|---|---|---|
installation.created | Your app was installed on a company or syndicate, or a user connected it to their account. | id (inst_…), status: "active", target.type / target.id / target.name (name is null for a user), tier, scopes (the granted set), installed_at, installed_by.role, occurred_at |
installation.revoked | An installation of your app was revoked — by the company or syndicate's admin, the user who connected it, your own team, or Wefunder. Its tokens stop working; webhooks for that installation stop. | the created fields with status: "revoked", plus revoked_at and revoked_by.role |
{
"id": "inst_8f3kQ2",
"status": "revoked",
"target": { "type": "company", "id": "co_abc123", "name": "Example Company" },
"tier": "admin",
"scopes": ["read:companies", "read:investments"],
"installed_at": "2026-01-01T00:00:00Z",
"installed_by": { "role": "founder" },
"revoked_at": "2026-01-09T00:00:00Z",
"revoked_by": { "role": "founder" },
"occurred_at": "2026-01-09T00:00:00.000000Z"
}
installed_by.roleandrevoked_by.rolesay who acted, never who they are:founder(a company admin),manager(a syndicate manager),user(the person who connected your app to their own account),partner(someone on your own team, throughDELETE /installations/{id}or the developer portal), orwefunder(Wefunder staff — for example when investor-PII approval is withdrawn). No names or emails.installation.createdis the push equivalent ofGET /installations:tierandscopesare what was actually granted, which can be less than you requested. Treat it as the moment to store the installation id you will see on every later delivery for that company or syndicate.- On
installation.revoked, mark that installation disconnected and stop using its tokens; they already return401. Prefer this event to waiting for a401. A founder who reinstalls your app produces a newinstallation.createdwith a newinst_…id. - A user reconnecting your app with different scopes revokes the old connection and creates a new
one, so you receive
installation.revokedfor the old id andinstallation.createdfor the new.
Referral events
Events for the referral codes your app creates. Both are subscribed to like any other event.
| Event | When | data |
|---|---|---|
investment.referred | An investment was credited to one of your app's referral codes. | referral_code, company, and the anonymized row: opaque investment and investor tokens, week, amount_tier, status, time_to_invest. Never the investor's identity. |
referral_cohorts.published | Every Monday (UTC), once last week's referral cohorts are published, for every app with a referral code active that week. | week (ISO week, e.g. 2026-W42) and published_at. Pull GET /referral_codes/{id}/stats for the numbers. |
investment.referredreaches your app only through an installation on the investment's company that grantsread:attribution:anonymized. Without one you never receive it for that company, in any form, because a per-investment event would tell you that a specific person you sent invested. Read the weekly ranges instead.referral_cohorts.publishedis sent whether or not anything converted, so its arrival tells you nothing except that new weekly cells are ready. It goes to app-wide endpoints only and has noinstallationblock.- The anonymized tokens match the ones
GET /referral_codes/{id}/statsreturns for the same company, so you can join the two. They differ between companies by design.