Files
hope-events/frontend/src/components/admin/WebhookGuide.tsx
T
joshuaandClaude Opus 5.5 8f6f90a151 Searchable event picker and webhook guide (1.50.2)
- 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
2026-10-29 08:23:09 +02:00

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&apos;s address. Everything is sent from the server; nothing comes from people&apos;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&apos;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&apos;s id (<code>evt_…</code>). It stays the same on retries, so use it to skip events you&apos;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&apos;s id. Each webhook gets its own, and it&apos;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=&lt;unix seconds&gt;,v1=&lt;signature&gt;</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>&lt;t&gt;.&lt;body&gt;</code>). It&apos;s keyed with the webhook&apos;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&apos;ve already handled.</li>
<li>Events can arrive out of order. Use <code>createdAt</code> and the record&apos;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&apos;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>
);
}