Skip to content

PayBridge -- Webhook Documentation ​

This document covers how PayBridge forwards payment processor events to your application and how to handle them securely.


Overview ​

PayBridge acts as a webhook intermediary between payment processors (Clearent/XplorPay) and your application:

Processor (Clearent)
    |
    | processor-specific event
    v
PayBridge /webhooks/clearent
    |
    | 1. Verify processor signature
    | 2. Check timestamp freshness (5-min window)
    | 3. Deduplicate by gateway event ID
    | 4. Update internal DB state
    | 5. Map to standardized event type
    | 6. Sign and forward to your webhook URL
    v
Your Application (registered webhook_url)

Your application receives standardized event types regardless of which processor originated the event. You never need to handle processor-specific event formats.


Event Types ​

PayBridge emits four categories of standardized events: payment, merchant onboarding, checkout, and document events. Your handler receives the same standardized shapes regardless of which processor originated them.

Payment Events ​

Event TypeWhen It FiresTrigger Conditions
payment.completedA payment has been settled or approved by the processorClearent: transaction.settled or transaction.approved
payment.declinedA payment was declined by the processorClearent: transaction.declined
payment.refundedA refund has been processed (full or partial)Clearent: transaction.refunded

Refund coverage. payment.refunded covers both full and partial refunds — a partial refund reports only the refunded portion, a full refund the whole amount. Each refund is recorded as its own transaction carrying a negative amount. To correlate a refund to its original sale, use the RefundResponse returned by POST /payments/{id}/refund — its id is the refund and its payment_id is the original sale. The webhook payment_id is processor-dependent and is not a reliable link back to the sale (see the event detail below).

Not currently emitted. PayBridge does not emit separate capture, void, or dispute/chargeback events — the processor integration does not surface them as distinct lifecycle callbacks today. A successful sale is reported via payment.completed (which may arrive more than once — see its details below). If you need capture/void/dispute handling, reconcile against GET /payments/{payment_id} and the processor's own reporting; do not wait for a webhook that is not sent.

Merchant Onboarding Events ​

Event TypeWhen It Fires
merchant.approvedThe onboarding application was approved; the merchant can process payments
merchant.boardedThe merchant is fully boarded / live at the processor
merchant.pendedThe application was pended by the processor
merchant.manual_reviewThe application entered manual review (corresponds to pending_review status)
merchant.rejectedThe onboarding application was denied

Checkout Events ​

Event TypeWhen It Fires
checkout.completedA hosted checkout session payment succeeded

Document Events ​

Fired while the processor's underwriting requests or reviews supporting documents for a merchant.

Event TypeWhen It Fires
document.requiredThe processor requires a document to continue underwriting
document.uploadedA required document was uploaded
document.acceptedAn uploaded document was accepted
document.rejectedAn uploaded document was rejected (re-upload needed)

Webhook Envelope ​

All outbound webhooks use a consistent JSON envelope. The structure varies slightly between payment events and merchant events.

Payment Event Envelope ​

json
{
    "event_type": "payment.completed",
    "webhook_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "payment_id": "a1b2c3d4-5678-9abc-def0-1234567890ab",
    "merchant_id": "b2c3d4e5-6789-0abc-def1-234567890abc",
    "amount": 75000,
    "status": "posted",
    "timestamp": "2026-03-10T12:15:30.123456+00:00"
}
FieldTypeDescription
event_typestringOne of: payment.completed, payment.declined, payment.refunded
webhook_idstring (UUID)Unique delivery ID. Use for deduplication.
payment_idstring (UUID)PayBridge transaction ID
merchant_idstring (UUID)Merchant that owns this transaction
amountintAmount in minor units (cents). 75000 = $750.00
statusstringTransaction status after this event (posted, declined, refunded)
timestampstring (ISO 8601)When this webhook was generated

Merchant Event Envelope ​

