Skip to content

Webhook Callback

When an async verification job finishes, EasySlip sends the result to your callbackUrl as an HTTP POST. This page describes the callback payload, the signature you must verify, and the retry behaviour.

Async verification is available for bank slips only. On success, data matches the synchronous POST /verify/bank success response. On failed, data is null and error explains why verification could not complete.

Delivery

  • One result callback per slip. Each enqueued slip — including every slip inside a batch — has its own callback; delivery retries may repeat it.
  • Method: POST with Content-Type: application/json and User-Agent: EasySlip-Webhook/2.0.
  • Redirects are not followed. Delivery has a 5-second timeout by default; respond promptly.
  • Target: the callbackUrl from the request, or the branch's configured default webhook URL if none was provided.
  • Your endpoint should respond with any 2xx status. Non-2xx responses (or timeouts) trigger retries.

Request Body

json
{
  "jobId": "3f2b1c8a-9d4e-4f10-b7a2-6c5d4e3f2a1b",
  "batchId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "status": "success",
  "data": {
    "remark": "Order #1001",
    "isDuplicate": false,
    "amountInSlip": 1500.00,
    "isAmountMatched": true,
    "rawSlip": {
      "payload": "00000000000000000000000000000000000000",
      "transRef": "68370160657749I376388B35",
      "date": "2024-01-15T14:30:00+07:00",
      "countryCode": "TH",
      "amount": {
        "amount": 1500.00,
        "local": { "amount": 1500.00, "currency": "THB" }
      },
      "fee": 0,
      "ref1": "",
      "ref2": "",
      "ref3": "",
      "sender": {
        "bank": { "id": "004", "name": "กสิกรไทย", "short": "KBANK" },
        "account": {
          "name": { "th": "นาย ผู้โอน ทดสอบ", "en": "MR. SENDER TEST" },
          "bank": { "type": "BANKAC", "account": "123-4-xxxxx-5" }
        }
      },
      "receiver": {
        "bank": { "id": "014", "name": "ไทยพาณิชย์", "short": "SCB" },
        "account": {
          "name": { "th": "บริษัท ตัวอย่าง จำกัด" },
          "bank": { "type": "BANKAC", "account": "xxx-x-x5678-x" }
        },
        "merchantId": null
      }
    }
  },
  "timestamp": "2024-01-15T14:32:05+07:00"
}

Fields

FieldTypeDescription
jobIdstringThe job's UUID (matches the jobId you received when enqueuing)
batchIdstring | nullThe batch UUID if the slip was part of a batch; null otherwise
statusstringsuccess, not_found, or failed (see below)
dataobject | nullSync-shaped verification data on success; failure context on not_found; null on failed
errorobjectPresent only on failed: safe code and message
timestampstringISO 8601 time the result was produced

status values

statusMeaningdata
successThe slip was verifiedFull verification result
not_foundStill not found after verification retriesFailure context, no verified slip
failedVerification could not complete because of an error or policy restrictionnull; see error

Type definition

typescript
type WebhookPayload = {
  jobId: string;
  batchId: string | null;
  timestamp: string; // ISO 8601
} & (
  | { status: 'success'; data: VerifyBankData }
  | { status: 'not_found'; data: unknown }
  | { status: 'failed'; data: null; error: { code: string; message: string } }
);

Failed verification

json
{
  "jobId": "3f2b1c8a-9d4e-4f10-b7a2-6c5d4e3f2a1b",
  "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"
}

Examples include QUOTA_EXCEEDED, SERVICE_EXPIRED, BRANCH_INACTIVE, VALIDATION_ERROR, API_SERVER_ERROR and RENEWAL_TEMPORARILY_UNAVAILABLE. Unknown errors use INTERNAL_SERVER_ERROR without internal exception details. Billing outages send failed only after their separate retry budget is exhausted, never during an intermediate retry.

Successful delivery of a failure callback leaves the polled job failed, not done. If delivery retries are also exhausted, polling preserves the original error.code/message and adds error.webhook: "failed" and delivery details. HTTP errors rejecting a request before acceptance do not create callbacks.

