# Webhooks



Webhooks allow you to receive real-time notifications when events occur in your Paytrie integration. Instead of polling the API for updates, webhooks push event data to your server as events happen.

## Already using v1 webhooks? [#already-using-v1-webhooks]

You can adopt v2 webhooks without interrupting your existing v1 integration. Setting up v2 does **not** disable v1, so both fire in parallel while you migrate.

Move over before the [sunset date](/v2/migration-guide#deprecation-timeline), then turn off v1 yourself:

1. Register your v2 webhook URL with `PUT /v2/webhook-configurations` (see below).
2. Confirm v2 events are arriving at your endpoint.
3. Once you're confident, turn off v1 yourself.

To turn off v1, you don't need a new endpoint — use the existing v1 [`PATCH /webhooks`](/docs/webhooks#update-webhook-configuration) call and set every enabled flag to `false`:

```bash
curl -X PATCH "https://api.paytrie.com/webhooks" \
  -H "x-api-key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "verifiedEmailEnabled": false,
    "transactionInitiatedEnabled": false,
    "transactionCompleteEnabled": false,
    "transactionStatusUpdateEnabled": false
  }'
```

## Available events [#available-events]

v2 delivers two event types. Each carries the current `status` of the resource, so a single event type covers the full lifecycle (created → processing → complete, etc.).

| Event type           | Trigger                                                             |
| -------------------- | ------------------------------------------------------------------- |
| `user.update`        | A user's status changes (e.g. completes KYC verification)           |
| `transaction.update` | A transaction's status changes (created, processing, complete, ...) |

## Setting up webhooks [#setting-up-webhooks]

To receive webhooks, you need to:

1. Create a POST endpoint on your server to receive webhook payloads
2. Register your webhook URL using the API (see below)
3. Verify requests using your signing secret

<Callout type="info">
  v2 sends every event to a single webhook URL. Use one endpoint that handles
  both event types (branch on the envelope `type` field).
</Callout>

### Create or update webhook configuration [#create-or-update-webhook-configuration]

```bash
curl -X PUT "https://api.paytrie.com/v2/webhook-configurations" \
  -H "x-api-key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://your-server.com/webhooks" }'
```

The `signingSecret` is returned **only on the first PUT** for an integrator that does not yet have one. Store it securely — to get a new one later, use the rotate endpoint.

```json
{
  "success": true,
  "data": {
    "url": "https://your-server.com/webhooks",
    "createdAt": "2026-05-19T20:41:09.000Z",
    "updatedAt": "2026-05-19T20:41:09.000Z",
    "signingSecret": "whsec_..."
  }
}
```

<Card title="API Reference: Create or replace webhook configuration" href="/v2/api-reference/webhookconfiguration/setWebhookConfiguration" icon="arrow-right-left">
  View complete request parameters and response schema
</Card>

### Get current webhook configuration [#get-current-webhook-configuration]

```bash
curl "https://api.paytrie.com/v2/webhook-configurations" \
  -H "x-api-key: your-api-key"
```

```json
{
  "success": true,
  "data": {
    "url": "https://your-server.com/webhooks",
    "createdAt": "2026-05-19T20:41:09.000Z",
    "updatedAt": "2026-05-19T20:41:09.000Z"
  }
}
```

<Card title="API Reference: Get webhook configuration" href="/v2/api-reference/webhookconfiguration/getWebhookConfiguration" icon="arrow-right-left">
  Get the current webhook configuration
</Card>

### Send a test webhook [#send-a-test-webhook]

Dispatch a synthetic event to your configured URL to confirm your endpoint is reachable and your signature verification works.

```bash
curl -X POST "https://api.paytrie.com/v2/webhook-configurations/test" \
  -H "x-api-key: your-api-key"
```

```json
{
  "success": true,
  "data": {
    "eventId": "b3f1c2d4-5678-4abc-9012-3456789abcde",
    "deliveredStatusCode": 200,
    "deliveredStatusText": "OK"
  }
}
```

<Card title="API Reference: Send a test webhook" href="/v2/api-reference/webhookconfiguration/sendTestWebhook" icon="arrow-right-left">
  View complete request parameters and response schema
</Card>

### Remove webhook configuration [#remove-webhook-configuration]

```bash
curl -X DELETE "https://api.paytrie.com/v2/webhook-configurations" \
  -H "x-api-key: your-api-key"
```

Returns `204 No Content` on success.

<Card title="API Reference: Remove webhook configuration" href="/v2/api-reference/webhookconfiguration/deleteWebhookConfiguration" icon="arrow-right-left">
  View complete request parameters and response schema
</Card>

<Callout type="info">
  Your webhook endpoint must be publicly accessible via HTTPS and respond with a
  2xx status code to acknowledge receipt.
</Callout>

## Webhook payloads [#webhook-payloads]

All webhooks are sent as POST requests with a JSON body. Every event shares a common envelope; the event-specific data lives under `payload`.

```json
{
  "id": "b3f1c2d4-5678-4abc-9012-3456789abcde",
  "apiVersion": "v2",
  "occurredAt": "2026-05-19T20:41:09.000Z",
  "type": "transaction.update",
  "payload": {}
}
```

### User update [#user-update]

Triggered when a user's status changes — for example, when they complete KYC and become `verified`.

```json
{
  "id": "b3f1c2d4-5678-4abc-9012-3456789abcde",
  "apiVersion": "v2",
  "occurredAt": "2026-05-19T20:41:09.000Z",
  "type": "user.update",
  "payload": {
    "userId": "550e8400-e29b-41d4-a716-446655440000",
    "email": "user@example.com",
    "status": "verified"
  }
}
```

### Transaction update [#transaction-update]

Triggered whenever a transaction status changes. See [Transaction statuses](/v2/transactions) for all possible values.

```json
{
  "eventId": "c4a2d3e5-6789-4bcd-a123-456789abcdef",
  "apiVersion": "v2",
  "occurredAt": "2026-05-19T20:41:09.000Z",
  "type": "transaction.update",
  "payload": {
    "transactionId": "3943bb00-1551-4f1d-bf32-2d82608bc15e",
    "status": "complete",
    "email": "user@example.com",
    "wallet": "0x1234567890abcdef1234567890abcdef12345678",
    "paymentId": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
    "leftSideLabel": "CAD",
    "leftSideValue": 100.0,
    "rightSideLabel": "USDC-ETH",
    "rightSideValue": 72.5,
    "interacSecurityAnswer": null,
    "externalSessionId": "partner-session-abc123"
  }
}
```

## Payload fields [#payload-fields]

### Envelope [#envelope]

Every webhook shares these top-level fields:

| Field        | Type   | Description                                             |
| ------------ | ------ | ------------------------------------------------------- |
| `id`         | string | Unique ID for this event — use it as an idempotency key |
| `apiVersion` | string | Always `"v2"`                                           |
| `occurredAt` | string | ISO 8601 timestamp of when the event occurred           |
| `type`       | string | `"user.update"` or `"transaction.update"`               |
| `payload`    | object | The event-specific payload (see below)                  |

### `user.update` payload [#userupdate-payload]

| Field    | Type   | Description                                                                                       |
| -------- | ------ | ------------------------------------------------------------------------------------------------- |
| `userId` | string | The user's unique ID                                                                              |
| `email`  | string | The user's registered email address                                                               |
| `status` | string | The user's status: `initial`, `active`, `verified`, `rejected`, `inactive`, `upload`, `in-review` |

### `transaction.update` payload [#transactionupdate-payload]

| Field                   | Type           | Description                                                               |
| ----------------------- | -------------- | ------------------------------------------------------------------------- |
| `transactionId`         | string         | The unique transaction ID                                                 |
| `status`                | string         | The current transaction status                                            |
| `email`                 | string         | The user's registered email address                                       |
| `wallet`                | string         | The user's wallet address for the transaction                             |
| `paymentId`             | string \| null | The blockchain transaction hash (null if not yet on chain)                |
| `leftSideLabel`         | string         | The currency being sent (e.g. `"CAD"` for buy, `"USDC-ETH"` for sell)     |
| `leftSideValue`         | number         | The amount being sent                                                     |
| `rightSideLabel`        | string         | The currency being received (e.g. `"USDC-ETH"` for buy, `"CAD"` for sell) |
| `rightSideValue`        | number         | The amount being received                                                 |
| `interacSecurityAnswer` | string \| null | The Interac e-Transfer security answer (null if autodeposit is enabled)   |
| `externalSessionId`     | string \| null | Your custom session identifier passed when creating the transaction       |

## Webhook security/verification [#webhook-securityverification]

All webhooks are signed with a secret key unique to your integration. This allows you to verify that webhooks are genuinely from Paytrie and secure.

### How it works [#how-it-works]

Each webhook request includes two headers:

| Header                | Description                                        |
| --------------------- | -------------------------------------------------- |
| `X-Paytrie-Timestamp` | Unix timestamp (seconds) when the webhook was sent |
| `X-Paytrie-Signature` | HMAC-SHA256 signature in format `v1=<signature>`   |

The signature is computed as:

```
HMAC-SHA256(signing_secret, timestamp + "." + payload)
```

### Getting your signing secret [#getting-your-signing-secret]

Your signing secret is returned the first time you create a webhook configuration with `PUT /v2/webhook-configurations`. To rotate it later, call the rotate endpoint:

```bash
curl -X POST "https://api.paytrie.com/v2/webhook-configurations/signing-secret/rotate" \
  -H "x-api-key: your-api-key"
```

Response:

```json
{
  "success": true,
  "data": {
    "signingSecret": "whsec_..."
  }
}
```

<Callout type="warn">
  Store your signing secret securely. It is only shown when first created or
  rotated. If you lose it, rotate to a new secret.
</Callout>

<Card title="API Reference: Rotate signing secret" href="/v2/api-reference/webhookconfiguration/rotateWebhookSigningSecret" icon="arrow-right-left">
  View complete request parameters and response schema
</Card>

### Verifying webhook signatures [#verifying-webhook-signatures]

When you receive a webhook, verify its authenticity by computing the expected signature and comparing it to the one in the header.

<Callout type="warn">
  Always use the raw request body when verifying signatures. The cryptographic signature is sensitive to even the slightest change—if your framework parses the JSON and then re-stringifies it, verification will fail. The examples below show the correct approach: `express.raw()` with `req.body.toString()` in JS/TS, `request.get_data(as_text=True)` in Python, `io.ReadAll(r.Body)` in Go, and `request.raw_post` in Rails.
</Callout>

<Tabs items="['Node.js', 'Python', 'Go', 'Ruby']">
  <Tab value="Node.js">
    ```typescript
    import crypto from 'crypto';

    function verifyWebhookSignature(
      payload: string,
      signature: string,
      timestamp: string,
      secret: string
    ): boolean {
      const expectedSignature = crypto
        .createHmac('sha256', secret)
        .update(`${timestamp}.${payload}`)
        .digest('hex');

      // Remove 'v1=' prefix from signature
      const receivedSignature = signature.replace('v1=', '');

      return crypto.timingSafeEqual(
        Buffer.from(expectedSignature),
        Buffer.from(receivedSignature)
      );
    }

    app.post('/webhooks/paytrie', express.raw({ type: 'application/json' }), (req, res) => {
      const signature = req.headers['x-paytrie-signature'];
      const timestamp = req.headers['x-paytrie-timestamp'];
      const payload = req.body.toString();

      if (!verifyWebhookSignature(payload, signature, timestamp, process.env.PAYTRIE_WEBHOOK_SECRET)) {
        return res.status(401).send('Invalid signature');
      }

      // Process the webhook
      const event = JSON.parse(payload);
      console.log('Received webhook:', event);

      res.status(200).send('OK');
    });
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import hmac
    import hashlib
    from flask import Flask, request

    def verify_webhook_signature(payload: str, signature: str, timestamp: str, secret: str) -> bool:
        expected_signature = hmac.new(
            secret.encode(),
            f"{timestamp}.{payload}".encode(),
            hashlib.sha256
        ).hexdigest()

        # Remove 'v1=' prefix from signature
        received_signature = signature.replace('v1=', '')

        return hmac.compare_digest(expected_signature, received_signature)

    @app.route('/webhooks/paytrie', methods=['POST'])
    def handle_webhook():
        signature = request.headers.get('X-Paytrie-Signature')
        timestamp = request.headers.get('X-Paytrie-Timestamp')
        payload = request.get_data(as_text=True)

        if not verify_webhook_signature(payload, signature, timestamp, PAYTRIE_WEBHOOK_SECRET):
            return 'Invalid signature', 401

        # Process the webhook
        event = request.get_json()
        print('Received webhook:', event)

        return 'OK', 200
    ```
  </Tab>

  <Tab value="Go">
    ```go
    package main

    import (
    	"crypto/hmac"
    	"crypto/sha256"
    	"encoding/hex"
    	"io"
    	"net/http"
    	"strings"
    )

    func verifyWebhookSignature(payload, signature, timestamp, secret string) bool {
    	mac := hmac.New(sha256.New, []byte(secret))
    	mac.Write([]byte(timestamp + "." + payload))
    	expectedSignature := hex.EncodeToString(mac.Sum(nil))

    	// Remove 'v1=' prefix from signature
    	receivedSignature := strings.TrimPrefix(signature, "v1=")

    	return hmac.Equal([]byte(expectedSignature), []byte(receivedSignature))
    }

    func webhookHandler(w http.ResponseWriter, r *http.Request) {
    	signature := r.Header.Get("X-Paytrie-Signature")
    	timestamp := r.Header.Get("X-Paytrie-Timestamp")

    	body, _ := io.ReadAll(r.Body)
    	payload := string(body)

    	if !verifyWebhookSignature(payload, signature, timestamp, paytrieWebhookSecret) {
    		http.Error(w, "Invalid signature", http.StatusUnauthorized)
    		return
    	}

    	// Process the webhook
    	w.WriteHeader(http.StatusOK)
    }
    ```
  </Tab>

  <Tab value="Ruby">
    ```ruby
    require 'openssl'

    def verify_webhook_signature(payload, signature, timestamp, secret)
      expected_signature = OpenSSL::HMAC.hexdigest('sha256', secret, "#{timestamp}.#{payload}")

      # Remove 'v1=' prefix from signature
      received_signature = signature.sub('v1=', '')

      ActiveSupport::SecurityUtils.secure_compare(expected_signature, received_signature)
    end

    # Rails example
    class WebhooksController < ApplicationController
      skip_before_action :verify_authenticity_token

      def paytrie
        signature = request.headers['X-Paytrie-Signature']
        timestamp = request.headers['X-Paytrie-Timestamp']
        payload = request.raw_post

        unless verify_webhook_signature(payload, signature, timestamp, ENV['PAYTRIE_WEBHOOK_SECRET'])
          return head :unauthorized
        end

        # Process the webhook
        event = JSON.parse(payload)
        Rails.logger.info("Received webhook: #{event}")

        head :ok
      end
    end
    ```
  </Tab>
</Tabs>

<Callout type="info">
  Always use constant-time comparison functions (like `crypto.timingSafeEqual` or `hmac.compare_digest`) to prevent timing attacks.
</Callout>

### Preventing replay attacks [#preventing-replay-attacks]

To protect against replay attacks, check that the timestamp is recent:

```typescript
// TypeScript example
const WEBHOOK_TOLERANCE_SECONDS = 300; // 5 minutes

function isTimestampValid(timestamp: string): boolean {
  const webhookTime = parseInt(timestamp, 10);
  const currentTime = Math.floor(Date.now() / 1000);
  return Math.abs(currentTime - webhookTime) <= WEBHOOK_TOLERANCE_SECONDS;
}
```

## Best practices [#best-practices]

<Cards>
  <Card title="Respond quickly" icon="zap">
    Return a 2xx response immediately, then process the webhook asynchronously
  </Card>

  <Card title="Verify signatures" icon="shield">
    Always verify webhook signatures before processing to ensure authenticity
  </Card>

  <Card title="Check timestamps" icon="clock">
    Reject webhooks with timestamps too far in the past to prevent replay attacks
  </Card>

  <Card title="Deduplicate events" icon="copy">
    Use the envelope `id` to ignore events you have already processed
  </Card>
</Cards>

<Callout type="warn">
  Webhooks are sent once and not retried. Ensure your endpoint is reliable and returns a 2xx status code promptly.
</Callout>
