> ## Documentation Index
> Fetch the complete documentation index at: https://developers.paychoice.com.au/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Register an endpoint, verify signatures, and handle bank settlements and missed notifications.

PayChoice sends a POST to your endpoint when a charge is created and again each time its status changes. It is sent for **every charge under your merchant account — card or bank, API or Widget** — so it is the one place to confirm a payment outcome server-side.

It matters most for bank-account charges, which can take up to three business days to settle: the charge is `Processing` at creation and a second notification carries the final result.

## Register an endpoint

**1. Expose an HTTPS endpoint**

It must accept POST requests and return a `2xx` status on receipt. Respond quickly and do the work asynchronously if it's slow.

**2. Register it**

```bash theme={null}
curl https://api-sandbox.paychoice.com.au/api/v4/merchantwebhooks \
  -u "$PAYCHOICE_USERNAME:$PAYCHOICE_PASSWORD" \
  -d merchant_guid=$PAYCHOICE_MERCHANT_GUID \
  -d webhook_url=https://your-app.example.com/paychoice/webhook
```

```json theme={null}
{
  "id": "0f6a1c2d-8e4b-4a7c-9d3e-5b2f1a6c8d90",
  "merchant_guid": "315564b9-b863-4016-8c53-02c9d8cc80b7",
  "webhook_url": "https://your-app.example.com/paychoice/webhook",
  "webhook_secret": "c2VjcmV0LXNlY3JldC1zZWNyZXQtc2VjcmV0LXNlY3JldA==",
  "created_date": "2026-11-04T02:30:00.000Z",
  "updated_date": null,
  "updated_by": null,
  "active": true
}
```

**3. Store the secret**

`webhook_secret` signs every notification and is **only returned by this call**. Keep it in a secrets manager; never in source control or logs.

To change the URL later, `PATCH /api/v4/merchantwebhooks/{id}` with `merchant_guid` and the new `webhook_url`. Your secret is unchanged. `GET /api/v4/merchantwebhooks/{merchantGUID}` lists a merchant's registrations but requires agency-level credentials.

Register separate endpoints for sandbox and live.

## The payload

The body is JSON. Fields with a `null` value are omitted.

```json theme={null}
{
  "charge": {
    "created": "2025-01-01T22:57:02.303Z",
    "amount": "1.00",
    "currency": "AUD",
    "error": "Transaction Approved",
    "error_code": "00",
    "id": "f33a990c-237c-4e88-9bd1-eb2d655bb88c",
    "link": {
      "href": "/api/v4/charge/f33a990c-237c-4e88-9bd1-eb2d655bb88c",
      "rel": "self"
    },
    "reference": "INV000011-T",
    "aggregator_reference": "",
    "merchantguid": "11223344-5566-7788-99aa-bbccddeeff00",
    "status": "Approved",
    "status_code": "0",
    "meta_data_reference": ""
  },
  "object_type": "charge",
  "from_item": 0,
  "to_item": 0,
  "total_count": 0
}
```

A refund notification has the same envelope with a `refund` object and `object_type: "refund"`.

**charge.id** `uuid`

The charge ID. Use it to retrieve the charge, or to re-send the notification.

**charge.reference** `string`

Your reference — the `reference` you sent on the charge, or the Widget's `MerchantReference`. Match the notification to your order on this.

**charge.status\_code** `string`

`"0"` Approved, `"4"` Blocked, `"5"` Dishonoured, `"6"` Errored, `"9"` Processing. See [Charge status](https://developers.paychoice.com.au/reference/charge-status.md).

**charge.amount** `string`

Decimal with two places.

**charge.error / error\_code** `string`

Gateway result and code. See [Errors](https://developers.paychoice.com.au/reference/errors.md).

### Bank charges arrive twice

A bank charge is `Processing` (`"9"`) when first notified; a later notification with the same `id` carries the final `status`. Treat `id` as the key and the latest notification as current. Make your handler idempotent — receiving the same status twice must be harmless.

## Verify the signature

Every notification is signed so you can confirm it came from PayChoice and wasn't altered in transit. Reject anything that fails.

**1. Take the raw body**

Compute over the **exact bytes of the request body** as received — before any parsing or re-serialisation.

**2. Compute HMAC-SHA256**

Key: the UTF-8 bytes of your `webhook_secret` string, used as-is. Do not Base64-decode it.

**3. Compare to the header**

`X-Webhook-Signature` carries the digest as **uppercase hexadecimal byte pairs separated by hyphens**, e.g. `3A-F2-9C-…`. Strip the hyphens, lowercase, and compare to your own hex digest with a constant-time comparison.

```javascript Node.js theme={null}
const crypto = require('crypto');

function verify(rawBody, signatureHeader, secret) {
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = signatureHeader.replace(/-/g, '').toLowerCase();
  return received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}
```

```python Python theme={null}
import hashlib, hmac

def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
    expected = hmac.new(secret.encode('utf-8'), raw_body, hashlib.sha256).hexdigest()
    received = signature_header.replace('-', '').lower()
    return hmac.compare_digest(received, expected)
```

```csharp C# theme={null}
using System.Security.Cryptography;
using System.Text;

static bool Verify(byte[] rawBody, string signatureHeader, string secret)
{
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var expected = BitConverter.ToString(hmac.ComputeHash(rawBody)); // "3A-F2-9C-..."
    return CryptographicOperations.FixedTimeEquals(
        Encoding.ASCII.GetBytes(expected),
        Encoding.ASCII.GetBytes(signatureHeader.ToUpperInvariant()));
}
```

```php PHP theme={null}
function verify(string $rawBody, string $signatureHeader, string $secret): bool {
    $expected = hash_hmac('sha256', $rawBody, $secret);
    $received = strtolower(str_replace('-', '', $signatureHeader));
    return hash_equals($expected, $received);
}
```

Because each request is individually signed, your endpoint doesn't need its own authentication scheme. There is no timestamp header, so keep a record of processed `id` / `status` pairs if you need replay protection.

> [!TIP]
>
> IP or domain allow-listing (for example `*.paychoice.com.au`) and a secret key in the URL can be layered on top for defence in depth, but shouldn't replace signature verification.

## Missed notifications

PayChoice does **not** retry a notification automatically. If your endpoint was down or returned a non-2xx status, re-send the notifications for the charges you missed:

```bash theme={null}
curl https://api-sandbox.paychoice.com.au/api/v4/merchantwebhooks/$PAYCHOICE_MERCHANT_GUID/notification/retry \
  -u "$PAYCHOICE_USERNAME:$PAYCHOICE_PASSWORD" \
  -H "Content-Type: application/json" \
  -d '{ "type": "Merchant", "transactionGuids": ["f33a990c-237c-4e88-9bd1-eb2d655bb88c"] }'
```

As a safety net, [reconcile](https://developers.paychoice.com.au/getting-the-result/reconciliation.md) by listing charges for any orders still pending on your side after a few minutes.

## Next

[Reconciliation](https://developers.paychoice.com.au/getting-the-result/reconciliation.md) — looking up charges by your reference.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.