# 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.

## Available webhooks

| Webhook                       | Trigger                                |
| ----------------------------- | -------------------------------------- |
| **User Verified**             | When a user completes KYC verification |
| **Transaction Initiated**     | When a new transaction is created      |
| **Transaction Complete**      | When a transaction finishes processing |
| **Transaction Status Update** | When a transaction status changes      |

## Setting up webhooks

To receive webhooks, you need to:

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

You can configure separate URLs for each webhook type, or use a single endpoint that handles all webhook types.

### Get current webhook configuration

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

<Card title="API Reference: Get Webhooks" href="/docs/api-reference/webhooks/getWebhooks" icon="arrow-right-left">
  View complete request parameters and response schema
</Card>

### Update webhook configuration

```bash
curl -X PATCH "https://api.paytrie.com/webhooks" \
  -H "x-api-key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "verifiedEmailEnabled": true,
    "verifiedEmailUrl": "https://your-server.com/webhooks/user-verified",
    "transactionInitiatedEnabled": true,
    "transactionInitiatedUrl": "https://your-server.com/webhooks/tx-initiated",
    "transactionCompleteEnabled": true,
    "transactionCompleteUrl": "https://your-server.com/webhooks/tx-complete",
    "transactionStatusUpdateEnabled": true,
    "transactionStatusUpdateUrl": "https://your-server.com/webhooks/tx-status"
  }'
```

<Card title="API Reference: Update Webhooks" href="/docs/api-reference/webhooks/patchWebhooks" icon="arrow-right-left">
  View complete request parameters and response schema
</Card>

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

## Webhook payloads

All webhooks are sent as POST requests with a JSON body.

### User verified

Triggered when a user completes KYC verification and is ready to transact.

```json
{
  "email": "user@example.com",
  "status": "verified"
}
```

### Transaction initiated

Triggered when a new transaction is created.

```json
{
  "email": "user@example.com",
  "txId": "3943bb00-1551-4f1d-bf32-2d82608bc15e",
  "status": "pending request money transfer",
  "wallet": "0x1234567890abcdef1234567890abcdef12345678",
  "paymentId": null,
  "leftSideLabel": "CAD",
  "leftSideValue": 100.00,
  "rightSideLabel": "USDC-ETH",
  "rightSideValue": 72.50,
  "interacSecurityAnswer": null,
  "externalSessionId": "partner-session-abc123"
}
```

### Transaction complete

Triggered when a transaction completes successfully.

```json
{
  "email": "user@example.com",
  "txId": "3943bb00-1551-4f1d-bf32-2d82608bc15e",
  "status": "complete",
  "wallet": "0x1234567890abcdef1234567890abcdef12345678",
  "paymentId": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
  "leftSideLabel": "CAD",
  "leftSideValue": 100.00,
  "rightSideLabel": "USDC-ETH",
  "rightSideValue": 72.50,
  "interacSecurityAnswer": "maple2024",
  "externalSessionId": "partner-session-abc123"
}
```

### Transaction status update

Triggered whenever a transaction status changes. This provides more granular tracking than the initiated/complete webhooks. See [Transaction statuses](/docs/transactions#transaction-statuses) for all possible values.

```json
{
  "email": "user@example.com",
  "txId": "3943bb00-1551-4f1d-bf32-2d82608bc15e",
  "status": "processing request money transfer",
  "wallet": "0x1234567890abcdef1234567890abcdef12345678",
  "paymentId": null,
  "leftSideLabel": "CAD",
  "leftSideValue": 100.00,
  "rightSideLabel": "USDC-ETH",
  "rightSideValue": 72.50,
  "interacSecurityAnswer": null,
  "externalSessionId": "partner-session-abc123"
}
```

## Payload fields

### User verified webhook

| Field    | Type   | Description                            |
| -------- | ------ | -------------------------------------- |
| `email`  | string | The user's registered email address    |
| `status` | string | The verification status (`"verified"`) |

### Transaction webhooks

The following fields are included in all transaction webhooks (Initiated, Complete, Status Update):

| Field                   | Type           | Description                                                                                |
| ----------------------- | -------------- | ------------------------------------------------------------------------------------------ |
| `email`                 | string         | The user's registered email address                                                        |
| `txId`                  | string         | The unique transaction ID                                                                  |
| `status`                | string         | The current transaction status                                                             |
| `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 (null if not provided) |

## Webhook security/verification

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

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)
```

### Generating your signing secret

To generate or rotate your webhook signing secret, call the signing secret endpoint with your api key:

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

Response:

```json
{
  "status": "success",
  "signingSecret": "whsec_..."
}
```

<Callout type="warn">
  Store your signing secret securely. It will only be shown once when generated. If you lose it, you'll need to rotate to a new secret.
</Callout>

### 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

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

<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="Log payloads" icon="file-text">
    Log all webhook payloads for debugging and auditing
  </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>
