- EventPicker replaces the plain event dropdowns on staff/supervisor pages and the kiosk:
upcoming first, past behind "Show past events", search across all, keyboard support.
- Reports events list can be searched ("Select these N").
- Site settings -> Webhooks: "How webhooks work" guide with headers, signature checking
(Node.js/PHP) and an example of every event type (EVENT_TYPES[].example, shape-tested).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
173 lines
12 KiB
TypeScript
173 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState } from "react";
|
|
import { BookOpen, ChevronDown, ChevronRight, Copy } from "lucide-react";
|
|
|
|
type EventType = { type: string; label: string; description: string; example?: unknown };
|
|
|
|
const RETRIES = "1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours";
|
|
|
|
const VERIFY_NODE = `// Node.js / Express — check the signature before trusting a request.
|
|
const crypto = require("crypto");
|
|
const SECRET = process.env.CROSSEVENTS_WEBHOOK_SECRET; // whsec_…
|
|
|
|
app.post("/crossevents-hook", express.raw({ type: "application/json" }), (req, res) => {
|
|
const header = req.get("X-CrossEvents-Signature") || ""; // t=1767225600,v1=5f0c…
|
|
const { t, v1 } = Object.fromEntries(header.split(",").map(p => p.split("=")));
|
|
const body = req.body.toString("utf8"); // the raw body, unparsed
|
|
const expected = crypto.createHmac("sha256", SECRET).update(\`\${t}.\${body}\`).digest("hex");
|
|
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300; // within 5 minutes
|
|
if (!fresh || !v1 || !crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) {
|
|
return res.status(400).send("bad signature");
|
|
}
|
|
const event = JSON.parse(body);
|
|
// Deliveries can repeat: skip an event.id you've already handled.
|
|
res.sendStatus(200); // answer quickly, then do the slow work
|
|
handle(event);
|
|
});`;
|
|
|
|
const VERIFY_PHP = `<?php // PHP — check the signature before trusting a request.
|
|
$secret = getenv('CROSSEVENTS_WEBHOOK_SECRET');
|
|
$body = file_get_contents('php://input');
|
|
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_CROSSEVENTS_SIGNATURE'] ?? ''), $sig);
|
|
$expected = hash_hmac('sha256', $sig['t'] . '.' . $body, $secret);
|
|
if (abs(time() - (int) $sig['t']) > 300 || !hash_equals($expected, $sig['v1'] ?? '')) {
|
|
http_response_code(400); exit('bad signature');
|
|
}
|
|
$event = json_decode($body, true);
|
|
http_response_code(200);`;
|
|
|
|
function CopyButton({ text, label = "Copy" }: { text: string; label?: string }) {
|
|
const [done, setDone] = useState(false);
|
|
return (
|
|
<button type="button" className="inline-flex items-center gap-1 px-2 py-0.5 rounded border bg-white text-[11px] hover:bg-gray-50"
|
|
onClick={() => { navigator.clipboard?.writeText(text).then(() => { setDone(true); setTimeout(() => setDone(false), 1500); }).catch(() => {}); }}>
|
|
<Copy className="w-3 h-3" aria-hidden /> {done ? "Copied" : label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function Code({ text }: { text: string }) {
|
|
return (
|
|
<div className="relative">
|
|
<div className="absolute right-2 top-2"><CopyButton text={text} /></div>
|
|
<pre className="max-h-80 overflow-auto rounded-lg bg-gray-900 text-gray-100 p-3 pr-20 text-[11px] leading-relaxed">{text}</pre>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const H = ({ children }: { children: React.ReactNode }) => <h4 className="text-sm font-semibold text-gray-900 mt-4 mb-1.5">{children}</h4>;
|
|
|
|
/**
|
|
* Site settings → Webhooks → "How webhooks work": what arrives, when, how to check it's from us,
|
|
* and an example of every event type, for whoever builds the receiving side.
|
|
*/
|
|
export function WebhookGuide({ eventTypes, keepDays, defaultOpen = false }: { eventTypes: EventType[]; keepDays: number; defaultOpen?: boolean }) {
|
|
const [open, setOpen] = useState(defaultOpen);
|
|
const [openType, setOpenType] = useState<string | null>(null);
|
|
const [lang, setLang] = useState<"node" | "php">("node");
|
|
const envelope = (t: EventType) => JSON.stringify({ id: "evt_3f9c2a1e-6d4b-4c8e-9a51-2b7d0e6f1c34", type: t.type, createdAt: "2026-10-29T08:16:01.512Z", data: t.example ?? {} }, null, 2);
|
|
|
|
return (
|
|
<section className="border rounded-xl bg-white" data-testid="webhook-guide">
|
|
<button type="button" aria-expanded={open} onClick={() => setOpen(o => !o)} className="w-full flex items-center gap-2 px-4 py-3 text-left">
|
|
{open ? <ChevronDown className="w-4 h-4 text-gray-500" aria-hidden /> : <ChevronRight className="w-4 h-4 text-gray-500" aria-hidden />}
|
|
<BookOpen className="w-4 h-4 text-brand-700" aria-hidden />
|
|
<span className="font-medium text-gray-900">How webhooks work</span>
|
|
<span className="text-xs text-gray-500">what is sent, the signature, and an example of each event</span>
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="px-4 pb-4 text-sm text-gray-700 border-t">
|
|
<H>What happens</H>
|
|
<ol className="list-decimal pl-5 space-y-1">
|
|
<li>Something happens here, such as a registration or a payment, that one of your webhooks is set to send.</li>
|
|
<li>A second or two later, an <code>HTTP POST</code> with a JSON body goes to that webhook's address. Everything is sent from the server; nothing comes from people's browsers.</li>
|
|
<li>Your side answers with any <code>2xx</code> status within 10 seconds, and the delivery is done.</li>
|
|
<li>
|
|
Anything else counts as a failure: another status, no answer, or a redirect (redirects aren't followed). The delivery is tried again after {RETRIES}, then marked <em>failed</em>.
|
|
You can send any delivery again from its <em>Delivery log</em>.
|
|
</li>
|
|
</ol>
|
|
|
|
<H>Using it without writing code</H>
|
|
<p>
|
|
In Zapier, use <strong>Webhooks by Zapier → Catch Hook</strong>; in Make, use <strong>Webhooks → Custom webhook</strong>. Paste the address it gives you here, choose the events, then click <em>Send test</em>. The fields in the examples below become available to map into ChurchSuite, Planning Center, a spreadsheet, Mailchimp and so on.
|
|
</p>
|
|
|
|
<H>What arrives</H>
|
|
<p className="mb-2">Every request has the same envelope. <code>data</code> depends on the type (see the examples below).</p>
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full text-xs border rounded">
|
|
<thead className="bg-gray-50 text-left"><tr><th className="px-2 py-1.5">Field</th><th className="px-2 py-1.5">Meaning</th></tr></thead>
|
|
<tbody className="divide-y">
|
|
<tr><td className="px-2 py-1.5"><code>id</code></td><td className="px-2 py-1.5">The event's id (<code>evt_…</code>). It stays the same on retries, so use it to skip events you've already handled.</td></tr>
|
|
<tr><td className="px-2 py-1.5"><code>type</code></td><td className="px-2 py-1.5">The event type, e.g. <code>registration.paid</code>. The <em>Send test</em> button sends <code>webhook.test</code>.</td></tr>
|
|
<tr><td className="px-2 py-1.5"><code>createdAt</code></td><td className="px-2 py-1.5">When it happened (ISO 8601, UTC).</td></tr>
|
|
<tr><td className="px-2 py-1.5"><code>data</code></td><td className="px-2 py-1.5">The details. Amounts are in rand; dates are ISO 8601 in UTC.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p className="mt-3 mb-2">Headers:</p>
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full text-xs border rounded">
|
|
<tbody className="divide-y">
|
|
<tr><td className="px-2 py-1.5 whitespace-nowrap"><code>Content-Type</code></td><td className="px-2 py-1.5"><code>application/json</code></td></tr>
|
|
<tr><td className="px-2 py-1.5 whitespace-nowrap"><code>User-Agent</code></td><td className="px-2 py-1.5"><code>CrossEvents-Webhooks/1</code></td></tr>
|
|
<tr><td className="px-2 py-1.5 whitespace-nowrap"><code>X-CrossEvents-Event</code></td><td className="px-2 py-1.5">The event type, the same as <code>type</code>.</td></tr>
|
|
<tr><td className="px-2 py-1.5 whitespace-nowrap"><code>X-CrossEvents-Delivery</code></td><td className="px-2 py-1.5">This delivery's id. Each webhook gets its own, and it's the same on retries.</td></tr>
|
|
<tr><td className="px-2 py-1.5 whitespace-nowrap"><code>X-CrossEvents-Signature</code></td><td className="px-2 py-1.5"><code>t=<unix seconds>,v1=<signature></code>, explained below.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<H>Checking it came from here</H>
|
|
<p className="mb-2">
|
|
Anyone who knows the address could post to it, so check the signature before acting on a request. <code>v1</code> is the HMAC-SHA256, in hex, of <code>t</code>, a full stop, and the raw request body (<code><t>.<body></code>). It's keyed with the webhook's signing secret, the <code>whsec_…</code> shown once when you add the webhook or make a new secret.
|
|
Work it out on the raw body before parsing the JSON, compare in constant time, and reject a <code>t</code> more than 5 minutes old.
|
|
</p>
|
|
<div className="flex gap-1 mb-2" role="group" aria-label="Example language">
|
|
{(["node", "php"] as const).map(l => (
|
|
<button key={l} type="button" aria-pressed={lang === l} onClick={() => setLang(l)}
|
|
className={`px-2 py-0.5 rounded text-xs border ${lang === l ? "bg-brand-600 text-white border-brand-600" : "bg-white hover:bg-gray-50"}`}>
|
|
{l === "node" ? "Node.js" : "PHP"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<Code text={lang === "node" ? VERIFY_NODE : VERIFY_PHP} />
|
|
|
|
<H>Good to know</H>
|
|
<ul className="list-disc pl-5 space-y-1">
|
|
<li>An event can arrive more than once, for example after a retry or a manual resend, so use <code>id</code> to skip ones you've already handled.</li>
|
|
<li>Events can arrive out of order. Use <code>createdAt</code> and the record's <code>status</code>, not the order they arrive in.</li>
|
|
<li>Each payload is built about a second after the event, from the record as it is then, and retries send that same payload. If a registration is paid and then changed back within that second, nothing is sent.</li>
|
|
<li>Kids check-in and volunteer tickets are never sent. A registration made only of those sends nothing, and those lines are left out of <code>items</code>.</li>
|
|
<li>Only <code>https</code> addresses on the public internet are allowed. Each webhook's log keeps what was sent for {keepDays} days.</li>
|
|
<li>Turning a webhook off stops new deliveries to it. Deleting it removes its log too.</li>
|
|
</ul>
|
|
|
|
<H>Event types and examples</H>
|
|
<ul className="border rounded-lg divide-y">
|
|
{eventTypes.map(t => (
|
|
<li key={t.type}>
|
|
<button type="button" aria-expanded={openType === t.type} onClick={() => setOpenType(openType === t.type ? null : t.type)}
|
|
className="w-full flex items-start gap-2 px-3 py-2 text-left hover:bg-gray-50">
|
|
{openType === t.type ? <ChevronDown className="w-4 h-4 mt-0.5 text-gray-500 shrink-0" aria-hidden /> : <ChevronRight className="w-4 h-4 mt-0.5 text-gray-500 shrink-0" aria-hidden />}
|
|
<span className="flex-1">
|
|
<code className="text-xs font-semibold text-gray-900">{t.type}</code> <span className="text-gray-900">· {t.label}</span>
|
|
<span className="block text-xs text-gray-500">{t.description}</span>
|
|
</span>
|
|
</button>
|
|
{openType === t.type && <div className="px-3 pb-3" data-testid={`webhook-example-${t.type}`}><Code text={envelope(t)} /></div>}
|
|
</li>
|
|
))}
|
|
<li className="px-3 py-2 text-xs text-gray-600">
|
|
<code className="font-semibold text-gray-900">webhook.test</code> · Sent by <em>Send test</em>. <code>data</code> is <code>{`{ "message": "…" }`}</code>, so your side can answer 2xx and ignore it.
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|