In a nutshellWebhooks let you set up a notification system that automatically sends you updates when certain actions happen via the Pandascrow API — like payments, escrows, or account changes.
Introduction
Most API calls return a result immediately — success or failure in the same response. Some don't. Payment confirmations, escrow completions, milestone releases, and long-running processes can take seconds, minutes, or hours to reach a final state.
Rather than have your server poll for updates, Pandascrow sends a webhook — an HTTP POST to a URL you control — the moment an event reaches a terminal state.
By subscribing to webhooks, your system stays in sync without polling, and your users see updates in real time.
📩 Receiving & Acknowledging Webhooks
When Pandascrow delivers an event, we send an HTTP POST to your configured webhook URL with a JSON body and a set of authentication headers.
Your endpoint should:
- Read the raw request body. Do not parse and re-serialize it — see Verifying event origin below.
- Verify the signature using your secret key.
- Process the event idempotently. The same event may arrive more than once if we retry.
- Respond with HTTP 200 OK and a JSON body indicating success.
A successful acknowledgment looks like this:
HTTP/1.1 200 OK
Content-Type: application/json
{
"status": true
}Any response other than HTTP 2xx — or a 2xx response whose body is not {"status": true} — is treated as a delivery failure and triggers a retry.
🔁 Retry behavior
If your endpoint doesn't acknowledge a webhook, we retry with exponential backoff:
| Mode | Schedule |
|---|---|
| Live | Every 3 minutes for the first 4 attempts, then once per hour for up to 72 hours |
| Test | Once per hour for up to 72 hours |
After 72 hours without a successful acknowledgment, the delivery is abandoned. You can view failed deliveries and manually replay them from your dashboard under Developers → Webhooks → Deliveries.
🛡️ Verifying event origin
Your webhook URL is public. Anyone who knows it can send a POST to it. To reject spoofed requests, verify that each event was signed by Pandascrow.
We send three headers with every webhook:
| Header | Description |
|---|---|
X-Pandascrow-Signature | HMAC-SHA256 hex digest of the raw request body |
X-Pandascrow-Timestamp | Unix timestamp (seconds) at delivery time |
X-Pandascrow-Event | The event type, e.g. escrow.completed |
Signature format
The X-Pandascrow-Signature header is a 64-character lowercase hexadecimal string — nothing more.
- Algorithm:
HMAC-SHA256 - Message: the raw HTTP request body, byte-for-byte, before any JSON parsing
- Key: your application's secret key
sklive...orsktest...) - Encoding: hexadecimal, lowercase
- Prefixes: none. The value is the bare digest, not
sha256=..., notv1=..., not base64.
Do not re-serialize the JSON before hashing. If you parse the body into an object and then re-encode it, key order, whitespace, and Unicode escaping can change, and the digest will not match. Always HMAC the raw bytes as received.
Verification example
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_PANDASCROW_SIGNATURE'] ?? '';
$secret = getenv('PANDASCROW_SECRET_KEY');
$expected = hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
echo json_encode(['status' => false, 'error' => 'invalid signature']);
exit;
}
$event = json_decode($rawBody, true);
// ... process $event ...
http_response_code(200);
echo json_encode(['status' => true]);const crypto = require('crypto');
const express = require('express');
const app = express();
// Capture the raw body — do NOT use express.json() for the webhook route
app.post(
'/webhooks/pandascrow',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.get('X-Pandascrow-Signature');
const secret = process.env.PANDASCROW_SECRET_KEY;
const rawBody = req.body; // Buffer
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// Constant-time comparison
const a = Buffer.from(signature || '', 'hex');
const b = Buffer.from(expected, 'hex');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).json({ status: false, error: 'invalid signature' });
}
const event = JSON.parse(rawBody.toString('utf8'));
handleEvent(event); // your business logic
res.status(200).json({ status: true });
}
);import hmac, hashlib, json, os
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/webhooks/pandascrow', methods=['POST'])
def webhook():
raw_body = request.get_data() # bytes
signature = request.headers.get('X-Pandascrow-Signature', '')
secret = os.environ['PANDASCROW_SECRET_KEY'].encode()
expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
return jsonify(status=False, error='invalid signature'), 401
event = json.loads(raw_body)
handle_event(event)
return jsonify(status=True), 200Timestamp freshness (recommended)
Reject events whose X-Pandascrow-Timestamp is more than 5 minutes off from your server's clock. This protects against replay attacks where an attacker captures a valid signed request and re-sends it later.
$age = abs(time() - (int) $_SERVER['HTTP_X_PANDASCROW_TIMESTAMP']);
if ($age > 300) {
http_response_code(401);
exit;
}IP whitelisting (optional, defense in depth)
Signature verification is the primary defense. If your infrastructure supports it, you can additionally restrict inbound traffic to Pandascrow's egress ranges. Update from your Dashboard or Request our current ranges from [email protected] — they may change without notice, so treat this as a secondary control, never a replacement for signature verification.
Supported events
{
"event": "escrow.paid",
"data": {
"escrow_id": 2,
"escrow_type": "onetime",
"escrow_data": {
"_id": 2,
"escrow_type": "onetime",
"initiator_role": "seller",
"title": "Purchase #123456729 from Store ABC",
"description": "Purchase 20 Ton of metal",
"currency": "NGN",
"amount": "350.00",
"amount_pay": "350.00",
"amount_receive": "341.25",
"inspection_period": 1,
"no_reviews": 1,
"delivery_date": "2025-11-06",
"how_dispute_is_handled": "platform",
"who_pay_fees": "seller",
"dispute_window": 5,
"initiator_id": "a49882d5-041c-49f7-9bee-83ef86aa9b90",
"receiver_id": "291a18b4-f074-42f5-998d-7f2e2a18d96f",
"broker_id": null,
"prd_url": "",
"acceptance_criteria": "Hello world, this is an acceptance criteria xoxo",
"deposit_option": "full",
"callback_url": "app.wiserlance.com\/success",
"partner_escrow_fee": "0.00",
"status": "funded",
"created_at": "2026-02-16 15:52:24",
"updated_at": "2026-02-16 16:05:38"
},
"transaction": {
"_id": 2,
"escrow_id": 2,
"user_id": "a49882d5-041c-49f7-9bee-83ef86aa9b90",
"milestone_id": null,
"type": "fund",
"amount": "350.00",
"amount_pay": "350.00",
"amount_receive": "341.25",
"currency": "NGN",
"status": "success",
"payment_url": "https:\/\/checkout.paystack.com\/w05phx8ia6y3eaj",
"provider": "paystack",
"transaction_ref": "fjjqemac4g",
"domain": "test",
"channel": "card",
"ip_address": "127.0.0.1",
"authorization_code": "AUTH_1e3rnb81by",
"bin": "408408",
"last4": "4081",
"exp_month": "12",
"exp_year": "2030",
"card_type": "visa ",
"bank": "TEST BANK",
"country_code": "NG",
"signature": "SIG_0jfBFo9zdvDO8gEFOX9w",
"cust_name": "Pandascrow Buyer",
"cust_email": "[email protected]",
"created_at": "2026-02-16 15:52:30",
"updated_at": "2026-02-16 16:07:23"
},
"buyer": {
"_id": 4,
"escrow_id": 2,
"user_id": "291a18b4-f074-42f5-998d-7f2e2a18d96f",
"name": "Pandascrow Buyer",
"email": "[email protected]",
"phone": "+2348098765432",
"role": "receiver",
"joined_at": "2026-02-16 15:52:24"
},
"seller": {
"_id": 3,
"escrow_id": 2,
"user_id": "a49882d5-041c-49f7-9bee-83ef86aa9b90",
"name": "Precious Tom",
"email": "[email protected]",
"phone": null,
"role": "initiator",
"joined_at": "2026-02-16 15:52:24"
},
"gateway_data": {
"event": "charge.success",
"data": {
"id": 5844280067,
"domain": "test",
"status": "success",
"reference": "fjjqemac4g",
"amount": 35000,
"message": null,
"gateway_response": "Successful",
"paid_at": "2026-02-16T15:53:00.000Z",
"created_at": "2026-02-16T15:52:30.000Z",
"channel": "card",
"currency": "NGN",
"ip_address": "105.116.9.111",
"metadata": {
"escrow_id": "2",
"user_id": "a49882d5-041c-49f7-9bee-83ef86aa9b90",
"provider": "paystack"
},
"fees_breakdown": null,
"log": null,
"fees": 525,
"fees_split": null,
"authorization": {
"authorization_code": "AUTH_1e3rnb81by",
"bin": "408408",
"last4": "4081",
"exp_month": "12",
"exp_year": "2030",
"channel": "card",
"card_type": "visa ",
"bank": "TEST BANK",
"country_code": "NG",
"brand": "visa",
"reusable": true,
"signature": "SIG_0jfBFo9zdvDO8gEFOX9w",
"account_name": null,
"receiver_bank_account_number": null,
"receiver_bank": null
},
"customer": {
"id": 317040401,
"first_name": null,
"last_name": null,
"email": "[email protected]",
"customer_code": "CUS_730cys763yiibdn",
"phone": null,
"metadata": null,
"risk_action": "default",
"international_format_phone": null
},
"plan": [],
"subaccount": [],
"split": [],
"order_id": null,
"paidAt": "2026-02-16T15:53:00.000Z",
"requested_amount": 35000,
"pos_transaction_data": null,
"source": {
"type": "api",
"source": "merchant_api",
"entry_point": "transaction_initialize",
"identifier": null
}
}
},
"recurring_data": null
},
"timestamp": 1771258051
}{
"event": "escrow.completed",
"data": {
"event": "escrow.completed",
"escrow_id": 1,
"escrow_data": {
"_id": 1,
"title": "Payment for Buyer Offer- Aluminium Beverage Cans",
"description": "Secure payment for Buyer Offer- Aluminium Beverage Cans in Product by Lotanna",
"currency": "NGN",
"amount": "2000.00",
"amount_pay": "2130.00",
"amount_receive": "2000.00",
"status": "completed",
"escrow_type": "onetime",
"initiator_role": "broker",
"created_at": "2026-05-21 09:44:45",
"completed_at": "2026-05-21 11:17:49",
"broker_id": "aa5488cf-0c0b-4a77-af8a-afa5beacb2b0"
},
"transaction": {
"transaction_ref": "lp2huy4f01",
"amount": "2000.00",
"currency": "NGN",
"provider": "paystack",
"status": "success",
"completed_at": "2026-05-21 11:17:44"
},
"participants": {
"buyer": {
"user_id": "6811a04b-4f9c-491c-b89a-1c5c9f94672c",
"name": "Micah Tom",
"email": "[email protected]"
},
"seller": {
"user_id": "2a692c94-cc6a-4fdd-873d-bbeed77ee274",
"name": "Kendrick Lamar",
"email": "[email protected]"
},
"broker": {
"user_id": "aa5488cf-0c0b-4a77-af8a-afa5beacb2b0",
"name": "Precious Tom",
"email": "[email protected]"
}
},
"completed_by": {
"user_id": null,
"name": null,
"email": null,
"role": null
},
"is_broker_escrow": true
},
"timestamp": 1779362269
}{
"event": "invoice.paid",
"data": {
"event": "invoice.paid",
"invoice_id": 3,
"transaction_id": "pa60xufb0b",
"invoice_data": {
"_id": 3,
"invoice_number": "INV-6A0DBECB16A59",
"uuid": "aa5488cf-0c0b-4a77-af8a-afa5beacb2b0",
"client_id": 1,
"currency": "NGN",
"subtotal": "6200.00",
"tax": "0.00",
"total": "6200.00",
"status": "paid",
"delivery_method": "email",
"payment_method": "bank_trasfer",
"notes": "Thank you for your business.",
"due_date": "2025-07-01",
"sent_at": "2026-05-20 14:01:47",
"created_at": "2026-05-20 14:01:47",
"updated_at": "2026-05-20 14:02:37"
},
"wallet_update": {
"_id": 1,
"uuid": "aa5488cf-0c0b-4a77-af8a-afa5beacb2b0",
"currency": "NGN",
"balance": "12400.00",
"tier": 1,
"metadata": null,
"created_at": "2026-05-20 10:13:18",
"updated_at": "2026-05-20 14:02:37"
},
"payer": {
"_id": 1,
"owner_uuid": "aa5488cf-0c0b-4a77-af8a-afa5beacb2b0",
"full_name": "Pandascrow HQ",
"email": "[email protected]",
"billing_address": "15 New Haven Crescent, NTA, Port Harcourt",
"phone": "08021325996",
"company_name": "Pandascrow",
"currency": "NGN",
"created_at": "2026-05-20 11:44:38",
"updated_at": "2026-05-20 13:56:18"
},
"webhook_raw": {
"event": "charge.success",
"data": {
"id": 6167317014,
"domain": "test",
"status": "success",
"reference": "pa60xufb0b",
"amount": 623100,
"message": null,
"gateway_response": "Successful",
"paid_at": "2026-05-20T14:02:03.000Z",
"created_at": "2026-05-20T14:01:48.000Z",
"channel": "card",
"currency": "NGN",
"ip_address": "105.113.40.117",
"metadata": 0,
"fees_breakdown": null,
"log": null,
"fees": 19347,
"fees_split": null,
"authorization": {
"authorization_code": "AUTH_w0a5qgqybp",
"bin": "408408",
"last4": "4081",
"exp_month": "12",
"exp_year": "2030",
"channel": "card",
"card_type": "visa ",
"bank": "TEST BANK",
"country_code": "NG",
"brand": "visa",
"reusable": true,
"signature": "SIG_0jfBFo9zdvDO8gEFOX9w",
"account_name": null,
"receiver_bank_account_number": null,
"receiver_bank": null
},
"customer": {
"id": 161136929,
"first_name": null,
"last_name": null,
"email": "[email protected]",
"customer_code": "CUS_84p6sd2spqx02tg",
"phone": null,
"metadata": null,
"risk_action": "default",
"international_format_phone": null
},
"plan": [],
"subaccount": [],
"split": [],
"order_id": null,
"paidAt": "2026-05-20T14:02:03.000Z",
"requested_amount": 623100,
"pos_transaction_data": null,
"source": {
"type": "api",
"source": "merchant_api",
"entry_point": "transaction_initialize",
"identifier": null
}
}
},
"paid_at": "2026-05-20 14:02:39"
},
"timestamp": 1779285759
}{
"event": "wallet.deposit.success",
"uuid": "merchant-uuid-here",
"amount": 25000,
"currency": "NGN",
"transaction_ref": "DVA_20260606222716_959",
"nipsessionid": "999169231016134100460496227401",
"sender_name": "John Doe",
"account_number": "5030000013",
"memo": "DVA Payment from John Doe to 5030000013",
"dva": {
"record_uuid": "dva-record-uuid",
"reference": "DVA_20260606222716_959",
"account_name": "Acme Store",
"amount_type": "EXACT",
"expected_amount": 25000,
"total_funded_amount": 25000,
"funding_count": 1,
"expiry_date": "2026-06-07 11:30:00"
}
}{
"event": "escrow.created",
"data": {
"escrow_id": 2,
"escrow_type": "onetime",
"escrow_data": {
"_id": 2,
"title": "Booking PANDA-815L4UL3",
"description": "Payment for booking PANDA-815L4UL3",
"currency": "NGN",
"amount": 20000,
"initiator_role": "broker",
"initiator_id": "d843806a-433e-43e1-80b0-4e62d0058bc2",
"broker_id": "d843806a-433e-43e1-80b0-4e62d0058bc2",
"deposit_option": "full",
"status": "pending"
},
"transaction": {
"transaction_ref": "chk_HSMoxwaQqob664xb",
"provider": "bach"
},
"payment": {
"method": "payment_link",
"payment_url": "https://sandbox-checkout.bachs.io/c/yHiSbFPxqpMtHz4",
"virtual_account": null,
"reference": "chk_HSMoxwaQqob664xb",
"provider": "bach"
},
"participants": {
"buyer": {
"name": "Pandascrow HQ",
"email": "[email protected]",
"phone": "08021325996"
},
"seller": {
"name": "Circlepanda HQ",
"email": "[email protected]",
"phone": "08179685649"
},
"broker": {
"user_id": "d843806a-433e-43e1-80b0-4e62d0058bc2",
"name": "Precious Tom",
"email": "[email protected]"
}
},
"created_by": {
"user_id": "d843806a-433e-43e1-80b0-4e62d0058bc2",
"name": "Precious Tom",
"email": "[email protected]"
},
"is_broker_escrow": true,
"timestamp": "2026-09-21 05:16:10"
},
"timestamp": 1789967770
}{
"event": "escrow.dispute.opened",
"data": {
"escrow_id": 1,
"escrow_data": {
"_id": 1,
"title": "Booking PANDA-815L4UL3",
"description": "Payment for booking PANDA-815L4UL3",
"currency": "NGN",
"amount": "20000.00",
"amount_pay": "21400.00",
"amount_receive": "20000.00",
"status": "disputed",
"escrow_type": "onetime",
"initiator_role": "broker",
"initiator_id": "d843806a-433e-43e1-80b0-4e62d0058bc2",
"created_at": "2026-09-21 04:56:14",
"disputed_at": "2026-09-25 16:44:02",
"broker_id": "d843806a-433e-43e1-80b0-4e62d0058bc2"
},
"dispute": {
"reason": "Project wasn't delivered",
"screenshot_url": "::1",
"raised_at": "2026-09-25 16:44:02",
"raised_by": {
"user_id": "d843806a-433e-43e1-80b0-4e62d0058bc2",
"name": "Precious Tom",
"email": "[email protected]",
"role": "broker"
}
},
"transaction": {
"transaction_ref": "chk_ks23bhvl8CLRYF4b",
"amount": "20000.00",
"currency": "NGN",
"provider": "bach",
"status": "pending",
"updated_at": "2026-09-25 16:44:02"
},
"participants": {
"buyer": {
"user_id": "8ec773b6-1118-4fe9-bf45-4cd99cac290e",
"name": "Pandascrow HQ",
"email": "[email protected]"
},
"seller": {
"user_id": "96a29702-b058-4d2b-8cf7-bde7c747c9a5",
"name": "Circlepanda HQ",
"email": "[email protected]"
},
"broker": {
"user_id": "d843806a-433e-43e1-80b0-4e62d0058bc2",
"name": "Precious Tom",
"email": "[email protected]"
}
},
"is_broker_escrow": true,
"timestamp": "2026-09-25 16:44:02"
},
"timestamp": 1790354642
}{
"event": "escrow.payout.completed",
"data": {
"escrow_id": 1,
"escrow_data": {
"_id": 1,
"title": "Booking PANDA-815L4UL3",
"currency": "NGN",
"amount": "20000.00",
"amount_pay": "21600.00",
"amount_receive": "20000.00",
"escrow_type": "onetime",
"initiator_role": "broker",
"broker_id": "76befcb5-bfb8-44c2-b2c7-1b2ce9bf57a0"
},
"payout": {
"reference": "chk_8mtMH3U3NJyfQHiU",
"amount": 20000,
"currency": "NGN",
"payout_type": "bank",
"recipient_uuid": "48da156e-7235-43f3-9aec-a989e314129d",
"bank_attempted": true,
"bank_succeeded": false,
"wallet_used": true,
"wallet_succeeded": true,
"wallet_target": "76befcb5-bfb8-44c2-b2c7-1b2ce9bf57a0",
"wallet_role": "PARTNER",
"requires_manual": false,
"provider_reference": null,
"failure_reason": "Payout failed: Bank account verification failed: Unknown Bank Code",
"status": "completed_via_wallet"
},
"is_broker_escrow": true,
"timestamp": "2026-10-04 17:37:59"
},
"timestamp": 1791135479
}🧪 Testing webhooks
You don't need to wait for a live event to test your endpoint:
- Go to Developers → Webhooks in the dashboard.
- Click Send test event and choose any event type.
- We deliver a synthetic event with a real signature, signed with the same secret as your live events.
Test events use test timestamps and do not affect escrow state.
Best practices
- Always verify the signature. Never trust an unauthenticated webhook.
- Always HMAC the raw body. Not the parsed-and-re-encoded JSON.
- Use constant-time comparison
hash_equals,crypto.timingSafeEqual,hmac.compare_digest) — never==or===. - Check the timestamp and reject events older than 5 minutes.
- Process events idempotently. Use the
payout.referenceortransaction_refas your idempotency key. - Respond quickly. Acknowledge within 5 seconds. If your processing takes longer, queue the event and process it asynchronously — the acknowledgment is what matters.
- Log the raw body and headers for every delivery. When something goes wrong, the raw material is what you'll need to diagnose it.
- Never echo your secret key. It is only used locally to verify incoming signatures.
Need help?
If a signature doesn't verify, before contacting support check that:
- You're hashing the raw body, not a re-serialized version.
- You're using the secret key from the same environment (live vs test) that sent the event.
- The signature string is exactly 64 lowercase hex characters. If you see a different length, something upstream is corrupting the value — quote characters, whitespace, or a prefix.
If all three check out and it still fails, write to [email protected] with the X-Pandascrow-Event, X-Pandascrow-Timestamp, and the first 8 and last 8 characters of both the sent signature and the signature your code computed. Do not send your secret key.