Subscribe to events from your Nine Pebbles centre and we'll POST them to your URL as they happen. Useful for syncing invoices into your accounting system, posting payment confirmations to Slack, kicking off onboarding flows when a new student enrols, and so on.
See also: REST API reference for polling integrations.
Each delivery is an HTTPS POST with these headers:
Content-Type: application/jsonX-NinePebbles-Event — the event type (e.g. invoice.paid)X-NinePebbles-Delivery-Id — unique per delivery; use as your idempotency keyX-NinePebbles-Centre-Id — which centre the event belongs toX-NinePebbles-Timestamp — Unix seconds when we generated the signatureX-NinePebbles-Signature — sha256=<hex hmac>{
"id": "wd_a1b2c3...",
"type": "invoice.paid",
"centre_id": "...",
"created_at": "2026-05-17T20:00:00.000Z",
"data": {
"invoice_id": "...",
"invoice_number": "INV-2026-0123",
"centre_id": "...",
"amount_paid": 120.00,
"currency": "GBP",
"paid_at": "2026-05-17T20:00:00.000Z",
"payment_method": "stripe"
}
}Compute HMAC-SHA256(secret, timestamp + "." + raw_body) and compare against the header in constant time. Reject timestamps more than 5 minutes from now to prevent replay attacks.
import crypto from 'node:crypto';
// Body of the POST (parse it AFTER verifying the signature)
const rawBody = await new Response(req.body).text();
const timestamp = req.headers['x-ninepebbles-timestamp'];
const signature = req.headers['x-ninepebbles-signature'];
const secret = process.env.NINEPEBBLES_WEBHOOK_SECRET;
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(timestamp + '.' + rawBody)
.digest('hex');
// Reject signatures older than 5 minutes (replay protection)
const ts = parseInt(timestamp, 10);
if (Math.abs(Date.now() / 1000 - ts) > 300) {
return new Response('stale', { status: 400 });
}
if (
signature.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
) {
return new Response('invalid signature', { status: 400 });
}
const event = JSON.parse(rawBody);
console.log('Got event:', event.type, event.id);
// ... handle event ...
return new Response('ok');We may retry a delivery if your endpoint times out or returns a non-2xx. Use X-NinePebbles-Delivery-Id as your idempotency key — if you receive the same id twice, treat the second as a duplicate.
Each event is sent as soon as it happens. On any non-2xx response or network failure (15 second timeout), we retry with backoff (at least 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours; retries currently run once a day, so a retry can take up to a day). After 5 failed attempts we stop and mark the delivery exhausted. The subscription stays active — you can hit Replay in the dashboard to re-queue.
After 10 consecutive failures the subscription gets flagged for attention in your settings UI, but it isn't automatically disabled.
invoice.createdFired when an invoice is generated and sent to a parent.
{
invoice_id, invoice_number, centre_id,
student_id, guardian_id, // either may be null
amount, currency, due_date,
created_at
}invoice.paidFired when an invoice's amount_paid reaches the total amount.
{
invoice_id, invoice_number, centre_id,
amount_paid, currency,
paid_at,
payment_method // "stripe" | "gocardless" | "manual" | "unknown"
}invoice.voidedFired when an admin voids an invoice.
{
invoice_id, invoice_number, centre_id,
void_reason, // may be null
voided_at, voided_by
}payment.failedFired when a payment attempt (card or DD) is declined.
{
invoice_id, invoice_number, centre_id,
failure_reason, // may be null
attempted_at
}student.enrolledFired when a new student is added (via a registration form, lead conversion, or admin).
{
student_id, full_name, centre_id,
enrolled_at
}mandate.activatedFired when a Direct Debit mandate is confirmed by the bank and ready to charge.
{
mandate_id, gc_mandate_id, centre_id,
guardian_id, // may be null
activated_at
}lead.createdFired for each new enquiry (one per child), from any source: website form, intake API, online booking, the enquiry inbox, staff, or POST /api/v1/leads.
{
lead_id, centre_id,
parent_full_name, parent_email, parent_phone, // may be null
child_full_name, child_year_group, // may be null
source, intake_method,
created_at
}With an API key, a tool can subscribe itself: POST /api/v1/hooks with { target_url, event, centre_id } and remove it again with DELETE /api/v1/hooks/{id}. The key needs the scope for that event (invoices → read:invoices, student.enrolled → read:students, mandate.activated → read:guardians, lead.created → read:leads). Revoking the key removes its hooks, and replying 410 Gone to a delivery unsubscribes it. Events are sent straight away; failed deliveries are retried.
https. Localhost and private IP ranges are rejected at save time.Questions? Contact support.