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

# Webhooks

## Overview

PayKilla sends webhook notifications to inform your application about events that occur in your account. Webhooks allow you to receive real-time updates about payments, transactions, invoices, and other important events.

**Webhook URL:** you choose it — any publicly reachable `https://` endpoint on your side, for example `https://your-domain.com/webhooks/paykilla`. The URL is set in the dashboard; it is not passed in API requests.

***

## Quick Start

The simplest webhook integration handles one event, `INVOICE_PAID` (add `INVOICE_EXPIRED` if you want to cancel unpaid orders).

```typescript
import { createHmac, timingSafeEqual } from "crypto"

const SECRET_KEY = process.env.PAYKILLA_V2_SECRET_KEY!
const WEBHOOK_URL = "https://your-app.com/webhooks/paykilla"

export async function POST(request: Request) {
  // 1. Get signature headers
  const timestamp = request.headers.get("X-API-TIMESTAMP")
  const recvWindow = parseInt(request.headers.get("X-API-RECV-WINDOW") || "5000", 10)
  const signature = request.headers.get("X-API-SIGN")
  const payload = await request.text()

  // 2. Verify signature
  if (!timestamp || !signature) {
    return new Response("Unauthorized", { status: 401 })
  }

  // Check timestamp freshness
  if (Date.now() - parseInt(timestamp, 10) > recvWindow) {
    return new Response("Request expired", { status: 401 })
  }

  // Verify HMAC signature
  const message = `${timestamp}POST${WEBHOOK_URL}${payload}`
  const expected = createHmac("sha256", SECRET_KEY).update(message).digest("hex")
  
  // timingSafeEqual throws on buffers of different length, so compare the length first
  const expectedBuf = Buffer.from(expected, "hex")
  const signatureBuf = Buffer.from(signature, "hex")
  const isValid = expectedBuf.length === signatureBuf.length && timingSafeEqual(expectedBuf, signatureBuf)
  
  if (!isValid) {
    return new Response("Unauthorized", { status: 401 })
  }

  // 3. Parse and handle event
  const event = JSON.parse(payload)
  
  if (event.eventType === "INVOICE_PAID") {
    const invoiceId = event.data.id
    await markOrderAsPaid(invoiceId)
  }

  // 4. Always respond 200 quickly
  return new Response("OK", { status: 200 })
}
```

That's it! For most e-commerce use cases, this is all you need.

***

## Which Events Do You Need?

Choose events based on your integration requirements:

### Simple Store

For basic payment acceptance, you only need 2 events:

| Event             | Why                 |
| ----------------- | ------------------- |
| `INVOICE_PAID`    | Mark order as paid  |
| `INVOICE_EXPIRED` | Cancel unpaid order |

### Standard Integration

For better user experience with payment status tracking:

| Event                   | Why                                    |
| ----------------------- | -------------------------------------- |
| `INVOICE_CREATED`       | Confirm invoice was created            |
| `PAYMENT_PENDING`       | Show "waiting for payment" status      |
| `TRANSACTION_CONFIRMED` | Show "payment received, confirming..." |
| `INVOICE_PAID`          | Mark order as paid                     |
| `INVOICE_EXPIRED`       | Cancel unpaid order                    |

### Full Integration

For complete visibility including compliance and withdrawals, enable all events.

### PAYMENT\_COMPLETED vs INVOICE\_PAID

These events are sent at the same time when payment is successful:

| Event               | Payload                                       | When to use                                       |
| ------------------- | --------------------------------------------- | ------------------------------------------------- |
| `INVOICE_PAID`      | Invoice `id`, `type` and `clientOrderId` only | Simple integrations                               |
| `PAYMENT_COMPLETED` | Full details including `payments[]` array     | When you need payment details (currency, amounts) |

If you don't need payment details, `INVOICE_PAID` is sufficient.

***

## Event Flow

Typical sequence of events for a successful payment:

```
INVOICE_CREATED          Invoice created, waiting for payment
        ↓
PAYMENT_PENDING          Payment address requested for the invoice
        ↓
TRANSACTION_CONFIRMED    First blockchain confirmation
        ↓
PAYMENT_COMPLETED  ←→  INVOICE_PAID    Payment successful (sent simultaneously)
```

**Notes:**

* `INVOICE_PAID` can arrive without `PAYMENT_COMPLETED` if that webhook is disabled in settings
* `INVOICE_EXPIRED` is sent instead if payment is not received before expiration
* `PAYMENT_PARTIAL` is only sent for Open Amount invoices

Sequence of events for a deposit to a static address:

```
DEPOSIT_RECEIVED         Transfer detected; for a static address data.customerId tells you who paid
        ↓
TRANSACTION_CONFIRMED    First blockchain confirmation
        ↓
TRANSACTION_FINAL        Final confirmation reached
        ↓
COMPLIANCE_PASSED        AML check passed
        ↓
BALANCE_UPDATED          Amount credited to your balance (after the fee)
```

A deposit to the deposit address of your own account ends with the same `BALANCE_UPDATED`.

***

## TypeScript Interfaces

Copy these interfaces to your project for type-safe webhook handling:

