Paytrie Developer Documentation

Webhooks

Receive real-time notifications for user and transaction events

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?

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, 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 call and set every enabled flag to false:

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

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 typeTrigger
user.updateA user's status changes (e.g. completes KYC verification)
transaction.updateA transaction's status changes (created, processing, complete, ...)

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

v2 sends every event to a single webhook URL. Use one endpoint that handles both event types (branch on the envelope type field).

Create or update webhook configuration

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.

{
  "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_..."
  }
}

API Reference: Create or replace webhook configuration

View complete request parameters and response schema

Get current webhook configuration

curl "https://api.paytrie.com/v2/webhook-configurations" \
  -H "x-api-key: your-api-key"
{
  "success": true,
  "data": {
    "url": "https://your-server.com/webhooks",
    "createdAt": "2026-05-19T20:41:09.000Z",
    "updatedAt": "2026-05-19T20:41:09.000Z"
  }
}

API Reference: Get webhook configuration

Get the current webhook configuration

Send a test webhook

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

curl -X POST "https://api.paytrie.com/v2/webhook-configurations/test" \
  -H "x-api-key: your-api-key"
{
  "success": true,
  "data": {
    "eventId": "b3f1c2d4-5678-4abc-9012-3456789abcde",
    "deliveredStatusCode": 200,
    "deliveredStatusText": "OK"
  }
}

API Reference: Send a test webhook

View complete request parameters and response schema

Remove webhook configuration

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

Returns 204 No Content on success.

API Reference: Remove webhook configuration

View complete request parameters and response schema

Your webhook endpoint must be publicly accessible via HTTPS and respond with a 2xx status code to acknowledge receipt.

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.

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

User update

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

{
  "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

Triggered whenever a transaction status changes. See Transaction statuses for all possible values.

{
  "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

Envelope

Every webhook shares these top-level fields:

FieldTypeDescription
idstringUnique ID for this event — use it as an idempotency key
apiVersionstringAlways "v2"
occurredAtstringISO 8601 timestamp of when the event occurred
typestring"user.update" or "transaction.update"
payloadobjectThe event-specific payload (see below)

user.update payload

FieldTypeDescription
userIdstringThe user's unique ID
emailstringThe user's registered email address
statusstringThe user's status: initial, active, verified, rejected, inactive, upload, in-review

transaction.update payload

FieldTypeDescription
transactionIdstringThe unique transaction ID
statusstringThe current transaction status
emailstringThe user's registered email address
walletstringThe user's wallet address for the transaction
paymentIdstring | nullThe blockchain transaction hash (null if not yet on chain)
leftSideLabelstringThe currency being sent (e.g. "CAD" for buy, "USDC-ETH" for sell)
leftSideValuenumberThe amount being sent
rightSideLabelstringThe currency being received (e.g. "USDC-ETH" for buy, "CAD" for sell)
rightSideValuenumberThe amount being received
interacSecurityAnswerstring | nullThe Interac e-Transfer security answer (null if autodeposit is enabled)
externalSessionIdstring | nullYour custom session identifier passed when creating the transaction

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:

HeaderDescription
X-Paytrie-TimestampUnix timestamp (seconds) when the webhook was sent
X-Paytrie-SignatureHMAC-SHA256 signature in format v1=<signature>

The signature is computed as:

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

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:

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

Response:

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

Store your signing secret securely. It is only shown when first created or rotated. If you lose it, rotate to a new secret.

API Reference: Rotate signing secret

View complete request parameters and response schema

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.

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.

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');
});
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
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)
}
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

Always use constant-time comparison functions (like crypto.timingSafeEqual or hmac.compare_digest) to prevent timing attacks.

Preventing replay attacks

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

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

Respond quickly

Return a 2xx response immediately, then process the webhook asynchronously

Verify signatures

Always verify webhook signatures before processing to ensure authenticity

Check timestamps

Reject webhooks with timestamps too far in the past to prevent replay attacks

Deduplicate events

Use the envelope id to ignore events you have already processed

Webhooks are sent once and not retried. Ensure your endpoint is reliable and returns a 2xx status code promptly.

On this page