Webhooks
Webhooks push your organization's volunteer and shift activity to a URL you control, so you can sync a CRM, trigger an automation, or log to a spreadsheet in real time. Configure one destination URL under Settings → Webhooks. Webhooks are available on the Engage plan.
Each endpoint only ever receives events for its own organization.
The request
Every event is an HTTP POST with a JSON body in this envelope:
{
"id": "d6b1a3e2-8c47-4f0a-9b21-2f9a7e5c1d80",
"type": "signup.created",
"created_at": "2026-07-10T14:32:00.000Z",
"organization_id": "8a7c1f34-5e2b-4c9d-a1f6-0b3e7d9c2a11",
"data": { /* event-specific, see below */ }
}Along with these headers:
| X-Webhook-Event | The event type, e.g. signup.created. |
| X-Webhook-Delivery | Unique delivery id (equals the body's id). De-duplicate on this. |
| X-Webhook-Signature | sha256=<hex> — HMAC-SHA256 of the raw body, keyed by your signing secret. |
| Content-Type | application/json |
Verifying the signature
Recompute the signature over the raw request body using your endpoint's signing secret (find and rotate it in Settings) and compare with a constant-time check. Reject anything that doesn't match.
import crypto from "crypto";
import express from "express";
const app = express();
// IMPORTANT: verify against the RAW body, before JSON parsing.
app.post(
"/webhooks/volunteers",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.header("X-Webhook-Signature") ?? "";
const expected =
"sha256=" +
crypto
.createHmac("sha256", process.env.VSM_WEBHOOK_SECRET)
.update(req.body) // req.body is a Buffer here
.digest("hex");
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid) return res.status(401).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
// Delivery is at-least-once — de-duplicate on event.id before acting.
handleEvent(event);
res.status(200).send("ok"); // 2xx = delivered; anything else is retried
},
);Delivery & retries
- Delivery is at-least-once. De-duplicate on the delivery
id. - Respond 2xx quickly to acknowledge. Any other status (or a timeout past 10s) is treated as a failure.
- Failures are retried with exponential backoff — roughly after 1m, 5m, 30m, 2h, then 6h — up to 6 attempts before the delivery is marked failed.
- Redirects are not followed. Point the URL directly at your handler.
- Do your real work asynchronously; return 200 first.
Events
| volunteer.created | A volunteer record is first created for your organization. |
| signup.created | A volunteer signs up and is confirmed for a shift. |
| signup.waitlisted | A volunteer signs up for a full shift and joins its waitlist. |
| signup.promoted | A waitlisted volunteer moves into a confirmed spot. |
| signup.canceled | A shift signup is canceled by anyone (volunteer, coordinator, or system). |
| volunteer.updated | A volunteer's name, contact info, or communication preferences change. |
| volunteer.removed | A volunteer leaves your organization or is deleted. |
| volunteer.checked_in | A volunteer is checked in to a shift. |
| volunteer.checked_out | A volunteer is checked out (clocked out) of a shift. |
Volunteer events
volunteer.created — a person is added to your org:
"data": {
"volunteer": {
"id": "3f2a...",
"name": "Jordan Lee",
"email": "jordan@example.org",
"phone": "+15551234567",
"created_at": "2026-07-10T14:32:00.000Z"
}
}volunteer.updated — details or preferences change:
"data": {
"volunteer": {
"id": "3f2a...",
"name": "Jordan Lee",
"email": "jordan@example.org",
"phone": "+15551234567",
"email_consent": true,
"sms_consent": false
}
}volunteer.removed — they leave or are deleted:
"data": {
"volunteer": { "id": "3f2a...", "name": "Jordan Lee", "email": "jordan@example.org" },
"reason": "left" // "left" (self-service) or "deleted" (coordinator)
}Signup events
signup.created, signup.waitlisted, signup.promoted, signup.canceled, volunteer.checked_in, and volunteer.checked_out all share this shape — the type tells you what happened and the relevant fields are populated (e.g. waitlist_position on a waitlisted signup, checked_in_at on a check-in):
"data": {
"signup": {
"id": "9c4d...",
"status": "confirmed", // confirmed | waitlist | canceled
"waitlist_position": null, // set when status is "waitlist"
"canceled_by": null, // "volunteer" | "coordinator" | "system"
"cancel_reason": null,
"checked_in_at": null, // ISO timestamp on volunteer.checked_in
"checked_out_at": null // ISO timestamp on volunteer.checked_out
},
"volunteer": { "id": "3f2a...", "name": "Jordan Lee", "email": "jordan@example.org" },
"shift": {
"id": "1b8e...",
"title": "Saturday food bank",
"start_time": "2026-07-18T13:00:00.000Z",
"end_time": "2026-07-18T16:00:00.000Z",
"program_name": "Weekend meals"
}
}Ready to start? Add your endpoint in Settings and use “Send test event” to see your first delivery.