Skip to content

Authentication

RekberPay uses HMAC-signed links for partner integrations. No bearer tokens, no OAuth flows, no credentials in the browser. You sign a request on your backend with a shared secret, and the API verifies the signature before accepting it.

Why HMAC?

Traditional API keys have a problem: if they leak, an attacker can impersonate you until you rotate the key. With HMAC signing:

  • No credentials in transit — the secret never leaves your server
  • Tamper-proof — changing any signed field invalidates the signature
  • Time-bound — every link has an expires timestamp; old links are rejected
  • Replay-safe — combined with ref (your unique order ID), duplicate requests are caught by idempotency

This is the same pattern Stripe uses for webhook signatures and AWS uses for API requests. It's battle-tested at scale.

How it works

1. You sign the request

On your backend, you build a canonical string from the request parameters, HMAC-SHA256 it with your LINK_SIGNING_SECRET, and hex-encode the result.

The canonical string is:

key1=encodeURIComponent(value1)&key2=encodeURIComponent(value2)&...

where the keys are sorted alphabetically and drawn from this allowlist:

  • buyer (optional) — UUID of the buyer in RekberPay, if known
  • category (optional) — goods, service, or digital
  • desc (optional) — longer item description
  • expires (required) — unix timestamp when the link expires
  • item (required) — item or service name
  • ref (required) — your unique order/transaction ID
  • seller (required) — UUID of the seller in RekberPay
  • v (required) — signature version, always 1

Example canonical string:

category=service&expires=1748448000&item=Jasa%20Logo%20Design&ref=ORDER_12345&seller=0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d&v=1

Then:

js
const signature = crypto
    .createHmac('sha256', LINK_SIGNING_SECRET)
    .update(canonical)
    .digest('hex');

2. You POST the signed payload

Include the signature in the sig field:

json
{
    "seller": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
    "item": "Jasa Logo Design",
    "category": "service",
    "amount": 250000,
    "expires": 1748448000,
    "ref": "ORDER_12345",
    "v": 1,
    "sig": "f3a9c1e8b7d6a5f4c3b2a1e0d9c8b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0"
}

3. The API verifies

RekberPay recomputes the signature from the received params (excluding sig itself) and compares it in constant time. If they don't match, the request is rejected with 403 INVALID_SIGNATURE.

The API also checks:

  • expires is in the future
  • ref hasn't been used before by this seller (idempotency)
  • seller exists and is active
  • amount is within allowed bounds (10,000 – 100,000,000 IDR)

Implementation

Node.js

js
import crypto from 'node:crypto';

const SECRET = process.env.LINK_SIGNING_SECRET;
const ALLOWED = ['buyer', 'category', 'desc', 'expires', 'item', 'ref', 'seller', 'v'];

function signLink(params) {
    const canonical = ALLOWED
        .filter((k) => params[k] !== undefined && params[k] !== '')
        .map((k) => `${k}=${encodeURIComponent(String(params[k]))}`)
        .join('&');
    return crypto.createHmac('sha256', SECRET).update(canonical).digest('hex');
}

// Usage
const params = {
    seller: '0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d',
    item: 'Jasa Logo Design',
    category: 'service',
    expires: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60,
    ref: `ORDER_${Date.now()}`,
    v: 1,
};
params.sig = signLink(params);

// POST to /api/escrows/from-link with params + amount

PHP

php
<?php
$SECRET = getenv('LINK_SIGNING_SECRET');
$ALLOWED = ['buyer', 'category', 'desc', 'expires', 'item', 'ref', 'seller', 'v'];

function sign_link(array $params, string $secret, array $allowed): string {
    $parts = [];
    foreach ($allowed as $k) {
        if (isset($params[$k]) && $params[$k] !== '') {
            $parts[] = $k . '=' . rawurlencode((string) $params[$k]);
        }
    }
    return hash_hmac('sha256', implode('&', $parts), $secret);
}

$params = [
    'seller'   => '0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d',
    'item'     => 'Jasa Logo Design',
    'category' => 'service',
    'expires'  => time() + 7 * 24 * 60 * 60,
    'ref'      => 'ORDER_' . time(),
    'v'        => 1,
];
$params['sig'] = sign_link($params, $SECRET, $ALLOWED);

Python

python
import hmac
import hashlib
import os
import time
from urllib.parse import quote

SECRET = os.environ['LINK_SIGNING_SECRET'].encode()
ALLOWED = ['buyer', 'category', 'desc', 'expires', 'item', 'ref', 'seller', 'v']

def sign_link(params: dict) -> str:
    parts = [
        f"{k}={quote(str(params[k]), safe='')}"
        for k in ALLOWED
        if params.get(k) not in (None, '')
    ]
    canonical = '&'.join(parts)
    return hmac.new(SECRET, canonical.encode(), hashlib.sha256).hexdigest()

params = {
    'seller': '0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d',
    'item': 'Jasa Logo Design',
    'category': 'service',
    'expires': int(time.time()) + 7 * 24 * 60 * 60,
    'ref': f'ORDER_{int(time.time())}',
    'v': 1,
}
params['sig'] = sign_link(params)

Field reference

Required fields

FieldTypeDescription
sellerUUIDThe seller's user ID in RekberPay. Contact support to get this.
itemstringItem or service name. Max 200 chars. Shown to the buyer on the payment page.
expiresintegerUnix timestamp (seconds since epoch). Links expire at this time. Max 30 days in the future.
refstringYour unique order/transaction ID. Used for idempotency — same ref = same escrow. Max 100 chars.
vintegerSignature version. Always 1.

Optional fields

FieldTypeDescription
buyerUUIDPre-fill the buyer if you already know their RekberPay user ID. If omitted, they can join via QR or email invite.
categorystringgoods, service, or digital. Affects fee calculation and auto-release SLA. Default: goods.
descstringLonger item description. Max 1000 chars. Shown on the escrow detail page.

Not signed: amount

amount is in the request body but not in the canonical string. This lets you reuse a signed link for the same item with different amounts (e.g., a configurable service tier). The API still validates amount server-side.

Expiry best practices

  • Default: 7 days from now
  • Max: 30 days
  • Min: 5 minutes (to allow for clock skew)

If a link expires before the buyer pays, they'll see a "Link expired" error. You can generate a fresh link with a new expires timestamp and the same ref — the API will return the existing escrow if it's still pending.

Security notes

Never expose the secret

LINK_SIGNING_SECRET is equivalent to an API key. Anyone with it can create escrows on your behalf. Keep it server-side only. Never commit it to git, never put it in your frontend bundle, never log it.

Use HTTPS in production

HMAC prevents tampering, but it doesn't encrypt the payload. Always use HTTPS so the request body (including amount and item) isn't readable in transit.

Constant-time comparison

The API uses crypto.timingSafeEqual to compare signatures. This prevents timing attacks where an attacker measures how long the comparison takes to guess the signature byte by byte. Your verification code should do the same if you're verifying webhooks.

Testing your implementation

Use the Testing guide to verify your signing logic against the sandbox API. The sandbox uses a different LINK_SIGNING_SECRET than production — request both from [email protected].

Next steps

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