Skip to content

Webhooks

When you submit a slip for async verification, the result is delivered to your server via a webhook — an HTTP POST to a URL you control. This page covers the payload, how to verify the signature, and delivery/retry behavior.

Configuring the callback URL

The destination URL is resolved in this order:

  1. The callbackUrl field in the async request body, if provided.
  2. Otherwise, the branch's configured webhook URL.

If neither is set, the async request is rejected with VALIDATION_ERROR. The URL must use HTTPS and resolve to a public address (URLs pointing at private/internal IP ranges are rejected).

Each branch may also have a webhook secret. When a secret is configured, every webhook is signed (see Verifying the signature).

Payload

The webhook body is JSON:

json
{
  "jobId": "550e8400-e29b-41d4-a716-446655440000",
  "batchId": null,
  "status": "success",
  "data": {
    "isDuplicate": false,
    "rawSlip": {
      "transRef": "68370160657749I376388B35",
      "amount": { "amount": 1500.00, "local": { "amount": 1500.00, "currency": "THB" } },
      "sender": { "bank": { "id": "004", "short": "KBANK" } },
      "receiver": { "bank": { "id": "014", "short": "SCB" } }
    }
  },
  "timestamp": "2024-01-15T14:30:00.000Z"
}
FieldTypeDescription
jobIdstringThe job this result belongs to
batchIdstring | nullThe batch this job was part of, or null for a single async submit
statusstringsuccess (slip verified) or not_found (slip could not be verified)
dataobjectThe verification result — same shape as the sync verify data. For not_found, carries the error detail.
timestampstringISO-8601 time the webhook was generated

Verifying the signature

When a webhook secret is configured for the branch, the request includes:

http
X-EasySlip-Signature: sha256=<hex>

<hex> is the HMAC-SHA256 of the raw request body (the exact bytes received — do not re-serialize), keyed with your webhook secret, hex-encoded. Recompute it and compare using a constant-time comparison. Reject the request if the signatures differ.

javascript
import crypto from 'crypto';

// Express example — capture the RAW body: express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } })
function verifyWebhook(rawBody, signatureHeader, secret) {
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(signatureHeader || '', 'utf8');
  const b = Buffer.from(expected, 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post('/webhooks/easyslip', (req, res) => {
  if (!verifyWebhook(req.rawBody, req.get('X-EasySlip-Signature'), process.env.EASYSLIP_WEBHOOK_SECRET)) {
    return res.status(401).send('invalid signature');
  }
  const event = JSON.parse(req.rawBody.toString('utf8'));
  // ... handle event.jobId / event.status / event.data
  res.sendStatus(200);
});
typescript
import crypto from 'crypto';

function verifyWebhook(rawBody: Buffer, signatureHeader: string | undefined, secret: string): boolean {
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(signatureHeader ?? '', 'utf8');
  const b = Buffer.from(expected, 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
php
function verifyWebhook(string $rawBody, ?string $signatureHeader, string $secret): bool
{
    $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
    return is_string($signatureHeader) && hash_equals($expected, $signatureHeader);
}

// Usage
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_EASYSLIP_SIGNATURE'] ?? null;
if (!verifyWebhook($rawBody, $signature, getenv('EASYSLIP_WEBHOOK_SECRET'))) {
    http_response_code(401);
    exit('invalid signature');
}
$event = json_decode($rawBody, true);
python
import hmac
import hashlib

def verify_webhook(raw_body: bytes, signature_header: str | None, secret: str) -> bool:
    expected = 'sha256=' + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return signature_header is not None and hmac.compare_digest(expected, signature_header)

# Flask example
@app.post('/webhooks/easyslip')
def easyslip_webhook():
    raw = request.get_data()  # raw bytes
    if not verify_webhook(raw, request.headers.get('X-EasySlip-Signature'), os.environ['EASYSLIP_WEBHOOK_SECRET']):
        return 'invalid signature', 401
    event = request.get_json()
    # ... handle event
    return '', 200

Use the raw body

The signature is computed over the exact bytes sent. If you verify against a re-serialized JSON object (reordered keys, changed whitespace) the signature won't match. Always sign the raw request body.

Delivery & retries

  • A webhook delivery succeeds on any 2xx response. Respond quickly (do heavy work asynchronously) and return 2xx as soon as you've accepted the event.
  • On a non-2xx, timeout, or network error, delivery is retried up to 3 times with backoff at roughly 10s, 30s, and 2 minutes (1 initial attempt + 3 retries).
  • Redirects are not followed — point callbackUrl at the final endpoint.
  • jobId is stable across retries, so deliveries are idempotent — dedupe on jobId and treat repeats as at-least-once delivery.
  • If every attempt fails, the result is still available via GET /verify/bank/jobs/:jobId until the job expires (7 days).

Notes

  • Always verify the signature before trusting a webhook (when a secret is configured).
  • Return 2xx fast; a slow handler risks a timeout and an unnecessary retry.
  • Use jobId (and batchId for batches) to correlate the result with your original request.

Bank Slip Verification API for Thai Banking