json
{
    "event_type": "merchant.approved",
    "webhook_id": "a23bc45d-67ef-8901-a234-5b6c7d8e9f01",
    "merchant_id": "b2c3d4e5-6789-0abc-def1-234567890abc",
    "processor_type": "clearent",
    "processor_merchant_id": "CLR_12345",
    "onboarding_status": "approved",
    "timestamp": "2026-03-11T14:00:00.000000+00:00"
}
FieldTypeDescription
event_typestringOne of: merchant.approved, merchant.boarded, merchant.pended, merchant.manual_review, merchant.rejected
webhook_idstring (UUID)Unique delivery ID. Use for deduplication.
merchant_idstring (UUID)Merchant whose onboarding status changed
processor_typestringProcessor type: clearent
processor_merchant_idstringExternal merchant ID at the processor
onboarding_statusstringThe merchant's new onboarding status (e.g. approved, boarded, pending_review, rejected)
rejection_reasonstringOptional reason when the event is merchant.rejected
timestampstring (ISO 8601)When this webhook was generated

Document Event Envelope ​

Document-lifecycle events (document.*) carry document identifiers instead of the payment/onboarding fields.

json
{
    "event_type": "document.rejected",
    "webhook_id": "c45de67f-8901-2bcd-ef34-567890123456",
    "document_id": "d1e2f3a4-5678-90bc-def1-234567890abc",
    "merchant_id": "b2c3d4e5-6789-0abc-def1-234567890abc",
    "document_type": "bank_statement",
    "status": "rejected",
    "rejection_reason": "Statement is older than 90 days",
    "timestamp": "2026-03-11T14:00:00.000000+00:00"
}
FieldTypeDescription
event_typestringOne of: document.required, document.uploaded, document.accepted, document.rejected
webhook_idstring (UUID)Unique delivery ID. Use for deduplication.
document_idstring (UUID)The document this event concerns
merchant_idstring (UUID)Merchant the document belongs to
document_typestringThe kind of document (processor-defined, e.g. bank_statement)
statusstringThe document's status after this event
descriptionstringOptional human-readable detail
rejection_reasonstringOptional reason, present on document.rejected
timestampstring (ISO 8601)When this webhook was generated

Event Details ​

payment.completed ​

Fired when a payment is settled or approved by the processor. This is the primary success signal -- update your order status, send receipts, and fulfill the order when you receive this event.

Envelope example:

json
{
    "event_type": "payment.completed",
    "webhook_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "payment_id": "txn_abc123",
    "merchant_id": "a1b2c3d4-...",
    "amount": 75000,
    "status": "posted",
    "timestamp": "2026-03-10T12:15:30.123456+00:00"
}

Key details:

  • The amount field is always in minor units (cents). 75000 = $750.00.
  • The status field will be posted for both settled and approved events.
  • A single sale can produce more than one payment.completed. Clearent sends transaction.approved and transaction.settled as distinct events, and both map to payment.completed. They carry the same payment_id (the same underlying transaction) but different webhook_ids, so webhook_id-based dedup does not collapse them — treat payment.completed idempotently per payment_id.
  • For failover payments (where the primary processor failed and the secondary succeeded), the webhook fires normally. The payment_id corresponds to the same transaction you originally created. You can check GET /payments/{payment_id} to see failover_from_id if you need to know failover occurred.
  • This event maps from Clearent transaction.settled/transaction.approved.

Recommended actions:

  1. Mark the order as paid in your system.
  2. Send a payment confirmation email to the customer.
  3. Begin fulfillment (ship product, activate service, etc.).

payment.declined ​

Fired when a payment is declined by the processor. Decline reasons include insufficient funds, invalid card, expired card, and processor-specific codes.

Envelope example:

json
{
    "event_type": "payment.declined",
    "webhook_id": "d12e34f5-6789-0abc-def1-234567890abc",
    "payment_id": "txn_def456",
    "merchant_id": "a1b2c3d4-...",
    "amount": 75000,
    "status": "declined",
    "timestamp": "2026-03-10T12:16:00.000000+00:00"
}

Key details:

  • The status field will be declined.
  • The amount field reflects the attempted charge amount in cents.
  • Decline reasons are not included in the webhook envelope. Query GET /payments/{payment_id} to get the full decline details if needed.
  • Terminal declines (insufficient funds, stolen card, expired card) are not retried by the failover engine. Only transient processor errors trigger failover.

