# Verify callbacks and checksums

> How Bayarcash callbacks and return URLs work, and how to sign requests and verify callbacks with an HMAC-SHA256 checksum.
>
> Source: https://docs.bayarcash.com/callbacks-and-checksum/

When a payment finishes, Bayarcash tells your server with a **callback** and sends your customer back with a **return URL**. Every callback carries a `checksum`: check it with your API Secret Key before you trust the data. You can also add a checksum to your own requests so Bayarcash knows they were not changed.

## Callback URL and return URL

You set both URLs on each payment intent (`callback_url` and `return_url`). They do different jobs:

| | Callback URL | Return URL |
|---|---|---|
| Who calls it | Bayarcash's server, directly to yours | Your customer's browser, redirected by Bayarcash |
| How it arrives | POST | GET with the fields in the query string (API v3) |
| Can you trust it for the order status? | Yes, after checking the checksum | No: the customer can close the tab or change the URL |
| Use it to | Mark the order paid or failed | Show the customer a result page |

Update your orders from the callback only. If the return URL arrives first, show a "payment is being confirmed" message and wait for the callback, or look the transaction up with the API.

## Callbacks Bayarcash sends

Each callback has a `record_type` field that tells you what it is:

| Callback | When it is sent |
|---|---|
| Pre-transaction | Early in the payment, before the transaction record is final. |
| Transaction | When a payment reaches a final state (successful, failed, cancelled and so on). |
| FPX Direct Debit authorization | When a customer completes or abandons e-mandate enrolment. |
| FPX Direct Debit bank approval | When the customer's bank approves or rejects a mandate. This can come days later. |
| FPX Direct Debit transaction | For each collection taken from an active mandate. |

The `status` field in a transaction callback is a number:

| Status | Meaning |
|---|---|
| 0 | New |
| 1 | Pending |
| 2 | Unsuccessful |
| 3 | Successful |
| 4 | Cancelled |
| 5 | Abandoned |

## Answer every callback with HTTP 200

Your callback URL must answer **HTTP 200** once it has checked and saved the result. If it answers with any other status, or does not answer, Bayarcash sends the callback again. So your handler must be safe to run twice: check whether you have already processed that `transaction_id` before you fulfil the order.

Callbacks are sent as a POST. The Bayarcash plugins and SDKs read them as form fields (`$_POST` in PHP). If your framework receives `application/json`, read the JSON body instead.

## How the checksum works

The same rule signs requests and verifies callbacks. Only the list of fields changes from call to call. To build a checksum:

1. Take the fields that belong to that call (see the table below), with their values exactly as sent or received. Use an empty value for a field that is missing.
2. Sort the fields by field name, A to Z.
3. Join the values, in that order, with a `|` between each.
4. Compute an HMAC-SHA256 of that text, using your API Secret Key as the key. The result, as lowercase hex, is the checksum.

For a callback, compare your result with the `checksum` field the callback carries. Use a constant-time comparison, such as `hash_equals()` in PHP or `hmac.compare_digest()` in Python. If they differ, reject the callback.

A checksum on a request is optional but recommended. A checksum on a callback is always there: always verify it.

## Fields in each checksum

Each call uses its own fields:

| Call | Fields in the checksum |
|---|---|
| Create payment intent (request) | `payment_channel`, `order_number`, `amount`, `payer_name`, `payer_email` |
| FPX Direct Debit enrolment (request) | `order_number`, `amount`, `payer_name`, `payer_email`, `payer_telephone_number`, `payer_id_type`, `payer_id`, `application_reason`, `frequency_mode` |
| Pre-transaction callback | `record_type`, `exchange_reference_number`, `order_number` |
| Transaction callback | `record_type`, `transaction_id`, `exchange_reference_number`, `exchange_transaction_id`, `order_number`, `currency`, `amount`, `payer_name`, `payer_email`, `payer_bank_name`, `status`, `status_description`, `datetime` |
| Return URL | `transaction_id`, `exchange_reference_number`, `exchange_transaction_id`, `order_number`, `currency`, `amount`, `payer_bank_name`, `status`, `status_description` |
| FPX Direct Debit bank approval callback | `record_type`, `approval_date`, `approval_status`, `mandate_id`, `mandate_reference_number`, `order_number`, `payer_bank_code_hashed`, `payer_bank_code`, `payer_bank_account_no`, `application_type` |
| FPX Direct Debit transaction callback | `record_type`, `batch_number`, `mandate_id`, `mandate_reference_number`, `transaction_id`, `datetime`, `reference_number`, `amount`, `status`, `status_description`, `cycle` |

