Handling Webhooks
Process webhook deliveries reliably. This guide uses the official SDK; the hand-rolled recipe for other languages is in Verification.
Every delivery is a signed POST with a Wefunder-Signature header and a JSON envelope:
{
"id": "evt_0KZq8xExampleOpened01",
"event": "offering.opened",
"created_at": "2026-01-15T12:00:00Z",
"mode": "live",
"installation": { "id": "inst_7hQExampleInstall01", "target": { "type": "company", "id": "co_3kExampleCompany001" } },
"data": { "offering": "ofr_9m2ExampleRound00", "status": "open", "company": { "name": "Example Company", "url": "example-company" } }
}
Register an endpoint
Endpoints belong to your application and are managed through the live API (write:webhooks, org owner/admin/developer role). The signing secret is returned only on create and rotate.
import { Wefunder } from "@wefunder/sdk";
const endpoint = await wf.webhookEndpoints.create({
url: "https://yourapp.com/webhooks/wefunder", // public HTTPS; localhost is rejected — use a tunnel locally
events: ["offering.opened", "investment.executed"],
mode: "live", // "test" endpoints receive sandbox events
});
await saveSecret(endpoint.attributes!.secret!); // shown once
See Setup for update, remove, rotate, re-enable, and the full event catalog.
An endpoint created this way is app-wide: it receives every installation's events, and each delivery names its installation. Setup also describes endpoints bound to one installation for brokers that need one URL per tenant, and which of the two is open to you today.
What a 2xx means
Your response is the only signal we have about a delivery. When your endpoint answers with any 2xx, we mark the delivery done and never send it again. When it answers anything else, or does not answer at all (timeout, TLS or connection failure), we retry it with exponential backoff (30 seconds after the first failure, doubling, capped at 6 hours between attempts) for 72 hours, then dead-letter it where it can be redriven. Retries carry the same envelope, same id, same installation.
So a 2xx is a promise that the work will happen. Returning it before the work is safe anywhere, as long as the work is already on disk when you return. Returning it with the work only in memory is not: a crash, a deploy, or a thrown exception between the 200 and the handler loses the event, and nothing retries it.
The order that keeps the promise, in every language:
- Verify the signature against the raw body. Reject forgeries with
400. - Persist the delivery to durable storage, keyed on
(id, installation.id). One write that is also the dedupe: a repeat of a pair you already hold inserts nothing. - Acknowledge with
2xxonly after the write committed. If the write fails, let the exception propagate so the response is5xxand we retry. - Process from the stored row in a worker. A failure there leaves the row unprocessed for the worker to retry; the acknowledgement already happened and nothing is lost.
Verify and handle
constructEvent verifies the signature against the raw request body and parses the envelope. It throws WebhookSignatureError (with a reason) when a delivery is not authentic. The returned event is a discriminated union, so narrowing on event.event types event.data.
The handler below does steps 1 to 3; the worker after it does step 4. inbox is any durable table with a unique index on (event_id, installation_id), a payload column, and a processed_at column. The same shape works with a job queue in place of the polling worker as long as the enqueue is transactional with the insert (a database-backed queue, or an outbox the queue drains); an enqueue to a separate broker after the insert can be lost between the two.
// Express
import express from "express";
import { constructEvent, WebhookSignatureError } from "@wefunder/sdk";
const app = express();
app.post("/webhooks/wefunder", express.raw({ type: "*/*" }), async (req, res) => {
let event;
try {
event = constructEvent(req.body, req.headers, process.env.WEFUNDER_WEBHOOK_SECRET!);
} catch (err) {
if (err instanceof WebhookSignatureError) return res.status(400).send(err.reason);
throw err;
}
// The SDK's verified event object carries id, event, created_at, mode, data and timestamp,
// not `installation` (as of @wefunder/sdk 0.1.0-beta.13). Read it from the raw body the
// signature just verified; every event in today's catalog names its installation.
const installationId = JSON.parse(req.body).installation.id;
// Durable before acknowledged. INSERT ... ON CONFLICT (event_id, installation_id) DO NOTHING:
// a retry of a pair we already hold inserts nothing and is acknowledged again; the same event
// for a second installation is a different pair and is stored on its own. If this throws,
// Express answers 500 and Wefunder retries the delivery.
await inbox.insert({
eventId: event.id,
installationId,
payload: req.body, // the raw bytes; the worker parses them
});
res.sendStatus(200); // the row is committed; from here the worker owns it
});
// Worker: runs on a timer or a listener, never inside the request.
import { dispatchWebhook } from "@wefunder/sdk";
async function drainInbox() {
for (const row of await inbox.claimUnprocessed({ limit: 50 })) { // SELECT ... FOR UPDATE SKIP LOCKED
const event = JSON.parse(row.payload); // already verified when it was stored
// The installation travels with the stored row, so the worker never re-derives it.
await dispatchWebhook(event, {
"investment.executed": async (e) => recordFunding(row.installationId, e.data.id, e.data.amounts.committed),
"offering.opened": async (e) => announce(row.installationId, e.data.company.name),
default: (e) => console.log("unhandled", e.event),
});
await inbox.markProcessed(row.id); // a throw above leaves the row unprocessed; the next pass retries it
}
}
Handlers receive the installation with the event, so a worker serving several installations writes each delivery under its own installation (the company, syndicate, or user it concerns) and uses the token it holds for that installation when it calls the API. Nothing from one installation's delivery should reach another installation's rows.
For fetch-style servers (Next.js route handlers, Hono, Cloudflare Workers, Bun), pass the Request; the order is the same:
import { constructEventFromRequest, WebhookSignatureError } from "@wefunder/sdk";
export async function POST(request: Request) {
const raw = await request.clone().text(); // keep the exact bytes for the inbox
let event;
try {
event = await constructEventFromRequest(request, process.env.WEFUNDER_WEBHOOK_SECRET!);
} catch (err) {
if (err instanceof WebhookSignatureError) return new Response(err.reason, { status: 400 });
throw err; // 5xx: not stored, so Wefunder retries
}
const installationId = JSON.parse(raw).installation.id; // from the verified raw body, see above
await inbox.insert({ eventId: event.id, installationId, payload: raw });
return new Response(null, { status: 200 }); // committed first, then acknowledged
}
Verify the bytes as received. Parsing the JSON and re-serializing it can reorder keys or change spacing and will break the signature. With Express that means express.raw, not express.json, on the webhook route.
Test your handler
wf.webhookEndpoints.test(endpoint.id) sends a real, signed example event to your endpoint and reports the outcome inline. To unit-test without the API, sign a fixture yourself:
import { signWebhook } from "@wefunder/sdk";
const body = JSON.stringify({
id: "evt_test",
event: "offering.opened",
created_at: "2026-01-15T12:00:00Z",
mode: "test",
installation: { id: "inst_test", target: { type: "company", id: "co_test" } }, // real deliveries carry this; the inbox keys on it
data: { /* … */ },
});
const header = signWebhook({ payload: body, secret });
// POST `body` to your handler with `Wefunder-Signature: ${header}`
Best practices
- Persist, then respond. Write the delivery to durable storage, then return
2xx; do the work from the stored row, never from the request. A2xxmarks the delivery done on our side for good, so a2xxwith the work still in memory is a lost event if the process dies. Respond within the request timeout: a delivery that gets no2xxis retried over 72 hours, and an endpoint that fails for three days straight is disabled (see What a2xxmeans). - Deduplicate on the pair. Delivery is at-least-once. Key your inbox on the envelope
idtogether withinstallation.idand let the unique index reject repeats of the pair. An endpoint registered for the whole app receives one delivery per installation an event concerns — sameid, differentinstallationanddata— so keying onidalone drops all but the first. Two legitimate deliveries with the sameidand differentinstallation.idare both processed, each under its own installation; a retry of either pair is a no-op. Takeinstallation.idfrom the raw body after verification: the current SDK helpers (@wefunder/sdk0.1.0-beta.13,wefunder0.1.0b2 for Python,wefunder0.1.0.beta4 for Ruby) return an event object withoutinstallation, so reading it from that object silently keys every delivery the same and collapses the second installation's copy into a no-op. The pair key is correct today, when an app-wide endpoint still receives a single delivery per event, and stays correct when per-installation delivery turns on. - Order by
occurred_at, not arrival. Retries can deliver events out of order. Where a payload carriesoccurred_at, keep the state from the latest one. - Treat
investment.changedas a nudge. Its payload is deliberately thin; fetch the record withGET /investments/{id}or sync from your saved cursor. - Rotate without downtime. After
rotateSecret, deliveries carry av1for both secrets for 24 hours, andconstructEventaccepts either.