Recommended actions:

  1. Notify the customer that their payment was declined.
  2. Prompt them to update their payment method or try a different card.
  3. Update your order status to "payment failed" or similar.

payment.refunded ​

Fired when a refund is processed by the processor. This covers both full and partial refunds.

Envelope example:

json
{
    "event_type": "payment.refunded",
    "webhook_id": "e23f45a6-7890-1bcd-ef23-456789012345",
    "payment_id": "txn_abc123",
    "merchant_id": "a1b2c3d4-...",
    "amount": -25000,
    "status": "refunded",
    "timestamp": "2026-03-12T11:00:00.000000+00:00"
}

Key details:

  • The amount field is the refund amount as a negative number in cents. -25000 = $250.00 refunded.
  • To link a refund back to its original sale, use the RefundResponse from POST /payments/{id}/refund (its id is the refund, its payment_id the original). The webhook payment_id is processor-dependent — on Aptexx it is the refund's own id, while on Clearent the refund currently shares the original sale's processor id — so do not rely on it to correlate.
  • For partial refunds, the amount reflects only the refunded portion; each partial refund is recorded as its own transaction.
  • For full refunds, the amount equals the negative of the original payment amount.
  • The refund is routed to the same processor that handled the original payment, including failover scenarios. If the original payment failed over to the secondary processor, the refund goes to the secondary processor.

Recommended actions:

  1. Update your order to reflect the refund (partial or full).
  2. Notify the customer that their refund has been processed.
  3. Adjust any revenue/accounting records.

merchant.approved ​

Fired when a merchant's onboarding application is approved by the processor. The merchant can now process payments.

Envelope example:

json
{
    "event_type": "merchant.approved",
    "webhook_id": "a23bc45d-67ef-8901-a234-5b6c7d8e9f01",
    "merchant_id": "a1b2c3d4-...",
    "processor_type": "clearent",
    "processor_merchant_id": "CLR_12345",
    "onboarding_status": "approved",
    "timestamp": "2026-03-11T14:00:00.000000+00:00"
}

Recommended actions:

  1. Enable payment processing in your UI for this merchant.
  2. Notify the merchant that their account is active.
  3. Begin accepting payments through this merchant.

merchant.rejected ​

Fired when a merchant's onboarding application is denied by the processor.

Envelope example:

json
{
    "event_type": "merchant.rejected",
    "webhook_id": "b34cd56e-78f0-1234-a567-890123456789",
    "merchant_id": "a1b2c3d4-...",
    "processor_type": "clearent",
    "processor_merchant_id": "CLR_12345",
    "onboarding_status": "rejected",
    "rejection_reason": "Failed underwriting review",
    "timestamp": "2026-03-11T16:00:00.000000+00:00"
}

Recommended actions:

  1. Notify the merchant that their application was denied.
  2. Suggest they review their submitted information and re-apply.
  3. Direct them to your support team for assistance.

Notes:

  • rejection_reason is included when PayBridge has a reason from the processor or from an admin override.
  • If a processor webhook is delayed or missed, the same merchant event envelope may be generated by the onboarding reconcile path after polling the processor status.

checkout.completed ​

Fired when a customer completes payment on a hosted checkout session. This is the primary event for integrations that use the hosted checkout flow (redirect the customer to checkout_url).

Envelope example:

json
{
    "event_type": "checkout.completed",
    "webhook_id": "c45de67f-8901-2bcd-ef34-567890123456",
    "session_id": "session-uuid",
    "payment_id": "payment-uuid",
    "merchant_id": "a1b2c3d4-...",
    "amount": 75000,
    "currency": "USD",
    "status": "posted",
    "timestamp": "2026-03-26T12:02:30.000000+00:00"
}
FieldTypeDescription
event_typestringAlways checkout.completed
webhook_idstring (UUID)Unique delivery ID. Use for deduplication.
session_idstring (UUID)The checkout session ID
payment_idstring (UUID)PayBridge transaction ID
merchant_idstring (UUID)Merchant that owns this session
amountintAmount in minor units (cents). 75000 = $750.00
currencystring3-letter ISO currency code (e.g. USD)
statusstringTransaction status (posted)
timestampstringISO 8601 UTC timestamp

