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:
- Register your v2 webhook URL with
PUT /v2/webhook-configurations(see below). - Confirm v2 events are arriving at your endpoint.
- 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 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
To receive webhooks, you need to:
- Create a POST endpoint on your server to receive webhook payloads
- Register your webhook URL using the API (see below)
- 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:
| 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
| 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
| 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
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)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', 200package 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
endAlways 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.