Handling Webhooks
RekberPay tells you about escrow state changes by POSTing JSON to a URL you configure. Polling the API works but is slow, expensive, and gets you rate-limited. Webhooks are the right answer for production integrations.
Configuring your webhook URL
Email [email protected] with:
- Your partner ID
- Your webhook URL (must be HTTPS in production)
- The events you want (default: all events)
You'll receive a WEBHOOK_SECRET for signature verification. Keep it server-side only.
The payload
Every webhook is a POST with Content-Type: application/json and this body shape:
{
"event": "payment.completed",
"escrow_id": "7f8a9b0c-1d2e-3f4a-5b6c-7d8e9f0a1b2c",
"ref": "MARKETPLACE_98765",
"amount": 22500000,
"status": "paid",
"timestamp": "2026-05-18T09:27:00.000Z",
"data": {
"payment_method": "qris",
"paid_at": "2026-05-18T09:26:55.000Z"
},
"signature": "a1b2c3..."
}Common fields:
| Field | Type | Notes |
|---|---|---|
event | string | One of the events listed below |
escrow_id | UUID | The escrow this event is about |
ref | string | Your ref from when you signed the link |
amount | integer | IDR, no decimals |
status | string | The escrow's current state after this event |
timestamp | ISO 8601 | When the event occurred (server time, UTC) |
data | object | Event-specific extra fields |
signature | hex string | HMAC-SHA256 of the canonical event string |
Verify the signature
Before trusting the payload, verify it actually came from RekberPay.
The canonical string is:
event + escrow_id + amount + status + timestamp(concatenated with no separators). HMAC-SHA256 it with your WEBHOOK_SECRET and compare to the signature field in constant time.
import crypto from 'node:crypto';
function verifyWebhook(body, secret) {
const canonical = `${body.event}${body.escrow_id}${body.amount}${body.status}${body.timestamp}`;
const expected = crypto
.createHmac('sha256', secret)
.update(canonical)
.digest('hex');
try {
return crypto.timingSafeEqual(
Buffer.from(body.signature, 'hex'),
Buffer.from(expected, 'hex'),
);
} catch {
return false;
}
}
// Express handler
app.post('/webhooks/rekberpay', express.json(), (req, res) => {
if (!verifyWebhook(req.body, process.env.WEBHOOK_SECRET)) {
return res.status(401).send('invalid signature');
}
// process async, return 200 fast
enqueue(req.body);
res.status(200).send('ok');
});<?php
function verify_webhook(array $body, string $secret): bool {
$canonical = $body['event'] . $body['escrow_id'] . $body['amount']
. $body['status'] . $body['timestamp'];
$expected = hash_hmac('sha256', $canonical, $secret);
return hash_equals($expected, $body['signature']);
}
// In your webhook handler
$body = json_decode(file_get_contents('php://input'), true);
if (!verify_webhook($body, getenv('WEBHOOK_SECRET'))) {
http_response_code(401);
exit('invalid signature');
}import hmac, hashlib
def verify_webhook(body: dict, secret: str) -> bool:
canonical = f"{body['event']}{body['escrow_id']}{body['amount']}{body['status']}{body['timestamp']}"
expected = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, body['signature'])Don't use == to compare signatures. Use crypto.timingSafeEqual (Node), hash_equals (PHP), or hmac.compare_digest (Python). Naive comparison leaks timing information that an attacker can use to forge signatures byte by byte.
Events
escrow.created
Fired immediately after a partner-signed escrow is created. State: pending_payment.
{
"event": "escrow.created",
"escrow_id": "7f8a...",
"ref": "MARKETPLACE_98765",
"amount": 22500000,
"status": "pending_payment",
"timestamp": "2026-05-18T08:00:00.000Z",
"data": {
"expires_at": "2026-05-25T08:00:00.000Z",
"escrow_url": "https://rekberpay.com/escrow/7f8a.../pay"
}
}payment.completed
Buyer paid, funds are in escrow. State: paid.
{
"event": "payment.completed",
"escrow_id": "7f8a...",
"ref": "MARKETPLACE_98765",
"amount": 22500000,
"status": "paid",
"timestamp": "2026-05-18T09:27:00.000Z",
"data": {
"payment_method": "qris",
"paid_at": "2026-05-18T09:26:55.000Z",
"fee": 5000
}
}escrow.sent
Seller marked the item shipped/delivered. State: sent.
{
"event": "escrow.sent",
"escrow_id": "7f8a...",
"ref": "MARKETPLACE_98765",
"amount": 22500000,
"status": "sent",
"timestamp": "2026-05-18T11:00:00.000Z",
"data": {
"tracking_number": "JNE-123456789",
"courier": "JNE"
}
}escrow.received
Buyer confirmed receipt. State: received.
escrow.released
Funds released to the seller's RekberPay balance. State: released. (Terminal.)
{
"event": "escrow.released",
"escrow_id": "7f8a...",
"ref": "MARKETPLACE_98765",
"amount": 22500000,
"status": "released",
"timestamp": "2026-05-18T12:00:00.000Z",
"data": {
"released_by": "buyer",
"release_method": "manual",
"seller_payout": 22495000
}
}escrow.refunded
Funds refunded to the buyer. State: refunded. (Terminal.)
escrow.disputed
Buyer or seller opened a dispute. State: disputed.
{
"event": "escrow.disputed",
"escrow_id": "7f8a...",
"ref": "MARKETPLACE_98765",
"amount": 22500000,
"status": "disputed",
"timestamp": "2026-05-18T11:30:00.000Z",
"data": {
"opened_by": "buyer",
"reason": "not_as_described",
"description": "Item is not the correct color"
}
}dispute.resolved
Admin resolved a dispute.
{
"event": "dispute.resolved",
"escrow_id": "7f8a...",
"ref": "MARKETPLACE_98765",
"amount": 22500000,
"status": "released",
"timestamp": "2026-05-19T15:00:00.000Z",
"data": {
"outcome": "released",
"resolution_note": "Tracking confirms delivery; buyer evidence insufficient"
}
}outcome is one of released, refunded, hold.
Retry behavior
If your endpoint returns anything other than 2xx, RekberPay retries with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | immediate |
| 2 | 1 second |
| 3 | 5 seconds |
| 4 | 25 seconds |
| 5 | 2 minutes |
| 6 | 10 minutes |
| 7 (final) | 1 hour |
After 7 failed attempts, the webhook is marked failed and we stop trying. You can replay failed webhooks from the partner dashboard (or via API once that ships).
Best practices
Return 200 immediately
Don't process the webhook synchronously. Stick it on a queue and return 200 within 5 seconds. If your handler takes longer, we'll retry, and you'll process the same event multiple times.
app.post('/webhooks/rekberpay', express.json(), async (req, res) => {
if (!verifyWebhook(req.body, SECRET)) {
return res.status(401).send('invalid signature');
}
// Enqueue to your job system (Bull, Sidekiq, SQS, etc.)
await queue.add('rekberpay-webhook', req.body);
res.status(200).send('ok');
});Be idempotent
Process the same escrow_id + event only once. We deliver at-least-once, not exactly-once.
const eventKey = `${body.escrow_id}:${body.event}:${body.timestamp}`;
const existed = await redis.set(eventKey, '1', 'NX', 'EX', 86400);
if (!existed) return; // already processedUse a separate verification path for testing
In test environments, allow webhooks without signature verification (gated by a separate env flag). Don't rely on the real WEBHOOK_SECRET in unit tests.
function verifyWebhook(body, secret) {
if (process.env.SKIP_WEBHOOK_VERIFY === 'true') return true;
// ... real verification
}Log everything
Webhooks are how you catch state-machine bugs. Log:
- Raw payload (after signature verification)
- Processing duration
- Result (processed / skipped / errored)
- Idempotency hit/miss
You'll thank yourself in 6 months when a partner asks "why didn't event X trigger Y?".
Local development
Use ngrok or Cloudflare Tunnel to expose your local webhook handler:
ngrok http 3000
# https://abc123.ngrok-free.app → forwards to localhost:3000Then configure https://abc123.ngrok-free.app/webhooks/rekberpay as your sandbox webhook URL.
Common pitfalls
| Symptom | Likely cause |
|---|---|
| Signature always fails | You're verifying against the parsed body, not the raw bytes (less common in JSON), or the wrong secret |
| Webhooks arrive twice | At-least-once delivery — make your handler idempotent |
| Webhooks don't arrive | URL not registered, returning non-2xx, or your firewall blocking us. Check the partner dashboard's webhook log |
| Random 401s | Your reverse proxy is stripping the request body before signature verification |
| Out-of-order events | Possible — always trust timestamp and the status field over the order of arrival |
Next steps
- Idempotency — make your outgoing requests safe too
- Reference: Webhooks — full field reference for every event
- Testing — how to test webhook handling end-to-end