Skip to main content

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": {}
}
  • id is unique per event. A delivery can arrive more than once; use id together with installation.id to ignore repeats.
  • installation is the installation this delivery was produced for. target.type is company, syndicate, or user, and target.id is 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 own installation and its own data; an endpoint created for one installation only ever carries that installation. Every event except referral_cohorts.published is about something an app is installed on, so those deliveries carry installation (a signed test delivery to an app-wide endpoint carries a synthetic one; see Setup). The installation events are about an installation too: their installation names the one that was created or revoked — on installation.revoked, an installation that no longer exists. referral_cohorts.published is about your app itself: it reaches app-wide endpoints only and carries no installation block (see Referral events).
  • mode is test for events that happened in the sandbox, and live otherwise. It says where the event happened, not whether the envelope is a test: a test send to a live endpoint arrives with mode: live.
  • Test envelopes are the ones POST /webhook_endpoints/{id}/test sends. They go through the same projection, signature and transport as a real delivery, and they are marked in two places: id starts with evt_test_, and for an endpoint registered for the whole app installation is 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 (no test key), since that is the only identity it will ever carry. data holds 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 on installation.test === true or the evt_test_ prefix, never on mode, 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_…)
  • status
  • offering (ofr_…)
  • amounts.committed
  • investor.id, the investor (usr_…), stable across events
  • investor.name
  • investor_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 holds
  • total_committed, their sum, in the same units as amounts.committed
  • latest_applied_at, when the most recent of them was made, or null
  • investor_status, active while any remain, none_active when 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.

SurfaceA $250 investment
investment.created → data.amounts.committed250
investment.amount_changed after the investor raises it to $500 → data.amounts.committed / data.previous_amounts.committed500 / 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.currency25000, "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.

EventDescriptiondata.statusExtra fields
investment.createdAn 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.reinstatedA canceled investment or reservation was reinstated within the grace period — it is active (or reserved) again.active or reserved
investment.canceledAn investment or reservation was canceled. Conversions are never cancellations — one that moved to a successor emits investment.converted instead.canceledcanceled_at
investment.convertedAn investment converted into a successor investment — a reservation became a live investment, or the investment moved to a new offering or vehicle.convertedconverted_to, the successor (inv_…)
investment.amount_changedThe amount of an investment or reservation changed.active, reserved, or executedprevious_amounts.committed
investment.executedAn 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.

EventDescription
investment.changedThe 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 with GET /investments/{id}, or run your sync from your saved cursor with GET /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: false means 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: true is the investment's first appearance in the stream: a new investment when visible is true. fields lists 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. changed is everything else; investor_deactivated marks 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"), when visible: false for an id you hold ("canceled"), or when fields contains status, group or amounts for an id you hold ("payment landed", "executed", "amount changed"). Skip reason: "recomputed" and every other fields combination. 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 id plus installation.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).

EventDescription
offering.openedAn offering opened — accepting investments, or reservations for Testing-the-Waters offerings.
offering.closingAn offering stopped accepting investments and its funds are awaiting disbursement.
offering.closedAn offering's funds were disbursed — the raise is complete.
offering.canceledAn 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.

EventDescription
investment_session.createdA hosted investment session was created for a recipient.
investment_session.startedThe recipient opened the investment session link and began the hosted flow.
investment_session.completedAn investment session completed — the recipient's investment was created and confirmed.
investment_session.expiredAn investment session expired before the recipient completed it.
investment_session.canceledAn 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.

EventDescriptiondata
syndicate_member.invitedSomeone 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.reinvitedAn outstanding invitation to join the syndicate was re-sent.same as invited
syndicate_member.appliedSomeone 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.approvedA manager approved someone's request to join the syndicate.id, status: "approved", user, approved_by.id / approved_by.name
syndicate_member.joinedSomeone 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.leftSomeone 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.declinedA 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.joined with approved_by: null.
  • In a fund syndicate the manager approves before the investment payment settles, so approved and joined are separate events and can be minutes or days apart. applied never repeats for the same request; it repeats only when a rejected investor applies again.
  • Invitations are keyed by email because no membership exists yet. When the invitee later joins, the joined event's user.email matches, so a contact-keyed system can link the two.
  • left is the stop signal: a member can leave and come back, so a joined can follow a left for the same id. payment_failed means the manager's approval stands and the investor was asked to re-invest; a later successful payment arrives as joined again.
  • declined closes an application the way joined would have: after a declined, the next event for that id is a fresh applied if the person applies again. A member being removed is left, never declined.

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).

EventDescriptiondata
join_session.pending_approvalSomeone completed a join session and is waiting for a manager to approve them into the syndicate.status: "pending_approval", user
join_session.completedThe person became a member of the syndicate.status: "joined", user
join_session.rejectedA manager rejected a join session that was awaiting approval; the person did not become a member.status: "rejected", user
join_session.ineligibleThe person was not eligible to join the syndicate.status: "ineligible", ineligible_reason ("accreditation_required", "jurisdiction", "blocked", "club_unavailable", or "requires_investment"), user
join_session.canceledThe join session was canceled before it completed.status: "canceled", user (null if nobody had signed in)
join_session.expiredThe 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_approval when the person finishes the flow, then join_session.completed once a manager approves them. A syndicate that auto-approves emits join_session.completed directly.
  • join_session.completed is the join: when it arrives, the person is a member, and you also receive the matching syndicate_member.joined. Key a contact-keyed system on user.id.
  • theme is the effective theme the session rendered (id, version, override_fingerprint). It is null across 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.

EventDescriptiondata
installation.createdYour 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.revokedAn 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.role and revoked_by.role say 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, through DELETE /installations/{id} or the developer portal), or wefunder (Wefunder staff — for example when investor-PII approval is withdrawn). No names or emails.
  • installation.created is the push equivalent of GET /installations: tier and scopes are 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 return 401. Prefer this event to waiting for a 401. A founder who reinstalls your app produces a new installation.created with a new inst_… id.
  • A user reconnecting your app with different scopes revokes the old connection and creates a new one, so you receive installation.revoked for the old id and installation.created for the new.

Referral events​

Events for the referral codes your app creates. Both are subscribed to like any other event.

EventWhendata
investment.referredAn 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.publishedEvery 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.referred reaches your app only through an installation on the investment's company that grants read: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.published is 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 no installation block.
  • The anonymized tokens match the ones GET /referral_codes/{id}/stats returns for the same company, so you can join the two. They differ between companies by design.
Was this page helpful?