> For the complete documentation index, see [llms.txt](https://docs.jgopay.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.jgopay.com/english/webhooks.md).

# Callbacks

JGoPay sends callback notifications to your `callback_url` when a payment or withdrawal reaches a terminal status.

## Delivery

* Callbacks are sent as `POST` requests to your `callback_url`
* If no `callback_url` is provided in the payment request, the merchant's default callback URL is used
* Delivery is attempted up to **3 times** with exponential backoff if your server doesn't respond with a 2xx status

### Retry Schedule

| Attempt | Delay       |
| ------- | ----------- |
| 1st     | Immediate   |
| 2nd     | \~1 second  |
| 3rd     | \~5 seconds |

## Payload

### Deposit Callback

```json
{
  "webhook_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
  "payment_id": "PAY_MYR12345",
  "reference_id": "ORDER20260325001",
  "amount": 100.00,
  "currency": "MYR",
  "status": "SUCCESS",
  "transaction_type": "DEPOSIT",
  "gateway_reference": "TXN_20260325_ABC123",
  "completed_at": "2026-03-25T12:15:30Z",
  "created_at": "2026-03-25T12:03:00Z",
  "timestamp": "2026-03-25T12:15:31.000Z",
  "signature": "a1b2c3d4e5f6g7h8..."
}
```

### Withdrawal Callback

```json
{
  "webhook_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "payment_id": "PAY_WD1A2B3C4D",
  "reference_id": "WD20260325001",
  "amount": 500.00,
  "currency": "MYR",
  "status": "SUCCESS",
  "transaction_type": "WITHDRAWAL",
  "gateway_reference": "GW_WD_789012",
  "bank_code": "MY_MBB",
  "account_number": "****7890",
  "completed_at": "2026-03-25T11:00:00Z",
  "created_at": "2026-03-25T10:30:00Z",
  "timestamp": "2026-03-25T11:00:01.000Z",
  "signature": "x9y8z7w6v5u4..."
}
```

### Payload Fields

| Field               | Type   | Description                                              |
| ------------------- | ------ | -------------------------------------------------------- |
| `webhook_id`        | string | Unique delivery identifier (UUID v4) for deduplication   |
| `payment_id`        | string | JGoPay payment identifier                                |
| `reference_id`      | string | Your original order/invoice ID                           |
| `amount`            | number | Payment amount                                           |
| `currency`          | string | Currency code (`MYR`)                                    |
| `status`            | string | Terminal status (see below)                              |
| `transaction_type`  | string | `DEPOSIT` or `WITHDRAWAL`                                |
| `gateway_reference` | string | Gateway's transaction reference (may be `null`)          |
| `bank_code`         | string | Bank code (withdrawals only)                             |
| `account_number`    | string | Masked account number (withdrawals only)                 |
| `completed_at`      | string | Completion timestamp, ISO 8601 (`null` if not completed) |
| `created_at`        | string | Payment creation timestamp, ISO 8601                     |
| `timestamp`         | string | Callback generation timestamp, ISO 8601 UTC              |
| `signature`         | string | HMAC-SHA256 signature for verification                   |

## HTTP Headers

Each callback request includes the following headers:

| Header                | Description                                               |
| --------------------- | --------------------------------------------------------- |
| `Content-Type`        | `application/json`                                        |
| `X-Webhook-Signature` | HMAC-SHA256 signature (same as `signature` field in body) |
| `X-Webhook-ID`        | Same as `webhook_id` in body — use for deduplication      |
| `X-Webhook-Timestamp` | Same as `timestamp` in body — use for replay protection   |
| `X-Payment-ID`        | Payment identifier                                        |

## Terminal Statuses

Callbacks are sent only for terminal statuses:

| Status      | Description               | Your Action             |
| ----------- | ------------------------- | ----------------------- |
| `SUCCESS`   | Payment completed         | Fulfill the order       |
| `FAILED`    | Payment was declined      | Show error, allow retry |
| `EXPIRED`   | Payment session timed out | Show expiry message     |
| `CANCELLED` | Payment was cancelled     | Allow retry             |
| `REFUNDED`  | Payment was refunded      | Process refund          |

## Acknowledging Callbacks

Return any **HTTP 2xx** status code (200-299) to acknowledge receipt. The response body is not validated.

```
HTTP/1.1 200 OK
```

If we don't receive a 2xx response, the callback will be retried according to the retry schedule above.

## Idempotency

Callbacks may be delivered more than once (network retries, edge cases). Your handler must be idempotent — processing the same callback twice should not fulfill an order twice.

There are two levels of deduplication:

* **Delivery dedup** — Use `webhook_id` to detect retried deliveries of the exact same webhook.
* **Business idempotency** — Use `payment_id` to ensure you never fulfill the same payment twice, even across separate callback events. This is the critical check.

### Example (Node.js)

```javascript
app.post('/webhook', async (req, res) => {
  const payload = req.body;

  // 1. Verify signature first (see Signature Verification below)
  if (!verifyWebhookSignature(payload, process.env.API_KEY)) {
    return res.status(401).send('Invalid signature');
  }

  // 2. Check if this payment was already processed
  const existing = await db.query(
    'SELECT id FROM processed_payments WHERE payment_id = $1',
    [payload.payment_id]
  );

  if (existing.rows.length > 0) {
    // Already processed — acknowledge but skip fulfillment
    return res.status(200).send('OK');
  }

  // 3. Fulfill the order
  await db.query('BEGIN');
  try {
    await db.query(
      'INSERT INTO processed_payments (payment_id, status, processed_at) VALUES ($1, $2, NOW())',
      [payload.payment_id, payload.status]
    );
    await fulfillOrder(payload.reference_id, payload.status);
    await db.query('COMMIT');
  } catch (err) {
    await db.query('ROLLBACK');
    return res.status(500).send('Processing failed');
  }

  return res.status(200).send('OK');
});
```

### Rules

* Always check `payment_id` before fulfilling — this prevents double-processing even if you receive multiple distinct callbacks for the same payment.
* Insert the `payment_id` record and fulfill within the same database transaction to avoid race conditions.
* `reference_id` is your order ID — use it for *request* dedup when creating payments, not for callback dedup.

## Signature Verification

Each callback includes a `signature` field for verifying authenticity. The signature is an HMAC-SHA256 hash computed using a signing key derived from your API key.

### How It Works

The signing key is derived from your **raw API key** (the one you saved when creating your merchant) using two steps:

1. **Hash** your raw API key with SHA-256
2. **Derive** the signing key from that hash using HKDF

```
Raw API Key (you saved this at merchant creation)
    ↓ SHA-256
API Key Hash
    ↓ HKDF(sha256, salt='', info='webhook-signature', length=32)
Signing Key (used for HMAC-SHA256)
```

| Parameter              | Value                            |
| ---------------------- | -------------------------------- |
| **Hash algorithm**     | SHA-256                          |
| **Input key material** | SHA-256 hash of your raw API key |
| **Salt**               | empty                            |
| **Info**               | `webhook-signature`              |
| **Output length**      | 32 bytes                         |

### Signature Input String

The following fields are concatenated with `|` as separator:

```
webhook_id|payment_id|reference_id|amount|currency|status|timestamp|completed_at
```

* `amount` is converted to string (e.g., `100`)
* `completed_at` uses empty string if null

### Verification Examples

All examples take your **raw API key** as input and compute the hash internally.

**Node.js:**

```javascript
const crypto = require('crypto');

function verifyWebhookSignature(payload, apiKey) {
  const dataString = [
    payload.webhook_id,
    payload.payment_id,
    payload.reference_id,
    payload.amount.toString(),
    payload.currency,
    payload.status,
    payload.timestamp,
    payload.completed_at || '',
  ].join('|');

  // Step 1: Hash the raw API key with SHA-256
  const apiKeyHash = crypto.createHash('sha256').update(apiKey).digest();

  // Step 2: Derive signing key via HKDF
  const signingKey = crypto.hkdfSync('sha256', apiKeyHash, '', 'webhook-signature', 32);

  // Step 3: Compute expected signature
  const expectedSignature = crypto
    .createHmac('sha256', Buffer.from(signingKey))
    .update(dataString)
    .digest('hex');

  // Use timing-safe comparison to prevent timing attacks
  const sigBuf = Buffer.from(payload.signature, 'utf8');
  const expBuf = Buffer.from(expectedSignature, 'utf8');
  if (sigBuf.length !== expBuf.length) return false;
  return crypto.timingSafeEqual(sigBuf, expBuf);
}

// Usage:
// const apiKey = 'KhMc4WlA161g1rUNOJzbXkf65zV1gNDGzL4KSXASvwU='; // your raw API key
// const isValid = verifyWebhookSignature(webhookPayload, apiKey);
```

**Python** (requires `cryptography` package: `pip install cryptography`):

```python
import hmac, hashlib
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes

def verify_webhook(payload, api_key):
    data_string = '|'.join([
        payload['webhook_id'],
        payload['payment_id'],
        payload['reference_id'],
        str(payload['amount']),
        payload['currency'],
        payload['status'],
        payload['timestamp'],
        payload['completed_at'] or '',
    ])

    # Step 1: Hash the raw API key with SHA-256
    api_key_hash = hashlib.sha256(api_key.encode()).digest()

    # Step 2: Derive signing key via HKDF
    signing_key = HKDF(
        algorithm=hashes.SHA256(),
        length=32,
        salt=b'',
        info=b'webhook-signature',
    ).derive(api_key_hash)

    # Step 3: Compute and compare
    expected = hmac.new(signing_key, data_string.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(payload['signature'], expected)
```

**PHP:**

```php
function verifyWebhook(array $payload, string $apiKey): bool {
    $dataString = implode('|', [
        $payload['webhook_id'],
        $payload['payment_id'],
        $payload['reference_id'],
        (string) $payload['amount'],
        $payload['currency'],
        $payload['status'],
        $payload['timestamp'],
        $payload['completed_at'] ?? '',
    ]);

    // Step 1: Hash the raw API key with SHA-256
    $apiKeyHash = hash('sha256', $apiKey, true);

    // Step 2: Derive signing key via HKDF
    $signingKey = hash_hkdf('sha256', $apiKeyHash, 32, 'webhook-signature', '');

    // Step 3: Compute and compare
    $expected = hash_hmac('sha256', $dataString, $signingKey);
    return hash_equals($expected, $payload['signature']);
}
```

## Replay Protection

Each callback includes a `webhook_id` (unique per delivery) and `timestamp` (generation time). Use these to prevent replay attacks:

1. **Deduplication:** Store processed `webhook_id` values. If you receive a callback with a `webhook_id` you've already processed, skip it.
2. **Staleness check:** Reject callbacks where `timestamp` is older than 5 minutes from your server's current time. This prevents attackers from replaying captured callbacks.

```javascript
// Example: reject webhooks older than 5 minutes
const webhookTime = new Date(payload.timestamp).getTime();
const now = Date.now();
if (now - webhookTime > 5 * 60 * 1000) {
  console.log('Stale webhook rejected');
  return;
}
```

## Best Practices

* **Always verify the signature** before processing a callback
* **Respond quickly** — return 200 before doing heavy processing. Queue the work if needed
* **Handle duplicates** — use `webhook_id` for deduplication. Store processed IDs and skip repeats.
* **Don't rely solely on callbacks** — use the status polling API as a fallback for critical flows


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.jgopay.com/english/webhooks.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
