Skip to content

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:

json
{
    "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:

FieldTypeNotes
eventstringOne of the events listed below
escrow_idUUIDThe escrow this event is about
refstringYour ref from when you signed the link
amountintegerIDR, no decimals
statusstringThe escrow's current state after this event
timestampISO 8601When the event occurred (server time, UTC)
dataobjectEvent-specific extra fields
signaturehex stringHMAC-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.

js
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
<?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');
}
python
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'])
Constant-time comparison matters

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.

json
{
    "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.

json
{
    "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.

json
{
    "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.)

json
{
    "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.

json
{
    "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.

json
{
    "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:

AttemptDelay
1immediate
21 second
35 seconds
425 seconds
52 minutes
610 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.

js
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.

js
const eventKey = `${body.escrow_id}:${body.event}:${body.timestamp}`;
const existed = await redis.set(eventKey, '1', 'NX', 'EX', 86400);
if (!existed) return; // already processed

Use 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.

js
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:

bash
ngrok http 3000
# https://abc123.ngrok-free.app → forwards to localhost:3000

Then configure https://abc123.ngrok-free.app/webhooks/rekberpay as your sandbox webhook URL.

Common pitfalls

SymptomLikely cause
Signature always failsYou're verifying against the parsed body, not the raw bytes (less common in JSON), or the wrong secret
Webhooks arrive twiceAt-least-once delivery — make your handler idempotent
Webhooks don't arriveURL not registered, returning non-2xx, or your firewall blocking us. Check the partner dashboard's webhook log
Random 401sYour reverse proxy is stripping the request body before signature verification
Out-of-order eventsPossible — always trust timestamp and the status field over the order of arrival

Next steps

Released under the proprietary RekberPay license. Built for Indonesian merchants.