Signature Verification

The signature header is included only when a webhook secret is configured for the branch. Without a secret, callbacks are unsigned. Configure a secret before accepting production callbacks, then reject missing or invalid signatures.

EASYSLIP_WEBHOOK_SECRET below is an environment variable in your receiver application holding that branch secret, not a new global setting for the EasySlip API or worker.

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

The signature is the HMAC-SHA256 of the raw JSON request body, keyed with your branch's webhook secret, hex-encoded.

To verify: compute the HMAC-SHA256 of the received raw body (the exact bytes, before any JSON parsing/re-serialization) using your secret, then compare it — using a constant-time comparison — against the hex value in the header.

Use the raw body

Compute the HMAC over the raw request body bytes, not a re-serialized object. Re-encoding JSON can change whitespace/key order and break the signature. Capture the raw body before parsing.

javascript
import express from 'express';
import crypto from 'crypto';

const WEBHOOK_SECRET = process.env.EASYSLIP_WEBHOOK_SECRET;
const app = express();

// Capture the RAW body for signature verification
app.post('/webhooks/easyslip',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const header = req.get('X-EasySlip-Signature') || '';
    const expected = 'sha256=' + crypto
      .createHmac('sha256', WEBHOOK_SECRET)
      .update(req.body)                 // req.body is a Buffer (raw bytes)
      .digest('hex');

    const ok =
      /^sha256=[0-9a-f]{64}$/.test(header) &&
      crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));

    if (!ok) return res.status(401).send('invalid signature');

    const event = JSON.parse(req.body.toString('utf8'));
    // ... handle event.jobId / event.status / event.data ...

    res.sendStatus(200);
  });
php
<?php
$secret = getenv('EASYSLIP_WEBHOOK_SECRET');
$raw    = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_EASYSLIP_SIGNATURE'] ?? '';

$expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);

if (!hash_equals($expected, $header)) {
    http_response_code(401);
    exit('invalid signature');
}

$event = json_decode($raw, true);
// ... handle $event['jobId'] / $event['status'] / $event['data'] ...

http_response_code(200);
python
import hmac, hashlib, os
from flask import Flask, request, abort

WEBHOOK_SECRET = os.environ["EASYSLIP_WEBHOOK_SECRET"].encode()
app = Flask(__name__)

@app.post("/webhooks/easyslip")
def easyslip_webhook():
    raw = request.get_data()                      # raw bytes
    header = request.headers.get("X-EasySlip-Signature", "")
    expected = "sha256=" + hmac.new(WEBHOOK_SECRET, raw, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, header):
        abort(401)

    event = request.get_json()
    # ... handle event["jobId"] / event["status"] / event["data"] ...

    return "", 200

Retries

Verification and callback delivery have separate retry budgets:

  • Verification: any bank's not_found and BBL pending share one initial attempt plus four retries after 30 / 60 / 120 / 240 seconds. Success finishes immediately; exhausted attempts produce not_found, not failed. Total scheduled delay is 7m30s, excluding processing, queue wait and other deferrals; it is not a hard deadline.

  • Callback delivery: success, not_found and failed all use one initial delivery plus three retries after 10 / 30 / 120 seconds. All non-2xx responses, including 4xx, and timeout/network failures use this budget.

  • Even if all webhook deliveries fail, the result is still retrievable via GET /verify/bank/jobs/:jobId for ~7 days.

  • Make your handler idempotent — a retry can deliver the same jobId more than once. De-duplicate on jobId.

  • Return 2xx quickly; do heavy processing asynchronously so you don't time out and trigger needless retries.

Best Practices

  1. Verify the signature on every request before trusting the body.
  2. Respond 2xx fast, then process out-of-band.
  3. De-duplicate on jobId — allow repeated delivery; delivery is not guaranteed after retries are exhausted.
  4. Reconcile via polling — if you don't receive a webhook within your expected window, call GET .../jobs/:jobId.
  5. Keep the secret secret — store your webhook secret securely; never expose it client-side.

Bank Slip Verification API for Thai Banking