```typescript
// Base webhook structure
interface WebhookEvent<T = unknown> {
  id: string
  priority: "HIGH" | "MEDIUM" | "LOW"
  eventType: string
  data: T
}

// Payment events
interface PaymentCompletedData {
  id: string
  type: "INVOICE"
  currency: string              // invoice currency
  amount: string | null         // currently always null — read payments[] instead
  clientOrderId: string | null
  payments: {
    currency: string
    amount: string
    creditAmount: string
  }[]
}

interface PaymentFailedData {
  id: string
  type: "INVOICE"
  clientOrderId: string | null
}

interface PaymentPendingData {
  id: string
  type: "INVOICE"
  clientOrderId: string | null
}

// Invoice events
interface InvoiceCreatedData {
  id: string
  type: string                   // invoice type, e.g. "FIAT_BASED" or "FIXED_AMOUNT"
  currency: string
  amount: string
  clientOrderId: string | null
  createdAt: string
  expiredAt: string | null
}

interface InvoicePaidData {
  id: string
  type: "INVOICE"
  clientOrderId: string | null
}

interface InvoiceExpiredData {
  id: string
  type: "INVOICE"
  currency: string
  expectedAmount: string
  clientOrderId: string | null
  createdAt: string
  expiredAt: string
}

// Transaction events
interface TransactionConfirmedData {
  id: string
  type: "INVOICE" | "DEPOSIT"
  clientOrderId?: string | null  // Only for INVOICE type
  currency: string
  hash: string
  address: string
  extra: string | null
  amount: string
  walletId?: string              // present for DEPOSIT type
  confirmations: number
}

// System events
interface DepositReceivedData {
  id: string
  type: "DEPOSIT"
  currency: string
  hash: string
  address: string
  extra: string | null
  amount: string                 // amount received, before the fee
  walletId: string
  customerId?: string | null     // your customer identifier — for a deposit to a static address
}

interface WithdrawalCompletedData {
  id: string
  type: "WITHDRAWAL"
  currency: string
  hash: string
  address: string
  amount: string
}

interface WithdrawalInitiatedData {
  id: string
  type: "WITHDRAWAL"
  currency: string
  amount: string
  address: string
  createdAt: string
}

interface BalanceUpdatedData {
  id: string                     // ID of the operation that changed the balance
  type: "DEPOSIT" | "INVOICE" | "WITHDRAWAL"
  status: string                 // "PROCESSING" or "SUCCESS"
  currency: string
  amount: string                 // amount of the operation as applied to the balance
  balance: string
  holdBalance: string
  walletId: string
}

// Payment variation events
interface PaymentPartialData {
  id: string
  type: "INVOICE"
  clientOrderId: string | null
}

interface PaymentOverpaidData {
  id: string
  type: "INVOICE"
  clientOrderId: string | null
}

interface PaymentUnderpaidData {
  id: string
  type: "INVOICE"
  clientOrderId: string | null
}

// Compliance events
interface ComplianceFailedData {
  id: string
  type: "INVOICE" | "DEPOSIT"
  clientOrderId?: string | null  // Only for INVOICE type
}

interface CompliancePassedData {
  id: string
  type: "INVOICE" | "DEPOSIT"
  status?: string                // "SUCCESSFUL" for DEPOSIT type
  clientOrderId?: string | null  // Only for INVOICE type
}

// Transaction events
interface TransactionFinalData {
  id: string
  type: "INVOICE" | "DEPOSIT"
  clientOrderId: string | null   // always present; null for deposits and for invoices created without it
  currency: string
  hash: string
  address: string
  extra?: string                 // present only for currencies that use a memo / tag
  amount?: string                // not present for DEPOSIT type
  confirmations: number          // final confirmation count, depends on the currency
}

// Type-safe event handler
type WebhookEventMap = {
  // Payment events
  PAYMENT_COMPLETED: WebhookEvent<PaymentCompletedData>
  PAYMENT_FAILED: WebhookEvent<PaymentFailedData>
  PAYMENT_PENDING: WebhookEvent<PaymentPendingData>
  PAYMENT_PARTIAL: WebhookEvent<PaymentPartialData>
  PAYMENT_OVERPAID: WebhookEvent<PaymentOverpaidData>
  PAYMENT_UNDERPAID: WebhookEvent<PaymentUnderpaidData>
  // Invoice events
  INVOICE_CREATED: WebhookEvent<InvoiceCreatedData>
  INVOICE_PAID: WebhookEvent<InvoicePaidData>
  INVOICE_EXPIRED: WebhookEvent<InvoiceExpiredData>
  // Transaction events
  TRANSACTION_CONFIRMED: WebhookEvent<TransactionConfirmedData>
  TRANSACTION_FINAL: WebhookEvent<TransactionFinalData>
  // Compliance events
  COMPLIANCE_FAILED: WebhookEvent<ComplianceFailedData>
  COMPLIANCE_PASSED: WebhookEvent<CompliancePassedData>
  COMPLIANCE_FLAGGED: WebhookEvent<unknown>
  // System events
  BALANCE_UPDATED: WebhookEvent<BalanceUpdatedData>
  DEPOSIT_RECEIVED: WebhookEvent<DepositReceivedData>
  WITHDRAWAL_INITIATED: WebhookEvent<WithdrawalInitiatedData>
  WITHDRAWAL_COMPLETED: WebhookEvent<WithdrawalCompletedData>
  // Auto-conversion events
  CONVERSION_COMPLETED: WebhookEvent<unknown>
  CONVERSION_FAILED: WebhookEvent<unknown>
  CONVERSION_CURRENCY_UNAVAILABLE: WebhookEvent<unknown>
}
```

**Note:** Webhook payloads may contain additional fields not listed here. Your code should handle unknown fields gracefully.

***

## Configuration

### Setting Up Webhooks

1. Log in to your PayKilla dashboard
2. Navigate to **Settings** → **Webhooks**
3. Enter your webhook endpoint URL (must start with `https://`) and save it
4. Once the URL is saved, attach one of your API keys to the endpoint — its **secret key** is used to sign every delivery (see Signature Verification)
5. Select the events you want to receive and save the changes

