Webhooks
Webhooks push real-time notifications to a URL you control whenever a payment, deposit, or client event changes state—so you can react without polling.
Subscribe to events
Register an endpoint and the event types you want with POST /webhooks.
curl -X POST "https://api.sandbox.capay.com.au/webhooks" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "https://yourdomain.com/webhooks/capay",
"events": ["PAYMENT", "DEPOSIT", "CLIENT"]
}'
Event types
| Event type | Fires on |
|---|---|
PAYMENT | Payment status changes |
DEPOSIT | Deposit status changes |
CLIENT | Client status changes |
You subscribe at the event-type level; the specific status is carried inside each notification's payload.
Managing subscriptions
| Action | Endpoint |
|---|---|
| List subscriptions | GET /webhooks |
| Update a subscription | PUT /webhooks/{id} |
| Delete a subscription | DELETE /webhooks/{id} |
How delivery works
Encrypted payloads
For security, every webhook body is encrypted with AES-256-CBC. You receive a POST containing the initialization vector and the ciphertext:
{
"iv": "c6407a24ca95deaec313786321ec1e39",
"encrypted": "encrypted_payload_here"
}
Decrypt it with your API secret to recover the notification.
| Detail | Value |
|---|---|
| Algorithm | AES-256-CBC |
| Key size | 32 bytes (256 bits) |
| IV size | 16 bytes (128 bits) |
| Encoding | Hex for both iv and encrypted |
- Node.js
- Python
- Java
- PHP
- C#
- Go
const { createDecipheriv } = require("crypto");
function decryptWebhookPayload(encryptedData, secret, iv) {
// Create decipher with AES-256-CBC
const decipher = createDecipheriv(
"aes-256-cbc",
Buffer.from(secret, "utf-8"),
Buffer.from(iv, "hex")
);
// Decrypt the data
let decrypted = decipher.update(encryptedData, "hex", "utf8");
decrypted += decipher.final("utf8");
// Parse and return JSON
return JSON.parse(decrypted);
}
// Usage
const secret = "your_32_byte_secret_key_here"; // 32 characters for AES-256
const iv = "1234567890abcdef1234567890abcdef"; // 16 bytes in hex (32 chars)
const encryptedData = "your_encrypted_data_here"; // Hex encoded encrypted data
try {
const decryptedPayload = decryptWebhookPayload(encryptedData, secret, iv);
console.log("Decrypted payload:", decryptedPayload);
} catch (error) {
console.error("Decryption failed:", error.message);
}
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import json
def decrypt_webhook_payload(encrypted_data, secret, iv):
"""
Decrypt webhook payload using AES-256-CBC
Args:
encrypted_data: Hex encoded encrypted string
secret: 32-byte secret key (UTF-8 string)
iv: 16-byte initialization vector (hex string)
Returns:
Decrypted payload as dictionary
"""
# Convert inputs to bytes
key = secret.encode('utf-8')
iv_bytes = bytes.fromhex(iv)
encrypted_bytes = bytes.fromhex(encrypted_data)
# Create cipher and decrypt
cipher = AES.new(key, AES.MODE_CBC, iv_bytes)
decrypted = cipher.decrypt(encrypted_bytes)
# Remove PKCS7 padding and parse JSON
unpadded = unpad(decrypted, AES.block_size)
return json.loads(unpadded.decode('utf-8'))
# Usage
secret = 'your_32_byte_secret_key_here' # 32 characters
iv = '1234567890abcdef1234567890abcdef' # 32 hex chars (16 bytes)
encrypted_data = 'your_encrypted_data_here'
try:
decrypted_payload = decrypt_webhook_payload(encrypted_data, secret, iv)
print('Decrypted payload:', decrypted_payload)
except Exception as e:
print(f'Decryption failed: {e}')
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
public class AES256CBCDecryptor {
public static String decrypt(String encryptedData, String secret, String ivHex) throws Exception {
// convert secret and iv to byte array
byte[] keyBytes = secret.getBytes("UTF-8");
byte[] ivBytes = hexStringToByteArray(ivHex);
// create AES key spec and IV spec
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
IvParameterSpec ivSpec = new IvParameterSpec(ivBytes);
// initialize cipher object
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
// convert encrypted data from hex to byte array
byte[] encryptedBytes = hexStringToByteArray(encryptedData);
// decrypt
byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
// return decrypted string
return new String(decryptedBytes, "UTF-8");
}
// convert hex string to byte array
private static byte[] hexStringToByteArray(String hex) {
int len = hex.length();
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
+ Character.digit(hex.charAt(i + 1), 16));
}
return data;
}
public static void main(String[] args) {
try {
String encryptedData = ""; // replace with actual encrypted data (Hex format)
String secret = ""; // replace with actual secret key (32 bytes)
String ivHex = ""; // replace with actual IV (Hex format)
String decryptedData = decrypt(encryptedData, secret, ivHex);
System.out.println("Decrypted Data: " + decryptedData);
} catch (Exception e) {
e.printStackTrace();
}
}
}
<?php
function decryptWebhookPayload($encryptedData, $secret, $iv) {
/**
* Decrypt webhook payload using AES-256-CBC
*
* @param string $encryptedData Hex encoded encrypted string
* @param string $secret 32-byte secret key
* @param string $iv 16-byte IV (hex encoded)
* @return array Decrypted payload as associative array
*/
// Convert hex to binary
$encryptedBinary = hex2bin($encryptedData);
$ivBinary = hex2bin($iv);
// Decrypt using AES-256-CBC
$decrypted = openssl_decrypt(
$encryptedBinary,
'aes-256-cbc',
$secret,
OPENSSL_RAW_DATA,
$ivBinary
);
if ($decrypted === false) {
throw new Exception('Decryption failed: ' . openssl_error_string());
}
// Parse JSON
return json_decode($decrypted, true);
}
// Usage
$secret = 'your_32_byte_secret_key_here'; // 32 characters
$iv = '1234567890abcdef1234567890abcdef'; // 32 hex chars (16 bytes)
$encryptedData = 'your_encrypted_data_here';
try {
$decryptedPayload = decryptWebhookPayload($encryptedData, $secret, $iv);
print_r($decryptedPayload);
} catch (Exception $e) {
error_log('Decryption failed: ' . $e->getMessage());
}
?>
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
public class WebhookDecryptor
{
/// <summary>
/// Decrypt webhook payload using AES-256-CBC
/// </summary>
/// <param name="encryptedData">Hex encoded encrypted string</param>
/// <param name="secret">32-byte secret key (UTF-8 string)</param>
/// <param name="iv">16-byte initialization vector (hex string)</param>
/// <returns>Decrypted JSON string</returns>
public static string DecryptWebhookPayload(
string encryptedData,
string secret,
string iv)
{
// Convert hex strings to bytes
byte[] encryptedBytes = HexStringToByteArray(encryptedData);
byte[] ivBytes = HexStringToByteArray(iv);
byte[] keyBytes = Encoding.UTF8.GetBytes(secret);
using (Aes aes = Aes.Create())
{
aes.Key = keyBytes;
aes.IV = ivBytes;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
using (ICryptoTransform decryptor = aes.CreateDecryptor())
using (MemoryStream msDecrypt = new MemoryStream(encryptedBytes))
using (CryptoStream csDecrypt = new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read))
using (StreamReader srDecrypt = new StreamReader(csDecrypt))
{
return srDecrypt.ReadToEnd();
}
}
}
private static byte[] HexStringToByteArray(string hex)
{
byte[] bytes = new byte[hex.Length / 2];
for (int i = 0; i < bytes.Length; i++)
{
bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16);
}
return bytes;
}
}
// Usage Example
class Program
{
static void Main(string[] args)
{
string secret = "your_32_byte_secret_key_here"; // 32 characters
string iv = "1234567890abcdef1234567890abcdef"; // 32 hex chars
string encryptedData = "your_encrypted_data_here";
try
{
string decryptedJson = WebhookDecryptor.DecryptWebhookPayload(
encryptedData,
secret,
iv
);
Console.WriteLine("Decrypted payload: " + decryptedJson);
}
catch (Exception ex)
{
Console.Error.WriteLine("Decryption failed: " + ex.Message);
}
}
}
package main
import (
"crypto/aes"
"crypto/cipher"
"encoding/hex"
"encoding/json"
"fmt"
)
// DecryptWebhookPayload decrypts webhook payload using AES-256-CBC
func DecryptWebhookPayload(encryptedData, secret, iv string) (map[string]interface{}, error) {
// Convert hex strings to bytes
encryptedBytes, err := hex.DecodeString(encryptedData)
if err != nil {
return nil, fmt.Errorf("failed to decode encrypted data: %w", err)
}
ivBytes, err := hex.DecodeString(iv)
if err != nil {
return nil, fmt.Errorf("failed to decode IV: %w", err)
}
keyBytes := []byte(secret)
// Create AES cipher block
block, err := aes.NewCipher(keyBytes)
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
}
// Create CBC mode decryptor
mode := cipher.NewCBCDecrypter(block, ivBytes)
// Decrypt the data
decrypted := make([]byte, len(encryptedBytes))
mode.CryptBlocks(decrypted, encryptedBytes)
// Remove PKCS7 padding
decrypted, err = removePKCS7Padding(decrypted)
if err != nil {
return nil, fmt.Errorf("failed to remove padding: %w", err)
}
// Parse JSON
var result map[string]interface{}
if err := json.Unmarshal(decrypted, &result); err != nil {
return nil, fmt.Errorf("failed to parse JSON: %w", err)
}
return result, nil
}
// removePKCS7Padding removes PKCS7 padding from decrypted data
func removePKCS7Padding(data []byte) ([]byte, error) {
length := len(data)
if length == 0 {
return nil, fmt.Errorf("invalid padding: empty data")
}
padding := int(data[length-1])
if padding > length || padding > aes.BlockSize {
return nil, fmt.Errorf("invalid padding size")
}
return data[:length-padding], nil
}
// Usage
func main() {
secret := "your_32_byte_secret_key_here" // 32 characters for AES-256
iv := "1234567890abcdef1234567890abcdef" // 32 hex chars (16 bytes)
encryptedData := "your_encrypted_data_here"
decryptedPayload, err := DecryptWebhookPayload(encryptedData, secret, iv)
if err != nil {
fmt.Printf("Decryption failed: %v\n", err)
return
}
fmt.Printf("Decrypted payload: %+v\n", decryptedPayload)
}
Decrypted notification
Once decrypted, the payload is a WebhookNotification:
{
"subscriptionId": "4e408b3d-5f70-423d-b940-f7192cd77252",
"eventType": "PAYMENT",
"eventStatus": "SCHEDULED",
"timestamp": "2025-12-08T05:53:17.372Z",
"messageId": "123e4567-e89b-12d3-a456-426614174000",
"eventObject": {
"id": "4e408b3d-5f70-223d-b940-f7192cd77252",
"referenceNo": "20251208-PTW122",
"currencyCode": "USD",
"amount": 123,
"status": "SCHEDULED"
}
}
| Field | Description |
|---|---|
subscriptionId | The subscription that produced this event |
eventType | PAYMENT, DEPOSIT, or CLIENT |
eventStatus | Current status of the object |
timestamp | When the event was published |
messageId | Unique message ID—use it to deduplicate deliveries |
eventObject | Full object: a payment, deposit, or client, depending on eventType |
Because a webhook may be delivered more than once, use messageId to
deduplicate on your side and make event processing idempotent.
Retry policy
If your endpoint does not return a 2xx, CAPAY retries delivery:
- Timeout: each attempt has a 30-second timeout.
- Duration: retries continue for up to 1 hour.
- Backoff: exponential—short intervals at first, lengthening with each failure.
Troubleshooting
| Symptom | Check |
|---|---|
| Decryption fails | Confirm the secret matches your dashboard, and the IV comes from the payload |
| Invalid JSON after decrypt | Ensure PKCS7 padding is removed |
| Webhook never arrives | Confirm the endpoint is publicly reachable and accepts POST |