Recommended actions:

  1. Verify the session_id matches the order you created the session for.
  2. Confirm the amount matches the expected order total.
  3. Mark the order as paid and fulfil it.
  4. You can also verify via GET /checkout/sessions/{session_id} — always confirm from your backend, do not trust redirect query parameters alone.

Signature Verification ​

Every outbound webhook includes an X-PayBridge-Signature header containing an HMAC-SHA256 signature of the request body. Always verify this signature to ensure the webhook was sent by PayBridge and was not tampered with.

Signature Format ​

X-PayBridge-Signature: sha256=<hex-encoded HMAC-SHA256>

The HMAC is computed over the raw JSON request body bytes using your app's webhook_secret (returned when your consumer app is provisioned via POST /admin/apps).

Verification Steps ​

  1. Read the raw request body as bytes (do NOT parse JSON first).
  2. Compute HMAC-SHA256 of the raw body using your webhook_secret as the key.
  3. Prepend sha256= to your computed hex digest.
  4. Compare your computed signature with the X-PayBridge-Signature header using a constant-time comparison function (to prevent timing attacks).
  5. Reject the request if the signatures do not match.

Python Example ​

python
import hashlib
import hmac
import json

from fastapi import FastAPI, Header, HTTPException, Request

app = FastAPI()

WEBHOOK_SECRET = "your-webhook-secret-from-provisioning"


def verify_signature(body: bytes, signature: str, secret: str) -> bool:
    """Verify X-PayBridge-Signature header."""
    expected = "sha256=" + hmac.new(
        secret.encode(), body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)


@app.post("/api/pb-webhooks")
async def handle_pb_webhook(
    request: Request,
    x_paybridge_signature: str = Header(..., alias="X-PayBridge-Signature"),
):
    body = await request.body()

    if not verify_signature(body, x_paybridge_signature, WEBHOOK_SECRET):
        raise HTTPException(status_code=401, detail="Invalid signature")

    payload = json.loads(body)
    event_type = payload["event_type"]
    webhook_id = payload.get("webhook_id")

    # Deduplicate: check if you have already processed this webhook_id
    # if already_processed(webhook_id):
    #     return {"status": "ok"}

    if event_type == "payment.completed":
        handle_payment_completed(payload)
    elif event_type == "payment.declined":
        handle_payment_declined(payload)
    elif event_type == "payment.refunded":
        handle_payment_refunded(payload)
    elif event_type == "merchant.approved":
        handle_merchant_approved(payload)
    elif event_type == "merchant.rejected":
        handle_merchant_rejected(payload)

    # Mark this webhook_id as processed
    # mark_processed(webhook_id)

    return {"status": "ok"}

Node.js Example ​

javascript
const express = require("express");
const crypto = require("crypto");

const app = express();

// Capture raw body for signature verification
app.use(
  express.json({
    verify: (req, _res, buf) => {
      req.rawBody = buf;
    },
  })
);

const WEBHOOK_SECRET = "your-webhook-secret-from-provisioning";

function verifySignature(rawBody, signature, secret) {
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", secret).update(rawBody).digest("hex");

  // Constant-time comparison to prevent timing attacks
  try {
    return crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(signature)
    );
  } catch {
    return false;
  }
}

