Skip to main content

Managing Webhook Endpoints

Register and manage webhook endpoints with the /webhook_endpoints API. Endpoints belong to your application — every installation of your app shares them.

Scopes & permissions​

ActionScopeExtra requirement
Read (GET)read:webhooksThe acting user must have an owner, admin, or developer role in the app's organization
Write (create / update / delete / rotate / reenable / test)write:webhooksThe acting user must have an owner, admin, or developer role in the app's organization

Endpoints are developer-plane configuration, so every call requires an org management role in addition to the scope — the same permission that governs the developer portal. Other users who authorize your app with these scopes get 403 forbidden_role: an endpoint URL can itself be a secret, so it is never shown to them.

Sandbox​

Manage endpoints through the live API, including endpoints for sandbox. Create a test endpoint with "mode": "test" using a live token; it is mirrored into sandbox, which delivers your test events to it.

Against the sandbox API (a _test_ token), you can list and fetch your app's endpoints, send test events, and re-enable an endpoint that sandbox auto-disabled (sandbox checks endpoint health on its own, so a test endpoint can be disabled there while it stays enabled on live). Create, update, delete and rotate return:

{
"error": {
"type": "manage_endpoints_on_live_api",
"message": "Webhook endpoints can't be changed through the sandbox API. Manage them through the live API; create test endpoints there with mode \"test\" and they are mirrored into sandbox."
}
}

On sandbox, reads and test sends need no organization role: sandbox has no organization memberships, and only users in your app's own sandbox realm can hold a token for it.

Create an endpoint​

POST /webhook_endpoints
Authorization: Bearer YOUR_ACCESS_TOKEN
Content-Type: application/json

{
"url": "https://yourapp.com/webhooks/wefunder",
"events": ["offering.opened", "investment.executed"],
"mode": "live"
}

url must be HTTPS and must resolve to a public IP (private/internal addresses are rejected — SSRF protection). events must be names from the event catalog. mode is live (default) or test.

Response​

{
"data": {
"id": "whe_8f3ExampleEndpoint00",
"type": "webhook_endpoint",
"attributes": {
"url": "https://yourapp.com/webhooks/wefunder",
"mode": "live",
"events": ["offering.opened", "investment.executed"],
"enabled": true,
"secret": "whsec_…",
"failing_since": null,
"last_delivery_at": null,
"last_delivery_status": null,
"created_at": "2026-01-15T12:00:00Z"
}
},
"meta": { "request_id": "…" }
}
Save the secret now

secret is returned only in this create response and when you rotate it — never again. Store it; you need it to verify signatures.

Each app can have up to 10 endpoints (meta.quota on the list response shows your limit). Delete an endpoint to free a slot.

List / fetch​

GET /webhook_endpoints            # your app's endpoints (secret omitted); meta.quota shows the limit
GET /webhook_endpoints/{id} # a single endpoint (secret omitted)

Installation-bound endpoints​

An endpoint is normally app-wide: your server receives every installation's events, one delivery per matched installation, each stamped with its installation (see Events). A broker that needs one URL per tenant (Zapier REST hooks, Slack incoming webhooks, n8n) creates an endpoint bound to one installation instead:

POST /webhook_endpoints
{ "url": "https://hooks.zapier.com/hooks/catch/…", "events": ["investment.created"], "installation": "inst_…" }
  • installation must be an active installation of your app. An unknown or revoked id is 404, never a silent app-wide endpoint.
  • The credential decides the tenancy. An installation token may omit installation; it always binds to its own installation and can never create or touch an app-wide endpoint. A user token may bind only to an installation whose company, syndicate, or account the user has a role on at or above the installation's granted tier; a token that is bound to one company or syndicate reaches only that one. An org management role grants nothing over bound endpoints — they belong to the tenant.
  • Bound endpoints are live only, capped at 5 per installation, and do not count against your app-wide quota.
  • Creating a bound endpoint is idempotent on (installation, url, mode): repeating the call with the same events returns the existing endpoint with 200 (no secret, no url), and with different events returns 409 conflict.
  • A bound endpoint's url is returned only in the create response, together with the secret. Every later response — list, fetch, update, for every credential including your own developer tokens and the portal — carries url_hint (host plus the last six characters) instead. If the tenant needs the full URL again, they have it; we do not.
  • Deliveries to a bound endpoint carry exactly its installation, so deduplicating on id alone is enough there. The developer portal lists bound endpoints read-only; its write actions return 403 bound_endpoint.

Every endpoint response now includes installation (inst_… or null for app-wide).

Availability

The contract above is what the API enforces; creating a bound endpoint is a separate switch. Until it is turned on, POST /webhook_endpoints with installation answers 403 bound_endpoints_not_enabled ("Installation-bound webhook endpoints are not available yet"); reads, url_hint masking, and the installation field on responses are already live. Per-installation delivery to app-wide endpoints is another switch: until it is on, an app-wide endpoint receives one delivery per event even when several installations match, still stamped with the installation it was produced for. Build against the contract on this page and deduplicate on the pair from day one, and nothing changes on your side when either switch opens. This note is updated when they do.

Update​

PATCH /webhook_endpoints/{id}

{ "events": ["offering.opened"] }

events replaces the subscription list wholesale — it is not merged. Omitting events leaves it unchanged. An endpoint must always subscribe to at least one event, so an empty array (or null) is rejected — to stop receiving everything, delete the endpoint instead.

Delete​

DELETE /webhook_endpoints/{id}