Events belong to six groups: `PAYMENT`, `TRANSACTION`, `INVOICE`, `COMPLIANCE`, `SYSTEM` and `CONVERSION` (see [Event Summary](#event-summary)).

Your current webhook settings can be read with an API key: `GET /api/v2/user/webhook` returns the list of events (`eventGroup`, `eventType`, `description`, `isEnabled`) and the endpoint (`url` and the attached API key).

### Requirements

* Your endpoint must be publicly accessible via HTTPS
* Your endpoint must respond with HTTP 200 within **10 seconds**
* Failed deliveries are retried with increasing pauses (see Retry Policy below)

### Retry Policy

If your endpoint does not answer with HTTP 200, PayKilla sends the same webhook again. The pause grows by 2 minutes with every failed attempt:

| Attempt | Pause before it                |
| ------- | ------------------------------ |
| 1       | — (sent right after the event) |
| 2       | 2 minutes                      |
| 3       | 4 minutes                      |
| 4       | 6 minutes                      |
| 5       | 8 minutes                      |
| 6       | 10 minutes                     |
| 7       | 12 minutes                     |
| 8       | 14 minutes                     |

| Parameter           | Value                              |
| ------------------- | ---------------------------------- |
| Response timeout    | 10 seconds                         |
| First retry         | 2 minutes after the failed attempt |
| Maximum retry delay | 30 minutes                         |
| Retry count         | Varies by event priority           |

Every retry is a new signed request: the body, including the webhook `id`, is the same, while `X-API-TIMESTAMP` and `X-API-SIGN` are new. Use the `id` to make your handler idempotent. If `id` is an empty string, use `eventType` + `data.id` + `data.status` instead.

**Best practice:** Always respond with HTTP 200 as quickly as possible, then process the webhook asynchronously to avoid timeouts.

***

## Webhook Structure

All webhook payloads follow a consistent structure:

```json
{
  "id": "a767ea9b1f3a4f84bcf25c4a233b1f05",
  "priority": "HIGH",
  "eventType": "EVENT_NAME",
  "data": {}
}
```

`data` contains the event-specific fields described for each event below.

### Common Fields

| Field       | Type   | Description                                |
| ----------- | ------ | ------------------------------------------ |
| `id`        | string | Webhook ID                                 |
| `priority`  | string | Event priority: `HIGH`, `MEDIUM`, or `LOW` |
| `eventType` | string | Type of event that occurred                |
| `data`      | object | Event-specific payload data                |

### Client Order ID in Webhooks

Payment, invoice, transaction and compliance events that relate to an invoice include the `clientOrderId` field in the `data` object.

**When clientOrderId was provided:**

```json
{
  "id": "a767ea9b1f3a4f84bcf25c4a233b1f05",
  "priority": "MEDIUM",
  "eventType": "INVOICE_PAID",
  "data": {
    "id": "7aND5mZHW3GkRf9UbC",
    "type": "INVOICE",
    "clientOrderId": "order-123"
  }
}
```

**If clientOrderId was not provided:** in the response it will be `null`

```json
{
  "data": {
    "id": "7aND5mZHW3GkRf9UbC",
    "type": "INVOICE",
    "clientOrderId": null
  }
}
```

This allows you to correlate webhook events with your internal orders.

### Priority Levels

| Priority | Description                                   | Recommended Action                  |
| -------- | --------------------------------------------- | ----------------------------------- |
| `HIGH`   | Critical events requiring immediate attention | Process immediately, trigger alerts |
| `MEDIUM` | Important events for tracking                 | Process in near real-time           |
| `LOW`    | Informational events                          | Process asynchronously              |

***

## Signature Verification

When an API key is attached to your webhook endpoint (dashboard → Settings → Webhooks), every webhook request is signed using **HMAC-SHA256** with the **secret key of that API key**. This lets you verify that the request actually came from PayKilla and hasn't been tampered with.

### Webhook Headers

Each signed webhook request includes the following headers:

| Header              | Description                                                |
| ------------------- | ---------------------------------------------------------- |
| `X-API-KEY`         | Your public API key                                        |
| `X-API-SIGN`        | HMAC-SHA256 signature (hex-encoded)                        |
| `X-API-TIMESTAMP`   | Unix timestamp in milliseconds when the request was signed |
| `X-API-RECV-WINDOW` | Maximum allowed delay in milliseconds (`5000`)             |

The request is a `POST` with `Content-Type: application/json` and a compact JSON body. `X-API-SIGN` is 64 lower-case hex characters. Most HTTP frameworks expose header names in lower case (`x-api-sign`, `x-api-timestamp`).

### How the Signature is Built

The signature is computed over a message string constructed as:

```
message = timestamp + method + url + body
```

Where:

* **timestamp** — value from the `X-API-TIMESTAMP` header
* **method** — HTTP method in upper case (`POST`)
* **url** — full webhook endpoint URL, exactly as saved in the dashboard (scheme, host and path). A signature calculated with only the path does not match
* **body** — raw JSON body of the request

The signature is then:

```
HMAC-SHA256(message, your_secret_key) → hex string
```

### Verification Steps

1. Read the `X-API-TIMESTAMP` and `X-API-RECV-WINDOW` headers
2. Check that the request is not too old: `current_time - timestamp < recv_window`
3. Reconstruct the message: `timestamp + method + url + raw_body`
4. Compute the HMAC-SHA256 using your **secret key**
5. Compare the result with the `X-API-SIGN` header using a **constant-time comparison**

### Example

Suppose you receive the following webhook request:

**Request:**

```
POST https://your-app.com/webhooks/payments
```

**Headers:**

```
X-API-KEY: your_public_key
X-API-SIGN: 0eb4ccf1a1375731a9c9414e0b7258db8482599829c58d406412ac4a8d8b2d21
X-API-TIMESTAMP: 1738800000000
X-API-RECV-WINDOW: 5000
```

**Body:**

```json
{
  "id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
  "priority": "LOW",
  "eventType": "TRANSACTION_CONFIRMED",
  "data": {
    "confirmations": 1,
    "hash": "0x1a2b3c..."
  }
}
```

**Signed message (concatenated string):**

```
1738800000000POSThttps://your-app.com/webhooks/payments{"id":"d290f1ee-6c54-4b01-90e6-d701748f0851","priority":"LOW","eventType":"TRANSACTION_CONFIRMED","data":{"confirmations":1,"hash":"0x1a2b3c..."}}
```

**Your secret key:**

```
secret_key
```

To verify, compute `HMAC-SHA256(message, secret_key)` and compare the hex result with the `X-API-SIGN` header value.

You can use this example as a test vector for your implementation: the `X-API-SIGN` value above is the exact HMAC-SHA256 of the message above with the key `secret_key`. Note that the message contains the **full** URL — signing only the path (`/webhooks/payments`) produces a different signature. The body in this example is shortened for illustration (a real `TRANSACTION_CONFIRMED` event has more fields and priority `MEDIUM`), and its timestamp is in the past, so skip the freshness check when you test against it.

### Node.js Example

The signature covers the raw request body, so keep the raw bytes. With Express:

```javascript
const crypto = require("node:crypto")
const express = require("express")

const app = express()
// keep the raw body next to the parsed one
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString("utf8") } }))

// The full URL of your webhook endpoint (the one configured in the dashboard)
const WEBHOOK_URL = "https://your-app.com/webhooks/paykilla"

function verifyWebhook(req, secretKey) {
  // Node.js lower-cases incoming header names
  const timestamp = req.headers["x-api-timestamp"]
  const signature = req.headers["x-api-sign"]
  const recvWindow = parseInt(req.headers["x-api-recv-window"], 10)

  if (!timestamp || !signature || !Number.isFinite(recvWindow)) {
    throw new Error("Missing signature headers")
  }

  // 1. Check timestamp freshness
  if (Date.now() - parseInt(timestamp, 10) > recvWindow) {
    throw new Error("Request expired")
  }

  // 2. Reconstruct the message
  // Use the raw body string, not JSON.stringify(req.body),
  // and the FULL endpoint URL (scheme + host + path), not req.originalUrl
  const message = `${timestamp}${req.method}${WEBHOOK_URL}${req.rawBody}`

  // 3. Compute expected signature
  const expected = Buffer.from(
    crypto.createHmac("sha256", secretKey).update(message).digest("hex"),
    "hex"
  )
  const received = Buffer.from(signature, "hex")

  // 4. Constant-time comparison (timingSafeEqual throws on different lengths)
  if (received.length !== expected.length || !crypto.timingSafeEqual(expected, received)) {
    throw new Error("Invalid signature")
  }

  return true
}
```

**Next.js / Web API Example:**

```typescript
import { createHmac, timingSafeEqual } from "crypto"

const SECRET_KEY = process.env.PAYKILLA_V2_SECRET_KEY!
const WEBHOOK_URL = "https://your-app.com/webhooks/paykilla"

export async function POST(request: Request) {
  const timestamp = request.headers.get("X-API-TIMESTAMP")
  const recvWindow = parseInt(request.headers.get("X-API-RECV-WINDOW") || "5000", 10)
  const signature = request.headers.get("X-API-SIGN")
  const payload = await request.text()  // Raw body string

  if (!timestamp || !signature) {
    return new Response("Missing signature headers", { status: 401 })
  }

  // Check timestamp freshness
  if (Date.now() - parseInt(timestamp, 10) > recvWindow) {
    return new Response("Request expired", { status: 401 })
  }

  // Reconstruct message and verify signature
  const message = `${timestamp}POST${WEBHOOK_URL}${payload}`
  const expected = createHmac("sha256", SECRET_KEY).update(message).digest("hex")
  
  // timingSafeEqual throws on buffers of different length, so compare the length first
  const expectedBuf = Buffer.from(expected, "hex")
  const signatureBuf = Buffer.from(signature, "hex")
  const isValid = expectedBuf.length === signatureBuf.length && timingSafeEqual(expectedBuf, signatureBuf)

  if (!isValid) {
    return new Response("Invalid signature", { status: 401 })
  }

  const event = JSON.parse(payload)
  await handleWebhookEvent(event)

  return new Response("OK", { status: 200 })
}
```

### Python Example

```python
import hmac
import hashlib
import time

def verify_webhook(headers, method, url, body, secret_key):
    # body: the raw request body decoded to str; url: the full webhook endpoint URL
    headers = {k.lower(): v for k, v in headers.items()}  # header names are case-insensitive
    timestamp = headers["x-api-timestamp"]
    recv_window = int(headers["x-api-recv-window"])
    signature = headers["x-api-sign"]

    # 1. Check timestamp freshness
    now = int(time.time() * 1000)
    if now - int(timestamp) > recv_window:
        raise Exception("Request expired")

    # 2. Reconstruct the message
    message = f"{timestamp}{method}{url}{body}"

    # 3. Compute expected signature
    expected = hmac.new(
        secret_key.encode(),
        message.encode(),
        hashlib.sha256
    ).hexdigest()

    # 4. Constant-time comparison
    if not hmac.compare_digest(expected, signature):
        raise Exception("Invalid signature")

    return True
```

### PHP Example

```php
function verifyWebhook(array $headers, string $method, string $url, string $body, string $secretKey): bool
{
    // $body: the raw request body; $url: the full webhook endpoint URL
    $headers = array_change_key_case($headers, CASE_LOWER); // header names are case-insensitive
    $timestamp = $headers["x-api-timestamp"];
    $recvWindow = (int) $headers["x-api-recv-window"];
    $signature = $headers["x-api-sign"];

    // 1. Check timestamp freshness
    $now = (int) (microtime(true) * 1000);
    if ($now - (int) $timestamp > $recvWindow) {
        throw new \Exception("Request expired");
    }

    // 2. Reconstruct the message
    $message = $timestamp . $method . $url . $body;

    // 3. Compute expected signature
    $expected = hash_hmac("sha256", $message, $secretKey);

    // 4. Constant-time comparison
    if (!hash_equals($expected, $signature)) {
        throw new \Exception("Invalid signature");
    }

    return true;
}
```

### Important Notes

* HTTP header names are case-insensitive. Node.js exposes them lower-cased (`x-api-sign`); in other frameworks look them up case-insensitively.
* The HTTP method in the signed message is always **uppercase** (`POST`). Make sure you use the uppercase method when reconstructing the message.
* Always use a **constant-time comparison** function (`timingSafeEqual`, `hmac.compare_digest`, `hash_equals`) to prevent timing attacks.
* Make sure you're comparing the signature against the **raw request body** string, not a re-serialized version — JSON key order matters.
* The `recv_window` check protects against **replay attacks**. Reject requests where the timestamp is too far in the past.
* A retried delivery is signed again: it has the same webhook `id` in the body, but a new `X-API-TIMESTAMP` and a new `X-API-SIGN`. The freshness check therefore works for retries too; use the `id` to detect duplicates.
* Keep your **secret key** safe. Never expose it in client-side code or logs.

***

## Payment Events

Payment events notify you about the status of payment processing.

### PAYMENT\_COMPLETED

Payment completed successfully.

| Property | Value  |
| -------- | ------ |
| Priority | `HIGH` |

**Payload example:**

```json
{
  "id": "a767ea9b1f3a4f84bcf25c4a233b1f05",
  "priority": "HIGH",
  "eventType": "PAYMENT_COMPLETED",
  "data": {
    "id": "7aND5mZHW3GkRf9UbC",
    "type": "INVOICE",
    "amount": null,
    "currency": "USD",
    "payments": [
      {
        "amount": "34.0944",
        "currency": "USDTTRC",
        "creditAmount": "32.4"
      }
    ],
    "clientOrderId": "order-123"
  }
}
```

**Fields:**

| Field                                  | Description                                                                      |
| -------------------------------------- | -------------------------------------------------------------------------------- |
| `data.currency`                        | Invoice currency                                                                 |
| `data.amount`                          | **Currently `null` for all invoices.** Do not rely on it                         |
| `data.payments[]`                      | The payments received for the invoice (there can be more than one)               |
| `data.payments[].amount` / `.currency` | Amount and ticker actually paid                                                  |
| `data.payments[].creditAmount`         | Amount credited for the payment. In the example it is lower than the amount paid |

> **Planned change:** in an upcoming release `data.amount` will contain the total of all payments received for the invoice.

***

### PAYMENT\_FAILED

Payment failed to process.

| Property | Value  |
| -------- | ------ |
| Priority | `HIGH` |

**Payload example:**

```json
{
  "id": "a767ea9b1f3a4f84bcf25c4a233b1f05",
  "priority": "HIGH",
  "eventType": "PAYMENT_FAILED",
  "data": {
    "id": "7aND5mZHW3GkRf9UbC",
    "type": "INVOICE",
    "clientOrderId": "order-123"
  }
}
```

***

### PAYMENT\_PENDING

Sent when a payment address is requested for the invoice — that is, after `GET /api/v2/invoice/payment-method/{id}` has been called for one of its payment methods. No payment has been received at this point; the invoice stays `AWAITING_PAYMENT`. A repeated request for the same payment method does not send the event again.

| Property | Value |
| -------- | ----- |
| Priority | `LOW` |

**Payload example:**

```json
{
  "id": "a767ea9b1f3a4f84bcf25c4a233b1f05",
  "priority": "LOW",
  "eventType": "PAYMENT_PENDING",
  "data": {
    "id": "7aND5mZHW3GkRf9UbC",
    "type": "INVOICE",
    "clientOrderId": null
  }
}
```

***

### PAYMENT\_PARTIAL

Partial payment received (amount less than expected).

| Property | Value    |
| -------- | -------- |
| Priority | `MEDIUM` |

**Payload example:**

```json
{
  "id": "a767ea9b1f3a4f84bcf25c4a233b1f05",
  "priority": "MEDIUM",
  "eventType": "PAYMENT_PARTIAL",
  "data": {
    "id": "7aND5mZHW3GkRf9UbC",
    "type": "INVOICE",
    "clientOrderId": "order-123"
  }
}
```

***

### PAYMENT\_OVERPAID

Payment received exceeds the expected amount.

| Property | Value    |
| -------- | -------- |
| Priority | `MEDIUM` |

**Payload example:**

```json
{
  "id": "a767ea9b1f3a4f84bcf25c4a233b1f05",
  "priority": "MEDIUM",
  "eventType": "PAYMENT_OVERPAID",
  "data": {
    "id": "7aND5mZHW3GkRf9UbC",
    "type": "INVOICE",
    "clientOrderId": null
  }
}
```

***

### PAYMENT\_UNDERPAID

Payment received is less than the expected amount.

| Property | Value    |
| -------- | -------- |
| Priority | `MEDIUM` |

**Payload example:**

```json
{
  "id": "a767ea9b1f3a4f84bcf25c4a233b1f05",
  "priority": "MEDIUM",
  "eventType": "PAYMENT_UNDERPAID",
  "data": {
    "id": "7aND5mZHW3GkRf9UbC",
    "type": "INVOICE",
    "clientOrderId": "order-123"
  }
}
```

***

## Transaction Events

Transaction events provide information about blockchain transaction confirmations.

### TRANSACTION\_CONFIRMED

Transaction received first blockchain confirmation.

| Property | Value    |
| -------- | -------- |
| Priority | `MEDIUM` |

**Payload example (Deposit):**

```json
{
  "id": "a767ea9b1f3a4f84bcf25c4a233b1f05",
  "priority": "MEDIUM",
  "eventType": "TRANSACTION_CONFIRMED",
  "data": {
    "id": "7aND5mZHW3GkRf9UbC",
    "hash": "0xa1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90",
    "type": "DEPOSIT",
    "extra": null,
    "amount": "100",
    "address": "0x123...456",
    "currency": "USDTETH",
    "walletId": "3f6c1e0a9b7d4c2e8a5f0b1d2c3e4f5a",
    "confirmations": 1
  }
}
```

**Payload example (Invoice):**

```json
{
  "id": "a767ea9b1f3a4f84bcf25c4a233b1f05",
  "priority": "MEDIUM",
  "eventType": "TRANSACTION_CONFIRMED",
  "data": {
    "id": "7aND5mZHW3GkRf9UbC",
    "clientOrderId": "order-123",
    "type": "INVOICE",
    "currency": "USDTETH",
    "hash": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90",
    "address": "0x123...456",
    "extra": null,
    "amount": "100",
    "confirmations": 1
  }
}
```

***

### TRANSACTION\_FINAL

Transaction reached final confirmation and has been fully processed.

| Property | Value  |
| -------- | ------ |
| Priority | `HIGH` |

**Payload example (Invoice):**

```json
{
  "id": "a767ea9b1f3a4f84bcf25c4a233b1f05",
  "priority": "HIGH",
  "eventType": "TRANSACTION_FINAL",
  "data": {
    "id": "7aND5mZHW3GkRf9UbC",
    "clientOrderId": "order-123",
    "type": "INVOICE",
    "currency": "USDTETH",
    "hash": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90",
    "address": "0x123...456",
    "amount": "100",
    "confirmations": 12
  }
}
```

**Payload example (Deposit):**

```json
{
  "id": "a767ea9b1f3a4f84bcf25c4a233b1f05",
  "priority": "HIGH",
  "eventType": "TRANSACTION_FINAL",
  "data": {
    "id": "7aND5mZHW3GkRf9UbC",
    "hash": "0xa1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90",
    "type": "DEPOSIT",
    "address": "0x123...456",
    "currency": "USDTETH",
    "clientOrderId": null,
    "confirmations": 12
  }
}
```

`clientOrderId` is always present in this event (`null` for deposits). For a deposit the event carries no `amount` — take it from `DEPOSIT_RECEIVED` or `TRANSACTION_CONFIRMED`. The `extra` field (memo / tag) is included only for currencies that use one. The value of `confirmations` is the final confirmation count of the currency, so it differs between currencies (12 for `USDTETH`).

***

## Invoice Events

Invoice events track the lifecycle of payment invoices.

### INVOICE\_CREATED

New invoice has been created. `amount` is the invoice amount — for an invoice created from `items` without `totalPrice` it is the sum of the items.

| Property | Value |
| -------- | ----- |
| Priority | `LOW` |

**Payload example:**

```json
{
  "id": "a767ea9b1f3a4f84bcf25c4a233b1f05",
  "priority": "LOW",
  "eventType": "INVOICE_CREATED",
  "data": {
    "id": "7aND5mZHW3GkRf9UbC",
    "type": "FIAT_BASED",
    "currency": "USD",
    "amount": "100",
    "clientOrderId": "order-123",
    "createdAt": "2025-01-01T10:00:00.000Z",
    "expiredAt": "2025-01-01T18:00:00.000Z"
  }
}
```

***

### INVOICE\_EXPIRED

Invoice has expired without being paid. The webhook is sent when the invoice reaches its `expiredAt`. `expectedAmount` is a decimal string and can come with trailing zeros (for example `"10.0000000000000000000000"`).

| Property | Value    |
| -------- | -------- |
| Priority | `MEDIUM` |

**Payload example:**

```json
{
  "id": "a767ea9b1f3a4f84bcf25c4a233b1f05",
  "priority": "MEDIUM",
  "eventType": "INVOICE_EXPIRED",
  "data": {
    "id": "7aND5mZHW3GkRf9UbC",
    "type": "INVOICE",
    "currency": "USD",
    "expectedAmount": "100",
    "clientOrderId": null,
    "createdAt": "2025-01-01T10:00:00.000Z",
    "expiredAt": "2025-01-01T18:00:00.000Z"
  }
}
```

***

### INVOICE\_PAID

Invoice has been paid.

| Property | Value    |
| -------- | -------- |
| Priority | `MEDIUM` |

**Payload example:**

```json
{
  "id": "a767ea9b1f3a4f84bcf25c4a233b1f05",
  "priority": "MEDIUM",
  "eventType": "INVOICE_PAID",
  "data": {
    "id": "7aND5mZHW3GkRf9UbC",
    "type": "INVOICE",
    "clientOrderId": "order-123"
  }
}
```

***

## Compliance Events

Compliance events notify you about AML (Anti-Money Laundering) check results.

### COMPLIANCE\_FAILED

AML check failed and payment has been stopped.

| Property | Value  |
| -------- | ------ |
| Priority | `HIGH` |

**Payload example (Invoice):**

```json
{
  "id": "a767ea9b1f3a4f84bcf25c4a233b1f05",
  "priority": "HIGH",
  "eventType": "COMPLIANCE_FAILED",
  "data": {
    "id": "7aND5mZHW3GkRf9UbC",
    "type": "INVOICE",
    "clientOrderId": "order-123"
  }
}
```

**Payload example (Deposit):**

```json
{
  "id": "a767ea9b1f3a4f84bcf25c4a233b1f05",
  "priority": "HIGH",
  "eventType": "COMPLIANCE_FAILED",
  "data": {
    "id": "7aND5mZHW3GkRf9UbC",
    "type": "DEPOSIT"
  }
}
```

***

### COMPLIANCE\_PASSED

AML check passed successfully.

| Property | Value |
| -------- | ----- |
| Priority | `LOW` |

**Payload example (Deposit):**

```json
{
  "id": "a767ea9b1f3a4f84bcf25c4a233b1f05",
  "priority": "LOW",
  "eventType": "COMPLIANCE_PASSED",
  "data": {
    "id": "7aND5mZHW3GkRf9UbC",
    "type": "DEPOSIT",
    "status": "SUCCESSFUL"
  }
}
```

**Payload example (Invoice):**

```json
{
  "id": "a767ea9b1f3a4f84bcf25c4a233b1f05",
  "priority": "LOW",
  "eventType": "COMPLIANCE_PASSED",
  "data": {
    "id": "7aND5mZHW3GkRf9UbC",
    "type": "INVOICE",
    "clientOrderId": null
  }
}
```

***

## System Events

System events provide notifications about account balance changes, deposits, and withdrawals.

### BALANCE\_UPDATED

Account balance has been updated by an operation — for example a deposit credited, or a payout created and completed.

| Property | Value |
| -------- | ----- |
| Priority | `LOW` |

**Payload example (deposit credited):**

```json
{
  "id": "a767ea9b1f3a4f84bcf25c4a233b1f05",
  "priority": "LOW",
  "eventType": "BALANCE_UPDATED",
  "data": {
    "id": "7aND5mZHW3GkRf9UbC",
    "type": "DEPOSIT",
    "amount": "99",
    "status": "SUCCESS",
    "balance": "199",
    "currency": "USDTETH",
    "walletId": "3f6c1e0a9b7d4c2e8a5f0b1d2c3e4f5a",
    "holdBalance": "0"
  }
}
```

| Field                              | Description                                                                                                                     |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `data.id`                          | ID of the operation that changed the balance (18 characters)                                                                    |
| `data.type`                        | `DEPOSIT`, `WITHDRAWAL` (also used for payouts) or `INVOICE`                                                                    |
| `data.status`                      | `PROCESSING` or `SUCCESS`. Note the spelling: `SUCCESS`, not `SUCCESSFUL`                                                       |
| `data.amount`                      | Amount applied to the balance. For a deposit it is the credited amount — lower than the amount in `DEPOSIT_RECEIVED` by the fee |
| `data.balance`, `data.holdBalance` | Balance and held balance of the currency after the change                                                                       |
| `data.walletId`                    | Wallet ID (32 characters)                                                                                                       |

For a payout (see [Mass Payouts](https://paykilla.gitbook.io/paykilla-docs/api-integration/mass-payouts)) the event is sent twice with `data.type: "WITHDRAWAL"` and the ID of the payout batch in `data.id`: with `status: "PROCESSING"` when the payout is created (the amount is on hold, `holdBalance` is not zero) and with `status: "SUCCESS"` when it is completed.

The amounts in the example are sample values.

***

### DEPOSIT\_RECEIVED

A transfer to one of your addresses has been detected. It is the first event of a deposit; the amount is credited to your balance later (see `BALANCE_UPDATED`).

| Property | Value |
| -------- | ----- |
| Priority | `LOW` |

**Payload example (deposit to a static address):**

```json
{
  "id": "a767ea9b1f3a4f84bcf25c4a233b1f05",
  "priority": "LOW",
  "eventType": "DEPOSIT_RECEIVED",
  "data": {
    "id": "7aND5mZHW3GkRf9UbC",
    "hash": "0xa1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90",
    "type": "DEPOSIT",
    "extra": null,
    "amount": "100",
    "address": "0x123...456",
    "currency": "USDTETH",
    "walletId": "3f6c1e0a9b7d4c2e8a5f0b1d2c3e4f5a",
    "customerId": "customer-10452"
  }
}
```

| Field                        | Description                                                                                                                                                                       |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data.id`                    | Deposit ID (18 characters) — the same in all events of this deposit; use it with `GET /api/v2/operation/deposit/{id}`                                                             |
| `data.hash`                  | Blockchain transaction hash                                                                                                                                                       |
| `data.address`, `data.extra` | Address the transfer came to, and its memo / tag                                                                                                                                  |
| `data.amount`                | Amount received, before the fee                                                                                                                                                   |
| `data.walletId`              | Wallet ID (32 characters)                                                                                                                                                         |
| `data.customerId`            | For a deposit to a [static address](https://paykilla.gitbook.io/paykilla-docs/api-integration/static-addresses): the `customerId` of that address — this is how you know who paid |

***

### WITHDRAWAL\_INITIATED

Sent when the withdrawal is created. `data.id` is the withdrawal ID — the create request itself returns an empty body, so this webhook is one of the two ways to learn the ID (see [Withdrawal](https://paykilla.gitbook.io/paykilla-docs/api-integration/withdrawal)).

| Property | Value    |
| -------- | -------- |
| Priority | `MEDIUM` |

**Payload example:**

```json
{
  "id": "a767ea9b1f3a4f84bcf25c4a233b1f05",
  "priority": "MEDIUM",
  "eventType": "WITHDRAWAL_INITIATED",
  "data": {
    "id": "7aND5mZHW3GkRf9UbC",
    "type": "WITHDRAWAL",
    "currency": "TRX",
    "amount": "100",
    "address": "TXYZabcdefghijklmnopqrstuvwxyz1234",
    "createdAt": "2025-01-01T10:00:00.000Z"
  }
}
```

***

### WITHDRAWAL\_COMPLETED

Withdrawal has completed successfully (sent after the transaction is confirmed on the blockchain).

| Property | Value  |
| -------- | ------ |
| Priority | `HIGH` |

**Payload example:**

```json
{
  "id": "a767ea9b1f3a4f84bcf25c4a233b1f05",
  "priority": "HIGH",
  "eventType": "WITHDRAWAL_COMPLETED",
  "data": {
    "id": "7aND5mZHW3GkRf9UbC",
    "type": "WITHDRAWAL",
    "currency": "BTC",
    "hash": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90",
    "address": "bc1qyourrecipientaddressxxxxxxxxxxxxxxxxxx",
    "amount": "0.07519918"
  }
}
```

***

## Event Summary

### All Events by Category

| Category        | Event                             | Priority | Description                                                                    |
| --------------- | --------------------------------- | -------- | ------------------------------------------------------------------------------ |
| **Payment**     | `PAYMENT_COMPLETED`               | HIGH     | Payment completed successfully                                                 |
|                 | `PAYMENT_FAILED`                  | HIGH     | Payment failed                                                                 |
|                 | `PAYMENT_PENDING`                 | LOW      | Payment address requested, payment awaited                                     |
|                 | `PAYMENT_PARTIAL`                 | MEDIUM   | Partial payment received                                                       |
|                 | `PAYMENT_OVERPAID`                | MEDIUM   | Overpayment received                                                           |
|                 | `PAYMENT_UNDERPAID`               | MEDIUM   | Underpayment received                                                          |
| **Transaction** | `TRANSACTION_CONFIRMED`           | MEDIUM   | First blockchain confirmation                                                  |
|                 | `TRANSACTION_FINAL`               | HIGH     | Final confirmation reached                                                     |
| **Invoice**     | `INVOICE_CREATED`                 | LOW      | Invoice created                                                                |
|                 | `INVOICE_EXPIRED`                 | MEDIUM   | Invoice expired                                                                |
|                 | `INVOICE_PAID`                    | MEDIUM   | Invoice paid                                                                   |
| **Compliance**  | `COMPLIANCE_FAILED`               | HIGH     | AML check failed — payment stopped                                             |
|                 | `COMPLIANCE_PASSED`               | LOW      | AML check passed                                                               |
|                 | `COMPLIANCE_FLAGGED`              | —        | Manual check required                                                          |
| **System**      | `BALANCE_UPDATED`                 | LOW      | Balance updated                                                                |
|                 | `DEPOSIT_RECEIVED`                | LOW      | Deposit received                                                               |
|                 | `WITHDRAWAL_INITIATED`            | MEDIUM   | Withdrawal initiated                                                           |
|                 | `WITHDRAWAL_COMPLETED`            | HIGH     | Withdrawal completed                                                           |
| **Conversion**  | `CONVERSION_COMPLETED`            | —        | Auto-conversion completed successfully                                         |
|                 | `CONVERSION_FAILED`               | —        | Auto-conversion failed                                                         |
|                 | `CONVERSION_CURRENCY_UNAVAILABLE` | —        | The target currency of an auto-conversion rule became unavailable for exchange |

The current list of events is also returned by `GET /api/v2/user/webhook`.

Route `COMPLIANCE_FLAGGED` and the three `CONVERSION_*` events by `eventType` and treat their `data` as an opaque object.

***

## Best Practices

### Handling Webhooks

1. **Always verify signatures** before processing any webhook
2. **Respond quickly** with HTTP 200 to acknowledge receipt
3. **Process asynchronously** to avoid timeout issues
4. **Implement idempotency** using the webhook `id` field
5. **Log all webhooks** for debugging and audit purposes

### Recommended Event Handling

```typescript
async function handleWebhookEvent(event: WebhookEvent<{ id: string }>) {
  // Log the event
  console.log(`Received webhook: ${event.eventType}`, event.id)

  // Check for duplicate processing
  if (await isEventProcessed(event.id)) {
    console.log(`Event ${event.id} already processed, skipping`)
    return
  }

  // Process based on priority
  switch (event.priority) {
    case "HIGH":
      await processHighPriorityEvent(event)
      break
    case "MEDIUM":
      await queueForProcessing(event)
      break
    case "LOW":
      await processAsync(event)
      break
  }

  // Mark as processed
  await markEventProcessed(event.id)
}

async function processHighPriorityEvent(event: WebhookEvent<{ id: string }>) {
  switch (event.eventType) {
    case "PAYMENT_COMPLETED":
      await fulfillOrder(event.data.id)
      await sendConfirmationEmail(event.data)
      break
    case "PAYMENT_FAILED":
      await handleFailedPayment(event.data.id)
      await notifyCustomer(event.data)
      break
    case "COMPLIANCE_FAILED":
      await flagTransaction(event.data.id)
      await alertComplianceTeam(event.data)
      break
    case "WITHDRAWAL_COMPLETED":
      await updateWithdrawalStatus(event.data.id, "completed")
      break
    case "TRANSACTION_FINAL":
      await finalizeTransaction(event.data)
      break
  }
}
```

### Error Handling

```typescript
import { createHmac, timingSafeEqual } from "crypto"

const SECRET_KEY = process.env.PAYKILLA_V2_SECRET_KEY!
const WEBHOOK_URL = "https://your-app.com/webhooks/paykilla"

function verifySignature(
  timestamp: string,
  recvWindow: number,
  signature: string,
  body: string
): boolean {
  // Check timestamp freshness
  if (Date.now() - parseInt(timestamp, 10) > recvWindow) {
    return false
  }

  // Verify HMAC signature
  const message = `${timestamp}POST${WEBHOOK_URL}${body}`
  const expected = createHmac("sha256", SECRET_KEY).update(message).digest("hex")
  
  // timingSafeEqual throws on buffers of different length, so compare the length first
  const expectedBuf = Buffer.from(expected, "hex")
  const signatureBuf = Buffer.from(signature, "hex")
  return expectedBuf.length === signatureBuf.length && timingSafeEqual(expectedBuf, signatureBuf)
}

export async function POST(request: Request) {
  try {
    const timestamp = request.headers.get("X-API-TIMESTAMP")
    const recvWindow = parseInt(request.headers.get("X-API-RECV-WINDOW") || "5000", 10)
    const signature = request.headers.get("X-API-SIGN")
    const payload = await request.text()

    if (!timestamp || !signature || !verifySignature(timestamp, recvWindow, signature, payload)) {
      console.error("Invalid webhook signature")
      return new Response("Unauthorized", { status: 401 })
    }

    const event = JSON.parse(payload)
    
    // Queue for async processing
    await queueWebhookEvent(event)

    // Respond immediately
    return new Response("OK", { status: 200 })
  } catch (error) {
    console.error("Webhook processing error:", error)
    // Return 500 to trigger retry
    return new Response("Internal Server Error", { status: 500 })
  }
}
```

***

## Troubleshooting

### Webhooks Not Received

1. Verify your endpoint URL is correct in the dashboard
2. Ensure your endpoint is publicly accessible
3. Check that HTTPS is properly configured
4. Review firewall rules for incoming connections

### Invalid Signature Errors

1. Ensure you're using the secret key of the API key that is attached to the webhook endpoint in the dashboard (compare it with the public key in the `X-API-KEY` header of the delivery)
2. Verify you're reading the raw request body (not parsed JSON)
3. Check that no middleware is modifying the request body
4. Make sure you're using the full webhook URL in the message (scheme, host and path), not just the request path
5. Verify the HTTP method is uppercase (`POST`)
6. Check that the timestamp, method, URL, and body are concatenated in the correct order
7. Use constant-time comparison to prevent timing attacks

### Request Expired Errors

If you're receiving "Request expired" errors:

1. Check that your server clock is synchronized (use NTP)
2. Verify the `X-API-RECV-WINDOW` header value is sufficient
3. Ensure there's no significant network latency between PayKilla and your server

### Duplicate Events

Implement idempotency using the webhook `id` field:

```typescript
const processedEvents = new Set<string>()

function isEventProcessed(eventId: string): boolean {
  return processedEvents.has(eventId)
}

function markEventProcessed(eventId: string): void {
  processedEvents.add(eventId)
  // In production, store in database
}
```

***

## Support

If you have any questions about webhooks, please contact PayKilla support.