app.post("/api/pb-webhooks", (req, res) => {
  const signature = req.headers["x-paybridge-signature"];

  if (!signature || !verifySignature(req.rawBody, signature, WEBHOOK_SECRET)) {
    return res.status(401).json({ error: "Invalid signature" });
  }

  const { event_type, webhook_id, payment_id, merchant_id, amount } = req.body;

  // Deduplicate: check if you have already processed this webhook_id
  // if (alreadyProcessed(webhook_id)) return res.json({ status: "ok" });

  switch (event_type) {
    case "payment.completed":
      console.log(`Payment ${payment_id} completed: $${(amount / 100).toFixed(2)}`);
      break;
    case "payment.declined":
      console.log(`Payment ${payment_id} declined`);
      break;
    case "payment.refunded":
      console.log(`Refund on ${payment_id}: $${(Math.abs(amount) / 100).toFixed(2)}`);
      break;
    case "merchant.approved":
      console.log(`Merchant ${merchant_id} approved`);
      break;
    case "merchant.rejected":
      console.log(`Merchant ${merchant_id} rejected`);
      break;
    default:
      console.log(`Unknown event: ${event_type}`);
  }

  // Mark this webhook_id as processed
  // markProcessed(webhook_id);

  res.json({ status: "ok" });
});

app.listen(3000, () => console.log("Webhook server listening on port 3000"));

PHP Example ​

php
<?php

$webhookSecret = 'your-webhook-secret-from-provisioning';

// Read the raw request body
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_PAYBRIDGE_SIGNATURE'] ?? '';

// Verify signature
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $webhookSecret);

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid signature']);
    exit;
}

$payload = json_decode($rawBody, true);
$eventType = $payload['event_type'] ?? '';
$webhookId = $payload['webhook_id'] ?? '';

// Deduplicate: check if you have already processed this webhook_id
// if (alreadyProcessed($webhookId)) {
//     http_response_code(200);
//     echo json_encode(['status' => 'ok']);
//     exit;
// }

switch ($eventType) {
    case 'payment.completed':
        $paymentId = $payload['payment_id'];
        $amount = $payload['amount'];
        // Update order status, send receipt
        error_log("Payment {$paymentId} completed: $" . number_format($amount / 100, 2));
        break;

    case 'payment.declined':
        $paymentId = $payload['payment_id'];
        // Notify customer
        error_log("Payment {$paymentId} declined");
        break;

    case 'payment.refunded':
        $paymentId = $payload['payment_id'];
        $amount = abs($payload['amount']);
        // Update records
        error_log("Payment {$paymentId} refunded: $" . number_format($amount / 100, 2));
        break;

    case 'merchant.approved':
        $merchantId = $payload['merchant_id'];
        // Enable payment processing
        error_log("Merchant {$merchantId} approved");
        break;

    case 'merchant.rejected':
        $merchantId = $payload['merchant_id'];
        // Notify merchant
        error_log("Merchant {$merchantId} rejected");
        break;

    default:
        error_log("Unknown event: {$eventType}");
}

// Mark this webhook_id as processed
// markProcessed($webhookId);

http_response_code(200);
echo json_encode(['status' => 'ok']);

Retry Behavior ​

PayBridge uses a best-effort delivery model. Failed forwards can be retried, but retries are admin-triggered, not automatic (see below). All four event families — merchant.*, payment.*, checkout.completed, and document.* — persist a delivery record, so a failed forward is visible to support and can be retried.

Delivery Mechanics ​

AspectDetail
Timeout10 seconds per delivery attempt
Max attempts5 total (1 initial + 4 retries)
Retry triggerA network error or timeout only. A non-2xx HTTP response is logged and treated as delivered — it is not retried, so always return 2xx.
Retry mechanismAdmin-triggered: POST /admin/retry-webhooks (bulk, forwards under the attempt cap) or POST /admin/webhooks/{id}/requeue (single event, re-arms an exhausted forward) — there is no automatic scheduler
Coveragemerchant.*, payment.*, checkout.completed, and document.* all record forward_failed_at on failure and are retryable. Status polling remains the recommended fallback for confirmation.

How Retries Work ​

  1. Initial delivery: When an event occurs (a processor callback, a completed checkout, or a document lifecycle change), PayBridge immediately attempts to forward it to your registered webhook_url.
  2. Failure tracking: Only a network error or timeout marks the webhook event with forward_failed_at and increments the attempt count. A non-2xx HTTP response does not — it is logged and treated as delivered, so it is never retried.
  3. Retry execution: Retries are triggered by calling POST /admin/retry-webhooks (bulk, respects the backoff schedule below). This queries all failed events with fewer than 5 attempts and retries them.
  4. Permanent failure: After 5 total attempts, automatic bulk retries stop and the event is considered permanently failed. It can still be re-armed and delivered on request via POST /admin/webhooks/{id}/requeue, which resets the attempt counter — used, for example, to replay events lost to a delivery bug.
  5. SSRF re-validation: On every retry, the webhook URL is re-validated against private/internal networks. If a previously safe URL now resolves to an internal IP, the retry is permanently blocked.