Removes the endpoint. No new deliveries are queued for it from that moment, and every delivery still waiting on it — pending or dead-lettered — is canceled shortly after. Canceled deliveries cannot be resent, and an endpoint can't be un-removed; create a new one if you need it back, and bootstrap it from a list or your saved cursor rather than expecting the old endpoint's backlog.

Rotate the signing secret​

POST /webhook_endpoints/{id}/rotate_secret

Returns a new secret (shown once). The previous secret keeps signing deliveries for a 24-hour overlap window, so you can roll the new secret out to your servers without dropping a single event — during that window the Wefunder-Signature header carries a v1 for both secrets, and verification accepts either.

Re-enable a disabled endpoint​

If an endpoint is auto-disabled after sustained failures:

POST /webhook_endpoints/{id}/reenable

Re-enabling flips the endpoint back on and clears its failure streak, so new events are queued for it again. It does not replay anything. Two things it will not bring back on its own:

  • Dead-lettered deliveries stay dead. Deliveries that exhausted their 72-hour retry window while your server was failing are kept, but re-enabling does not resend them. Resend them yourself (below).
  • Events from the disabled period were never queued. While an endpoint is disabled, Wefunder creates no deliveries for it, so there is nothing to resend for that window. The only way to recover those changes is to pull them.

Two clocks run independently, and knowing which one you are looking at matters here. Each delivery retries for 72 hours from when it was created (or from when it was last resent), then dead-letters. The endpoint is disabled only after every delivery to it has failed for 3 consecutive days, measured from the first failure of the streak and reset by any success. Disabling does not stop the deliveries already in the queue: a delivery that was pending when the endpoint went off keeps its own retry schedule, is still attempted, lands if your server now answers 2xx, and otherwise dead-letters at its own horizon. Only deleting the endpoint, or uninstalling (or reinstalling) the installation, cancels queued deliveries.

Recover in this order:

  1. Fix your server.
  2. Send a signed test. POST /webhook_endpoints/{id}/test works on a disabled endpoint and never touches its health. Confirm your handler verifies the signature and returns 2xx for the events you subscribe to.
  3. Re-enable with the call above.
  4. Inspect and resend dead deliveries. In the developer portal, open the endpoint under your app's Webhooks tab and filter its Deliveries to dead. Each row shows the stored payload and every attempt's response. Resend is per delivery and is accepted only while the endpoint is enabled and the delivery's installation is still active; a resent delivery gets a fresh 72-hour window. Canceled rows (the endpoint was deleted, or the installation was uninstalled or reinstalled) are not resendable. Deliveries to an installation-bound endpoint cannot be resent from the portal, because the URL belongs to the tenant, and there is no API for resending them today.
  5. Reconcile from your durable cursor. For investments, run GET /investments?cursor=… from the cursor you saved before the outage; it returns every change since, including the ones from the disabled window, with cents and currency. The lifecycle events are a nudge, not the ledger. For syndicate members and offerings, list the resource and compare.

Test your endpoint​

POST /webhook_endpoints/{id}/test
{ "event": "offering.opened" } # optional; defaults to the endpoint's first subscribed event

Sends a real, signed example event — the same envelope shape, signature scheme, and transport as a production delivery, with obviously-fake example values. Investor email appears only if your app holds read:investors:pii, the same as in real deliveries. Use it to confirm your endpoint verifies the signature and returns 2xx. The response reports the outcome inline:

{
"data": {
"type": "webhook_test",
"delivered": true,
"response_code": 200,
"error": null,
"duration_ms": 142,
"event": "offering.opened",
"payload": { }
}
}

error is null whenever your endpoint answered (check response_code for the status). When no response came back it is one of timeout, blocked_url (the URL failed the HTTPS/public-address check), tls_error, or connection_failed. An endpoint's last_delivery_status uses the same codes, or the HTTP status as a string such as "503".

A test never affects your endpoint's delivery health — a failing test won't count toward auto-disable — and it leaves no row in the endpoint's delivery history. (Rate-limited to 10 per minute per endpoint.)

The test envelope is marked so your handler can recognize it without guessing from mode:

{
"id": "evt_test_7Qx3ExampleTestEvent",
"event": "investment.created",
"created_at": "2026-01-15T12:00:00Z",
"mode": "live",
"installation": { "id": "inst_test_9kExampleTestInst00", "target": null, "test": true },
"data": { "id": "inv_example000000000000", "status": "active", "amounts": { "committed": 250 }, "…": "…" }
}
  • id starts with evt_test_.
  • mode is the endpoint's mode, so a test to a live endpoint says live. mode tells you where real events come from (sandbox or live); it is not a test marker.
  • installation is a synthetic block with target: null and test: true when the endpoint is registered for the whole app, because no single installation produced the event. When the endpoint is bound to one installation, it carries that installation's real block instead, since that is the only identity the endpoint will ever receive; the evt_test_ id is then the marker.
  • data holds fake records with recognizable ids (inv_example…, ofr_example…, usr_example) and whole-unit amounts (250; see amounts).

Gate production side effects on installation.test === true || id.startsWith("evt_test_"): verify the signature, log the delivery, return 2xx, and skip creating CRM contacts, deals, funding records or notifications. Reuse the same check in your own fixtures so a replayed test never looks like a real event.

Local development

localhost and private IPs are rejected by design. To test against a local server, use a tunnel (ngrok, cloudflared) that gives you a public HTTPS URL.

Was this page helpful?