Skip to content

Handling Webhooks ​

Not available yet

Outbound partner webhooks are not implemented. There is no webhook URL to configure — emailing for one will not get you deliveries, because nothing sends them. This guide is retained as the planned integration shape.

Poll instead: GET /api/escrows/:id/payment-status and GET /api/jasabayar/:id/payment-status. See Webhooks → What to use instead.

The advice below that polling is "slow, expensive, and gets you rate-limited" was written for a future in which webhooks exist. Today polling is the supported path, and a 5–10s cadence on an open checkout is well inside the rate limit.

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.