Skip to main content

Webhooks

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.

Setup

  1. Sign in as a centre admin and go to Settings → Webhooks.
  2. Click New webhook. Give it a name, the URL we should POST to, and pick the events you care about.
  3. We'll show you a signing secret once. Copy it somewhere safe (your secret manager) — we won't show it again.
  4. Use the Send test event button to verify your endpoint is reachable.

Request format

Each delivery is an HTTPS POST with these headers:

Example body

{
  "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"
  }
}

Verifying the signature

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');

Idempotency

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.

Retry policy

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.

Event catalog

invoice.created

Fired 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.paid

Fired 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.voided

Fired when an admin voids an invoice.

{
  invoice_id, invoice_number, centre_id,
  void_reason,        // may be null
  voided_at, voided_by
}

payment.failed

Fired when a payment attempt (card or DD) is declined.

{
  invoice_id, invoice_number, centre_id,
  failure_reason,     // may be null
  attempted_at
}

student.enrolled

Fired when a new student is added (via a registration form, lead conversion, or admin).

{
  student_id, full_name, centre_id,
  enrolled_at
}

mandate.activated

Fired 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.created

Fired 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
}

Subscribing from Zapier, Make or n8n (REST hooks)

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.

Security notes

Questions? Contact support.