Retry Schedule ​

The exponential backoff below is enforced in code: a retry attempted before an event's window has elapsed is skipped (recorded as skipped_backoff), never sent early. What is not automatic is the trigger — retries are batch-processed only when POST /admin/retry-webhooks runs (the PayBridge operations team runs it periodically, or it can be configured as a cron job). So the windows below are the minimum delay before an attempt becomes eligible; the actual send also waits for the next admin run.

AttemptBackoff before eligible (enforced)
1 (initial)Immediate
230 seconds after the initial failure
35 minutes
430 minutes
52 hours

After attempt 5 the event is permanently failed for bulk retries; it can still be re-armed via POST /admin/webhooks/{id}/requeue (which resets the attempt counter and bypasses the backoff gate for that one deliberate send).

Fallback: Status Polling ​

Since webhook delivery is best-effort, always implement status polling as a fallback:

  • Payment status: GET /payments/{payment_id} to check if a payment has been settled/declined.
  • Onboarding status: GET /merchants/{merchant_id}/onboard/status to check if a merchant has been approved/rejected.
  • Checkout sessions: GET /checkout/sessions/{session_id} to check if a session has been completed.

Event Ordering ​

PayBridge does not guarantee that webhooks arrive in the order the underlying events occurred. Delivery is best-effort and per-event: each event is forwarded independently, and a failed forward is retried on its own backoff curve (up to 2 hours between attempts). A payment.completed that failed its first send and a later payment.refunded that succeeded immediately can therefore reach your endpoint out of order, and the same is true across the merchant lifecycle (merchant.pended → merchant.approved).

Design your handler so ordering does not matter:

  • Treat each webhook as a signal to re-read state, not as the state itself. On any payment event, the authoritative status is GET /payments/{payment_id}; on any merchant event, it is GET /merchants/{merchant_id}/onboard/status. The webhook tells you when to check; the API tells you what is true now.
  • Use the timestamp field to detect stale events. It reflects when PayBridge generated the webhook. If you receive an event older than the state you already recorded for that payment_id/merchant_id, treat it as informational and do not roll your state backward.
  • Do not assume a terminal event is last. A retried non-terminal event can arrive after a terminal one; gate irreversible actions (refund accounting, account deactivation) on the API status, not on webhook arrival order.

Idempotency and Deduplication ​

Inbound Deduplication (Processor to PayBridge) ​

PayBridge deduplicates inbound processor webhooks using a composite key of gateway_id:event_type. This means:

  • The same transaction receiving both transaction.approved and transaction.settled events are treated as distinct (different event types).
  • A duplicate transaction.settled event for the same gateway transaction ID is silently dropped.
  • Deduplication uses SELECT ... FOR UPDATE to prevent race conditions from concurrent webhook deliveries.

Outbound Deduplication (PayBridge to Your App) ​

Every outbound webhook includes a webhook_id field (a UUID). Use this as a deduplication key on your side:

  1. When you receive a webhook, check if you have already processed this webhook_id.
  2. If yes, return 200 OK without reprocessing.
  3. If no, process the event and store the webhook_id as processed.

This protects against the case where PayBridge delivers the same webhook multiple times (for example, if your server returned 200 but PayBridge did not receive the response before timing out).

Recommended implementation:

sql
-- Example (MySQL — adapt syntax for your database)
CREATE TABLE processed_webhooks (
    webhook_id CHAR(36) PRIMARY KEY,
    processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Before processing:
INSERT INTO processed_webhooks (webhook_id) VALUES ($1)
ON CONFLICT (webhook_id) DO NOTHING
RETURNING webhook_id;
-- If no row returned, the webhook was already processed

Best Practices ​

1. Respond Quickly ​

Return a 2xx status code as fast as possible (ideally within 1-2 seconds). Do not perform heavy processing synchronously in your webhook handler. Instead:

  • Validate the signature.
  • Store the event in a queue or database.
  • Return 200 OK.
  • Process the event asynchronously (background worker, queue consumer, etc.).

2. Handle All Event Types ​

Your handler should gracefully accept any event_type, including ones you do not recognize. Return 200 OK for unknown events so that PayBridge does not retry them. This future-proofs your integration against new event types.

3. Verify Signatures in Production ​

Always verify the X-PayBridge-Signature header. Without verification, an attacker could forge webhooks to your endpoint and trigger fraudulent order fulfillment.

4. Use HTTPS ​

Your webhook endpoint must use HTTPS in production. PayBridge validates webhook URLs against internal/private networks (SSRF protection) and will reject URLs targeting 127.0.0.1, 10.x.x.x, 192.168.x.x, and other private ranges.

5. Implement Idempotent Handling ​

Always use the webhook_id to deduplicate. Processing the same event twice could result in sending duplicate emails, double-crediting refunds, or other unintended side effects.

6. Validate Merchant Ownership ​

When processing a webhook, verify that the merchant_id in the payload belongs to your application. This provides defense-in-depth against webhook URL misconfiguration.

7. Monitor for Missing Webhooks ​

Implement a reconciliation check that periodically polls the status APIs to catch events that may have been missed due to webhook delivery failures:

python
# Example: Check for payments that completed but we never got a webhook for
import httpx

async def reconcile_payments():
    async with httpx.AsyncClient() as client:
        response = await client.get(
            f"{BASE_URL}/payments/merchants/{MERCHANT_ID}/transactions",
            headers={"X-API-Key": API_KEY},
            params={"status": "posted", "limit": 100},
        )
        transactions = response.json()["items"]
        for txn in transactions:
            if not is_webhook_received(txn["id"]):
                # Process this payment manually
                handle_payment_completed_manually(txn)

Setting Up Your Webhook URL ​

Your webhook URL is configured when your consumer app is provisioned via POST /admin/apps (include the webhook_url field). To update it later (requires admin JWT from POST /admin/auth/login):

bash
curl -X PUT "$BASE_URL/admin/apps/$APP_ID/webhook-url" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_JWT" \
  -d '{"webhook_url": "https://your-domain.com/api/pb-webhooks"}'

Requirements for webhook URLs:

  • Must be a valid HTTPS URL (HTTP allowed only in development).
  • Must resolve to a public IP address (not 127.0.0.1, 10.x.x.x, 192.168.x.x, or other private ranges).
  • Must respond within 10 seconds.
  • Must return a 2xx status code to acknowledge receipt.

Testing Your Webhook Handler ​

Use POST /webhooks/test to send a test event to your registered webhook URL. This verifies your handler is correctly receiving and validating HMAC-signed events before you go live.

bash
curl -X POST "$BASE_URL/webhooks/test" \
  -H "X-API-Key: $API_KEY"

The test event uses event_type: "test" and includes a dummy merchant_id. Your handler should accept it and return 2xx.

Response:

json
{
  "delivered": true,
  "status_code": 200,
  "webhook_url": "https://your-domain.com/api/pb-webhooks"
}

If delivery fails, the response includes an error field with the failure reason.


Webhook Security Summary ​

ProtectionImplementation
AuthenticityHMAC-SHA256 signature in X-PayBridge-Signature header
IntegritySignature covers the entire raw request body
Replay preventionInbound processor webhook freshness check (5-minute window)
DeduplicationComposite gateway_id:event_type key with SELECT FOR UPDATE
SSRF preventionWebhook URL validated against private/internal networks on every delivery and retry
DNS rebinding preventionAfter URL validation, HTTP connection is pinned to the resolved IP
Timing attack preventionConstant-time signature comparison (hmac.compare_digest / crypto.timingSafeEqual / hash_equals)