Webhooks

Payments Central sends HTTP POST notifications to your endpoint when payment events occur, so you can react in real time without polling.

How webhooks work

When an event occurs (e.g. a transaction is captured), Payments Central sends an HTTP POST request to your webhook URL with a JSON body describing the event. Your endpoint must respond with 200 OK within 10 seconds.

Event types

EventDescription
transaction.createdA new transaction was created
transaction.capturedA transaction was successfully captured
transaction.failedA transaction was declined or failed
transaction.refundedA full or partial refund was issued
transaction.voidedA transaction was voided before settlement
ledger.entry.postedA journal entry was posted to the ledger

Event payload

Every webhook POST body follows this structure:

{
  "event": "transaction.captured",
  "id": "evt_01HXYZ999",
  "created_at": "2026-05-12T10:00:05Z",
  "data": {
    "id": "txn_01HXYZ123456",
    "merchant_id": "mer_01ABCDEF",
    "status": "captured",
    "amount": 4999,
    "currency": "USD",
    "gateway": "stripe",
    "merchant_ref": "order-8821",
    "created_at": "2026-05-12T10:00:00Z",
    "updated_at": "2026-05-12T10:00:05Z"
  }
}

Registering a webhook endpoint

  1. Go to Dashboard → Settings → Webhooks
  2. Click Add endpoint
  3. Enter your HTTPS URL (e.g. https://yourapp.com/webhooks/payments-central)
  4. Select the event types you want to subscribe to
  5. Copy the signing secret — you'll use it to verify deliveries

Verifying webhook signatures

Every webhook delivery includes a X-PC-Signature header — an HMAC-SHA256 of the raw request body signed with your webhook secret. Always verify this before processing the event.

Node.js verification

import crypto from 'crypto';

function verifyWebhook(rawBody: Buffer, signature: string, secret: string): boolean {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  // Use timingSafeEqual to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

// Express handler
app.post('/webhooks/payments-central', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['x-pc-signature'] as string;
  if (!sig || !verifyWebhook(req.body, sig, process.env.PC_WEBHOOK_SECRET!)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body.toString());

  switch (event.event) {
    case 'transaction.captured':
      await fulfillOrder(event.data.merchant_ref);
      break;
    case 'transaction.failed':
      await notifyCustomerOfFailure(event.data.merchant_ref);
      break;
  }

  res.sendStatus(200);
});

Python verification

import hashlib, hmac, os
from flask import Flask, request, abort

app = Flask(__name__)

def verify_webhook(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, expected)

@app.route('/webhooks/payments-central', methods=['POST'])
def handle_webhook():
    sig = request.headers.get('X-PC-Signature', '')
    if not verify_webhook(request.get_data(), sig, os.environ['PC_WEBHOOK_SECRET']):
        abort(401)

    event = request.get_json()

    if event['event'] == 'transaction.captured':
        fulfill_order(event['data']['merchant_ref'])

    return '', 200
Always verify signatures Processing unsigned webhooks is a security risk — attackers could forge fake payment success events. Verification takes 3 lines of code; always do it.

Retry behaviour

If your endpoint returns a non-2xx status or times out, Payments Central retries with exponential backoff:

AttemptDelay
1st retry1 minute
2nd retry5 minutes
3rd retry30 minutes
4th retry2 hours
5th retry8 hours

After 5 failed attempts the delivery is abandoned and marked as failed in the dashboard.

Idempotent event handling

Use the event id field (e.g. evt_01HXYZ999) to deduplicate redeliveries. Store processed event IDs and skip duplicates:

const alreadyProcessed = await db.exists('processed_events', event.id);
if (!alreadyProcessed) {
  await processEvent(event);
  await db.insert('processed_events', { id: event.id });
}

Testing webhooks locally

Use a tunnel tool to expose your local server during development:

# Using ngrok
ngrok http 3000

# Your tunnel URL:
# https://abc123.ngrok.io/webhooks/payments-central
# Register this URL in the dashboard under a test webhook endpoint
Sandbox webhook delivery Sandbox webhook events are delivered the same way as production. Test your full verification + processing pipeline in sandbox before going live.