# Acme webhooks (/guides/webhooks)



Acme webhooks allow you to receive real-time notification of important updates to the API objects
that you have created. For instance, you may want to be notified when a Hosted Payment has
succeeded.

## Initial setup [#initial-setup]

To get started with webhooks, provide Acme with a webhook endpoint URL for test mode API calls, and
another for live mode. We only support HTTPS endpoints, and webhooks are sent as POST requests.

Examples of valid webhook endpoint URLs:

* [https://rocketshop.com/payments/webhook](https://rocketshop.com/payments/webhook)
* [https://rocketshop.com/payments/webhook?id=abc123](https://rocketshop.com/payments/webhook?id=abc123)

Acme will issue you with one webhook signing key for test mode, and another for live mode. Webhook
signing keys must be stored securely. Do not store them in source control, web applications, or
mobile applications.

Example of a webhook signing key: `57INBTPuUP9htPQKBfZtJv`

## Webhook authentication [#webhook-authentication]

You must first authenticate the webhook to be certain that it originates from Acme and was not
modified in-transit. All webhook requests will contain an `Acme-Signature` HTTP header. This is a
hex-encoded HMAC-SHA256 (see: [RFC2104](https://datatracker.ietf.org/doc/html/rfc2104.html))
signature of the request.

In order to support zero-downtime key rotation in the future, there may be multiple signatures in
this header separated by commas. You should check each signature and accept the webhook only if one
of the signatures is verified successfully.

Example of an `Acme-Signature` HTTP header:

```
Acme-Signature: 3fdcc736f0bea59a11e6c3c0a7bb42256b1f82419e781f24b2e9f541d76649c1,ef7716a50b4b0d47a32d19c7effdd9ac19ffe228dc928fb6a8ee01f457569ea6
```

### Webhook signature algorithm [#webhook-signature-algorithm]

1. Obtain the Acme-Timestamp HTTP header value.

   Example: `2023-09-20T12:03:47Z`

2. Construct the HMAC input as: `<timestamp>|<raw-body>`

   Example: `2023-09-20T12:03:47Z|{"id":"wbh_0DN88RSVDZBTH",...}`

3. Calculate the HMAC-SHA256 signature with the appropriate webhook signing key.

   ```
   HMAC-SHA256(key, input)
   ```

4. Hex-encode the signature.

   Example: `3fdcc736f0bea59a11e6c3c0a7bb42256b1f82419e781f24b2e9f541d76649c1`

5. Reject the webhook request if the signature that you have calculated does not match one of the
   signatures in the Acme-Signature HTTP header.

We also highly recommend that you reject the webhook request if the timestamp in the HTTP header is
more than 1 minute (or a tolerance acceptable to you) away from the current time. This will guard
against [replay attacks](https://en.wikipedia.org/wiki/Replay_attack). This requires your server
time to be synchronized with NTP to avoid false negatives.

Note that HTTP header names are case-insensitive. Your application's HTTP framework may change the
case of the header names (e.g: `acme-timestamp`).

## Message format [#message-format]

The webhook body is a JSON-formatted message describing the event and affected API object(s). If
there is only one object, it will be described under the `object` field. Some events may affect
multiple objects, and the objects will be described in a list under the `objects` field instead.

Example webhook body with a single API object:

```json
{
  "id": "wbh_0EPX2GCPSEAX9",
  "createdAt": "2024-01-03T01:12:11.632678370Z",
  "mode": "LIVE",
  "event": "transactions.created",
  "object": {
    "id": "txn_0EPX2HDTM277A",
    "...": "..."
  }
}
```

Example webhook body with multiple API objects:

```json
{
  "id": "wbh_0FBV5V8G5SVJD",
  "createdAt": "2024-01-03T02:27:40.172341716Z",
  "mode": "LIVE",
  "event": "transactions.created",
  "objects": [
    {
      "id": "txn_0FBV5XNA50TA2",
      "...": "..."
    },
    {
      "id": "txn_0FBV62V1N2M44",
      "...": "..."
    }
  ]
}
```

### Fields [#fields]

* `id`: String - ID that uniquely identifies each webhook event. It remains the same between
  retries.
* `event`: String - Name of the event that triggered the webhook.
* `mode`: String, `LIVE` or `TEST` - API mode of the event/affected object.
* `createdAt`: String - Creation time of the webhook event.
* `object`: Object - API object affected by the event. This follows the same format as the
  corresponding API response in Acme API.
* `objects`: Array of objects - For an event affecting multiple API objects, they will be listed
  under this field instead.

For example, when a Hosted Refund created via /v1/hosted-refunds changes its status from `PENDING`
to `SUCCEEDED`, this field will contain the same object as the response to GET
/v1/hosted-refunds/\<id> (see Response section of
[/v1/hosted-refunds](/reference/get-hosted-refunds-id)).

Similarly, the `object` field of a `direct-debit-authorizations.succeeded` event will contain the
same object as the response to GET /v1/direct-debit-authorizations/\<id> (see Response section of
[/v1/direct-debit-authorizations](/reference/get-direct-debit-authorizations-id)).

### Examples of webhook bodies [#examples-of-webhook-bodies]

The example below have been pretty-formatted for readability. The actual webhook body may be sent
in a more compact form.

More examples can be found at [Acme webhook examples](/guides/webhook-examples).

Credit transaction landed in bank account:

```json
{
  "id": "wbh_0EPX2GCPSEAX9",
  "createdAt": "2024-01-03T01:12:11.632678370Z",
  "mode": "LIVE",
  "event": "transactions.created",
  "object": {
    "id": "txn_0EPX2HDTM277A",
    "dataSource": "ICN",
    "transactionType": "PAYNOW",
    "bankReference": "I103219508247000000000C829835617410",
    "description": "Invoice 123",
    "amount": 420,
    "currency": "SGD",
    "direction": "CREDIT",
    "counterparty": {
      "name": "JOHN DOE",
      "bank": "DBSSSGSGXXX",
      "bankAccountNumber": ""
    },
    "bankAccount": {
      "id": "intacc_0EPX2QBAADQ2R",
      "bank": "DBSSSGSGXXX",
      "bankAccountNumber": "1230007890"
    },
    "virtualAccountNumber": null,
    "transactionDate": "2024-01-03",
    "statementId": null,
    "createdAt": "2024-01-03T01:12:11.628852029Z",
    "updatedAt": "2024-01-03T01:12:11.628852029Z"
  }
}
```

## Events [#events]

Acme API emits webhooks for the following events.

### Transactions [#transactions]

API: [/v1/transactions](/reference/get-transactions)

| Event                | Scenario                                                                     | Number of objects                                                    |
| -------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| transactions.created | An incoming transaction/credit notification from the bank has been received. | Only one under `object` field, or one or more under `objects` field. |

### Statements [#statements]

API: [/v1/statements](/reference/get-statements)

| Event              | Scenario                                                                                               | Number of objects              |
| ------------------ | ------------------------------------------------------------------------------------------------------ | ------------------------------ |
| statements.created | An end-of-day bank account statement has been processed, and the transactions are ready for retrieval. | Only one under `object` field. |

### Refunds [#refunds]

API: [/v1/refunds](/reference/get-refunds)

| Event             | Scenario                                                           | Number of objects              |
| ----------------- | ------------------------------------------------------------------ | ------------------------------ |
| refunds.succeeded | A Refund has succeeded, and its status has changed to `SUCCEEDED`. | Only one under `object` field. |
| refunds.failed    | A Refund has failed, and its status has changed to `FAILED`.       | Only one under `object` field. |

### Hosted Payments [#hosted-payments]

API: [/v1/hosted-payments](/reference/get-hosted-payments-id)

| Event                     | Scenario                                                  | Number of objects              |
| ------------------------- | --------------------------------------------------------- | ------------------------------ |
| hosted-payments.succeeded | The state of a Hosted Payment has changed to `SUCCEEDED`. | Only one under `object` field. |
| hosted-payments.failed    | The state of a Hosted Payment has changed to `FAILED`.    | Only one under `object` field. |

### Hosted Refunds [#hosted-refunds]

API: [/v1/hosted-refunds](/reference/get-hosted-refunds-id)

| Event                    | Scenario                                                 | Number of objects              |
| ------------------------ | -------------------------------------------------------- | ------------------------------ |
| hosted-refunds.succeeded | The state of a Hosted Refund has changed to `SUCCEEDED`. | Only one under `object` field. |
| hosted-refunds.failed    | The state of a Hosted Refund has changed to `FAILED`.    | Only one under `object` field. |

### Direct Debit Authorizations [#direct-debit-authorizations]

API: [/v1/direct-debit-authorizations](/reference/get-direct-debit-authorizations-id)

| Event                                 | Scenario                                                              | Number of objects              |
| ------------------------------------- | --------------------------------------------------------------------- | ------------------------------ |
| direct-debit-authorizations.succeeded | The state of a Direct Debit Authorization has changed to `SUCCEEDED`. | Only one under `object` field. |
| direct-debit-authorizations.failed    | The state of a Direct Debit Authorization has changed to `FAILED`.    | Only one under `object` field. |
| direct-debit-authorizations.canceled  | The state of a Direct Debit Authorization has changed to `CANCELED`.  | Only one under `object` field. |

### Direct Debit Payments [#direct-debit-payments]

API: [/v1/direct-debit-payments](/reference/get-direct-debit-payments-id)

| Event                           | Scenario                                                        | Number of objects              |
| ------------------------------- | --------------------------------------------------------------- | ------------------------------ |
| direct-debit-payments.succeeded | The state of a Direct Debit Payment has changed to `SUCCEEDED`. | Only one under `object` field. |
| direct-debit-payments.failed    | The state of a Direct Debit Payment has changed to `FAILED`.    | Only one under `object` field. |

### Direct Debit Payment Batches [#direct-debit-payment-batches]

API: [/v1/direct-debit-payment-batches](/reference/get-direct-debit-payment-batches)

| Event                                  | Scenario                                                                                                             | Number of objects              |
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
| direct-debit-payment-batches.submitted | The state of a Direct Debit Payment Batch has changed to `SUBMITTED` after being submitted to the bank successfully. | Only one under `object` field. |
| direct-debit-payment-batches.rejected  | The state of a Direct Debit Payment Batch has changed to `FAILED` after being rejected by the bank.                  | Only one under `object` field. |

### Payments [#payments]

API: [/v1/payments](/reference/get-payments-id)

| Event              | Scenario                                           | Number of objects                                                    |
| ------------------ | -------------------------------------------------- | -------------------------------------------------------------------- |
| payments.succeeded | The state of a Payment has changed to `COMPLETED`. | Only one under `object` field, or one or more under `objects` field. |
| payments.failed    | The state of a Payment has changed to `FAILED`.    | Only one under `object` field, or one or more under `objects` field. |

#### Maker-checker Payment Flow [#maker-checker-payment-flow]

API: [/v1/payments/:id/approve](/reference/post-payments-id-approve) and [/v1/payments/:id/reject](/reference/post-payments-id-reject)

* When an API key is covered by a maker-checker policy, newly created payments enter a `PENDING_APPROVAL` state and emit a `payments.pending-approval` webhook.
* A separate checker key must then approve or reject the Payment, which emits `payments.approved` or `payments.approval-rejected`.
* A approval window expiry can be defined in the maker-checker policy. If no action is taken before the approval window expires, `payments.approval-expired` will be emitted.
* Approved Payments continue through the normal lifecycle (`payments.succeeded` / `payments.failed`).

| Event                      | Scenario                                                                                                                                                                                      | Number of objects              |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
| payments.pending-approval  | A Payment was created under a maker-checker policy and is awaiting approval. Status is `PENDING_APPROVAL`. The payload includes a `review` object with `requestedBy` and `expiresAt`.         | Only one under `object` field. |
| payments.approved          | A pending Payment has been approved by a checker. The payload's `review` object includes `approvedBy` and `approvedAt`. Payment will subsequently transition to `SUCCEEDED` after settlement. | Only one under `object` field. |
| payments.approval-rejected | A pending Payment has been rejected by a checker. Status becomes `APPROVAL_REJECTED`. The payload's `review` object includes `rejectedBy`, `rejectedAt`, and `rejectionReason`.               | Only one under `object` field. |
| payments.approval-expired  | A pending Payment exceeded its approval window without action. Status becomes `APPROVAL_EXPIRED`.                                                                                             | Only one under `object` field. |

### Batch Payments [#batch-payments]

API: [/v1/batch-payments](/reference/get-batch-payments-id)

| Event                     | Scenario                                                 | Number of objects              |
| ------------------------- | -------------------------------------------------------- | ------------------------------ |
| payment-batches.submitted | The state of a Batch Payment has changed to `SUBMITTED`. | Only one under `object` field. |
| payment-batches.rejected  | The state of a Batch Payment has changed to `FAILED`.    | Only one under `object` field. |

Note: this webhook reports only the batch-level status. Each payment's outcome is delivered separately via the `payments.succeeded` and `payments.failed` webhooks , with multiple payments grouped under the objects array (up to 100 per webhook). Examples: [Multiple successful payments](/guides/webhook-examples#payments)

### Tracked QR Payments [#tracked-qr-payments]

API: [/v1/tracked-payment-qr-codes](/reference/list-tracked-payment-qr-codes)

| Event                 | Scenario                                                      | Number of objects              |
| --------------------- | ------------------------------------------------------------- | ------------------------------ |
| qr-payments.succeeded | The state of a Tracked QR Payment has changed to `SUCCEEDED`. | Only one under `object` field. |
| qr-payments.failed    | The state of a Tracked QR Payment has changed to `FAILED`.    | Only one under `object` field. |

### Virtual Accounts [#virtual-accounts]

API: [/v1/virtual-accounts](/reference/get-virtual-accounts)

| Event                      | Scenario                                                   | Number of objects              |
| -------------------------- | ---------------------------------------------------------- | ------------------------------ |
| virtual-accounts.succeeded | The state of a Virtual Account has changed to `SUCCEEDED`. | Only one under `object` field. |
| virtual-accounts.failed    | The state of a Virtual Account has changed to `FAILED`.    | Only one under `object` field. |

## Retries [#retries]

You should respond to webhook requests with HTTP 200 status code within 5 seconds. If your webhook
endpoint fails to respond with HTTP 200 within 5 seconds, it will be treated as a failed delivery
and the webhook delivery will be retried over a period of time.

Each subsequent retry will occur with an increasing time interval
([exponential backoff](https://en.wikipedia.org/wiki/Exponential_backoff)). Test mode webhooks will
be retried for up to 4 times over a span of approximately 1 hour and 20 minutes. Live mode webhooks
will be retried for up to 8 times over a span of approximately 5 days and 4 hours.

Due to the distributed nature of our systems and the Internet, we cannot guarantee exactly-once
delivery of our webhooks. That is, you may receive duplicate webhooks. The `id` field can be used
for de-duplication if necessary.

## Appendix [#appendix]

### Sample implementation of webhook signature calculation [#sample-implementation-of-webhook-signature-calculation]

This is a sample implementation of webhook signature calculation Python. You can use it to validate
your own implementation.

```python
import hashlib
import hmac

def calculate_webhook_signature(signing_key, timestamp, raw_body):
    message = timestamp + "|" + raw_body
    signature = hmac.new(signing_key.encode(), message.encode(), hashlib.sha256)
    return signature.hexdigest()
```

Interactive Replit snippets:

* [Python](https://replit.com/@cheeliang-acme/EnchantingDelightfulSoftwareengineer)
* [TypeScript](https://replit.com/@cheeliang-acme/RecklessCuddlySquare)

### Webhook signature test case [#webhook-signature-test-case]

You can test your signature validation implementation with the following test inputs and expected
output.

* Signing key: `3JZqRZ6RvUOEBT92nmNLyA`
* Timestamp: `2023-09-20T12:55:36Z`
* Raw body:

```json
{
  "id": "wbh_0EPWZ59TG83M1",
  "createdAt": "2024-01-03T01:05:43.401984161Z",
  "mode": "LIVE",
  "event": "hosted-payments.succeeded",
  "object": {
    "id": "hpymt_0EPWZ776H01BP",
    "status": "SUCCEEDED",
    "resultCode": null,
    "amount": 420,
    "currency": "SGD",
    "channel": "APP_IOS",
    "method": "PAYLAH",
    "returnUrl": "acme://return/123",
    "redirectUrl": "https://api.tryacme.com/redirection/hosted-payments/hpymt_0EPWZ776H01BP/submit",
    "referenceId": "239555816",
    "tokenization": true,
    "hostedPaymentMethodId": "hpm_0EPWZDF3FP4TX",
    "customerProxy": "XXXX1234",
    "createdAt": "2024-01-03T01:05:06.160976Z",
    "updatedAt": "2024-01-03T01:05:43.398068573Z"
  }
}
```

Expected signature: `e95a0ff6bddd36b309329cec7ca22145ea3c0c7825e089130ec158483aa2538d`
