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.
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:
- Create POST endpoints on your server to receive webhook payloads
- Register your webhook URLs using the API (see below)
- 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
curl "https://api.paytrie.com/webhooks" \
-H "x-api-key: your-api-key"API Reference: Get Webhooks
View complete request parameters and response schema
Update webhook configuration
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"
}'API Reference: Update Webhooks
View complete request parameters and response schema
Your webhook endpoints 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.
User verified
Triggered when a user completes KYC verification and is ready to transact.
{
"email": "user@example.com",
"status": "verified"
}Transaction initiated
Triggered when a new transaction is created.
{
"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.
{
"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 for all possible values.
{
"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:
curl -X POST "https://api.paytrie.com/webhook-signing-secret" \
-H "x-api-key: your-api-key"Response:
{
"status": "success",
"signingSecret": "whsec_..."
}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.
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
Log payloads
Log all webhook payloads for debugging and auditing
Webhooks are sent once and not retried. Ensure your endpoint is reliable and returns a 2xx status code promptly.