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, not_found, or failed
dataobject | nullSync-shaped success data, context on not_found, or null on failed
errorobjectOnly on failed: safe code and message
timestampstringISO-8601 time the webhook was generated

Failed verification ​

json
{
  "jobId": "550e8400-e29b-41d4-a716-446655440000",
  "batchId": null,
  "status": "failed",
  "data": null,
  "error": {
    "code": "API_SERVER_ERROR",
    "message": "External API service is temporarily unavailable"
  },
  "timestamp": "2026-09-11T00:00:00.000Z"
}

An accepted job that cannot complete verification sends failed, including policy restrictions, QUOTA_EXCEEDED, VALIDATION_ERROR and API_SERVER_ERROR. Unexpected errors use INTERNAL_SERVER_ERROR without raw exception details. Errors rejecting the request before acceptance do not create callbacks.

Failed callbacks use the same signature and delivery retries. Successful delivery leaves the polled job failed. If delivery also exhausts its retries, polling preserves error.code/message and adds error.webhook: "failed" plus delivery details.

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