The table lists the fields; the sort in step 2 decides the order. If a payment intent has more than one `payment_channel`, join the channel numbers with commas (`1,5`) before you add it, as the PHP SDK does. For FPX Direct Debit maintenance requests and authorization callbacks, use the verification methods in an [official SDK](/sdks/), which carry the exact field lists.

## Example: verify a transaction callback in PHP

This checks a transaction callback without the SDK. With the [PHP SDK](/php-sdk/), `verifyTransactionCallbackData()` does the same in one line.

```php
function bayarcash_checksum(array $data, array $fields, string $secretKey): string
{
    $payload = [];
    foreach ($fields as $field) {
        $payload[$field] = $data[$field] ?? '';
    }
    ksort($payload);

    return hash_hmac('sha256', implode('|', $payload), $secretKey);
}

$fields = [
    'record_type', 'transaction_id', 'exchange_reference_number',
    'exchange_transaction_id', 'order_number', 'currency', 'amount',
    'payer_name', 'payer_email', 'payer_bank_name', 'status',
    'status_description', 'datetime',
];

$callback = $_POST;
$expected = bayarcash_checksum($callback, $fields, getenv('BAYARCASH_API_SECRET_KEY'));

if (! hash_equals($expected, (string) ($callback['checksum'] ?? ''))) {
    http_response_code(403);
    exit;
}

// Checksum is valid: save the result, then answer 200.
http_response_code(200);
```

## Example: sign a payment intent in Python

The same rule in Python, for a payment intent request:

```python

def bayarcash_checksum(values: dict, secret_key: str) -> str:
    joined = "|".join(str(values[k]) for k in sorted(values))
    return hmac.new(secret_key.encode(), joined.encode(), hashlib.sha256).hexdigest()

checksum = bayarcash_checksum({
    "payment_channel": "1",
    "order_number": "INV-1001",
    "amount": "10.00",
    "payer_name": "Ahmad bin Abdullah",
    "payer_email": "ahmad@example.com",
}, "YOUR_API_SECRET_KEY")
```

Send the result as `checksum` with the other fields. The full field reference is in the [API reference checksum guide](https://api-reference.bayarcash.com/#tag/checksum).

## Tips

- **Use HTTPS for your callback and return URLs.** The callback carries payment details, and HTTPS keeps them private in transit.
- **Keep the API Secret Key on your server only.** Anyone with it can forge a callback that passes the checksum check.
- **Use your framework package if you have one.** The Laravel and Django packages add a callback route that checks the checksum and rejects a bad one with HTTP 403.

## Common issues

### My WooCommerce log says "Invalid checksum for order". What is wrong?

The checksum did not match. Most often the API Secret Key is wrong, or it comes from the other environment (a sandbox key on a production callback, or the reverse). Copy the key again from the merchant dashboard you are using.

### My checksum never matches, even with the right key. What should I check?

Use the exact field list for that call, sort by field name, and use the values exactly as sent or received. 10.00 and 10 give different checksums. A missing field counts as an empty value, not as a skipped one.

### Why did the same callback arrive twice?

Bayarcash sends a callback again when your server does not answer with HTTP 200. Answer 200 once you have saved the result, and check the transaction ID so you do not process the same payment twice.
