Webhooks
Webhooks allow you to receive real-time notifications when events occur in your Hercle account. Instead of polling the API for updates, webhooks push data to your server as events happen.
Overview
When certain events occur (deposits, withdrawals, status changes, etc.), Hercle sends an HTTP POST request to your configured webhook URL with details about the event. Your server should acknowledge receipt by returning a 200 status code.
How It Works
- You provide Hercle with a webhook endpoint URL
- Hercle generates a public/private key pair and shares the public key with you
- When an event occurs, Hercle signs the payload and sends it to your endpoint
- Your server verifies the signature using the public key and processes the event
Webhook Payload Structure
All webhook events are wrapped in a standard envelope:
{
"EventId": "evt_abc123def456",
"EventType": "Banking.Deposit.StatusUpdated",
"Timestamp": "2025-01-15T14:30:00Z",
"Data": {
"StatusChange": {
"Previous": "REVIEWING",
"Current": "APPROVED"
},
"Resource": {
"Id": "payee-001",
"Name": "John Doe",
"CreatedAt": "2026-01-01T12:00:00Z",
"UpdatedAt": "2026-02-06T10:00:00Z",
"Address": { ... }
}
}
}
Envelope Fields
- EventId (string) — Unique identifier for this webhook event (use for idempotency)
- EventType (string) — The type of event that occurred
- Timestamp (string) — ISO 8601 timestamp of when the event occurred
- Data (string) — JSON-encoded event data (specific to each event type)
Note: The
Datafield is a JSON string that needs to be parsed. The structure depends on theEventType.
HTTP Headers
Each webhook request includes the following headers:
- X-Webhook-Signature — Base64-encoded RSA-SHA256 signature
- X-Webhook-Timestamp — Unix timestamp (seconds) when the request was signed
- X-Webhook-Id — Unique identifier for the webhook delivery (for tracking/debugging)
- Content-Type —
application/json
Event Types
Banking Events
Banking.Deposit.StatusUpdated
- Sent when a deposit is processed and completed
- Source: FiatRepublic
- Event Data: Transaction
Banking.Withdrawal.Created
- Sent when a new withdrawal request is created (for Individual and Business accounts)
- Source: FiatRepublic
- Event Data: Withdrawal
Banking.Withdrawal.StatusUpdated
- Sent when withdrawal status changes (CONFIRMING, SUCCESS, FAILURE, etc.)
- Source: FiatRepublic
- Event Data: Withdrawal
Banking.Payout.Created
- Sent when a new payout/transfer request is created (for Premium Business or Enterprise accounts)
- Source: FiatRepublic
- Event Data: Transfer
Banking.Payout.StatusUpdated
- Sent when payout/transfer status changes (CONFIRMING, CONFIRMED, SUCCESS, FAILURE, REJECTED)
- Source: FiatRepublic
- Event Data: Transfer
Virtual Account Events
VirtualAccount.Registered
- Sent when a new virtual account/internal address is registered
- Source: FiatRepublic
- Event Data: InternalAddress
Balance Events
Balance.Updated
- Sent when account balance changes
- Source: FiatRepublic
- Event Data: AccountBalance
End User Events
EndUser.Created
- Sent when a new end user (Individual or Business) is registered
- Source: FiatRepublic
- Event Data: IndividualEndUser or BusinessEndUser
EndUser.Updated
- Sent when end user info is updated (non-status fields)
- Source: FiatRepublic
- Event Data: BusinessEndUser
EndUser.StatusUpdated
- Sent when end user status changes (PENDING, ACTIVE, REJECTED, SUSPENDED)
- Source: FiatRepublic
- Event Data: BusinessEndUser
Payee Events
Payee.Created
- Sent when a new payee is registered
- Source: Hercle
- Event Data: Payee
Payee.StatusUpdated
- Sent when payee address status changes (PENDING, REVIEWING, APPROVED, REFUSED, DELETED)
- Source: Hercle
- Event Data: PayeeAddress
Event Data Models
Transaction Object
Sent with Banking.Deposit.StatusUpdated events. Source: FiatRepublic.
{
"Id": "a12f5e4d-3c6e-4b2a-9f4d-8e2b1c3d4e5f",
"UserId": "c56e7f8a-9b0c-4d1e-2f3a-4b5c6d7e8f9a",
"UserEmail": "user@example.com",
"Type": "DEPOSIT",
"Status": "SUCCESS",
"Description": "Deposit from bank transfer",
"Asset": "EUR",
"Amount": 1000.0,
"Debtor": {
"RefAddressId": "ref-123",
"BankName": "ABC Bank",
"BankAddressCountry": "IT",
"AccountHolderName": "John Smith",
"Country": "IT",
"AccountNumber": "",
"RoutingCodes": {},
"Iban": "IT123A456B789C012D345E678F90",
"Bic": "AABCDEFFXXX"
},
"Creditor": {
"RefAddressId": "ref-456",
"BankName": "ACME Bank",
"BankAddressCountry": "DE",
"AccountHolderName": "Acme Corporation",
"Country": "DE",
"AccountNumber": "",
"RoutingCodes": {},
"Iban": "DE123A456B789C012D345E678F90",
"Bic": "AABCDEFFXXX"
},
"Source": "FiatRepublic",
"SourceRefId": "fr_txn_123456",
"SourceRefClientId": "client_ref_789",
"CreatedAt": "2025-01-15T12:00:00Z",
"ApprovedAt": "2025-01-15T12:30:00Z",
"ProcessedAt": "2025-01-15T12:56:36Z"
}
Withdrawal Object
Sent with Banking.Withdrawal.Created and Banking.Withdrawal.StatusUpdated events. Source: FiatRepublic.
{
"Id": "987e6543-e21b-12d3-a456-426614174000",
"UserId": "987e6543-e21b-12d3-a456-426614174999",
"ClientId": "client_001",
"Name": "John",
"Surname": "Doe",
"Company": "Acme Corp",
"Destination": "IT60X0542811101000000123456",
"DestinationParams": { "Bic": "AABCDEFX" },
"Network": "SEPA",
"Amount": 500.0,
"Fee": 1.5,
"Asset": "EUR",
"Status": "SUCCESS",
"RefId": "withdrawal_ref_001",
"Description": "Monthly payment",
"IsInstant": false,
"CreatedAt": "2025-01-15T12:00:00Z"
}
Status Values: CREATED, CONFIRMING, CONFIRMED, EXECUTING, SUCCESS, FAILURE, REJECTED
Transfer Object
Sent with Banking.Payout.Created and Banking.Payout.StatusUpdated events. Source: FiatRepublic.
{
"Id": "123e4567-e89b-12d3-a456-426614174000",
"UserId": "987e6543-e21b-12d3-a456-426614174999",
"ClientId": "qwertyuiop-1234-5678-9012-abcdefabcdef",
"Description": "Monthly subscription payment",
"Debtor": {
"InternalAddressId": "internal-123",
"Asset": "EUR",
"Address": "DE89370400440532013000",
"AddressParams": {},
"Network": "SEPA",
"Amount": 100.0,
"Fee": 1.5
},
"Creditor": {
"PayeeAddressId": "payee-321",
"Asset": "EUR",
"Address": "IT60X0542811101000000123456",
"AddressParams": { "Bic": "AABCDEFX" },
"Network": "SEPA",
"Amount": 100.0
},
"ExchangeInfo": null,
"Status": "SUCCESS",
"RefId": "ref-654",
"IsInstant": false,
"CreatedAt": "2025-01-15T10:00:00Z",
"ApprovedAt": "2025-01-15T10:05:00Z",
"ProcessedAt": "2025-01-15T10:30:00Z"
}
Status Values: CREATED, CONFIRMING, CONFIRMED, PENDING, SUCCESS, FAILURE, REJECTED
InternalAddress Object
Sent with VirtualAccount.Registered events. Source: FiatRepublic.
{
"Id": "ia_abc123def456",
"UserId": "user_123",
"EndUserId": "enduser_456",
"Name": "EUR Deposit Account",
"Asset": "EUR",
"Address": "DE89370400440532013000",
"AddressParams": {},
"Network": "SEPA",
"RefAccountId": "vac_12345",
"RefAccountType": "VIRTUAL_ACCOUNT",
"RefAccountDetails": "Account details string",
"MasterFiatAccountId": "mfa_789",
"VirtualAccountId": "va_456",
"Deleted": false,
"CreatedAt": "2025-01-15T12:00:00Z"
}
RefAccountType Values: VIRTUAL_ACCOUNT, CRIPTO_ACCOUNT
AccountBalance Object
Sent with Balance.Updated events. Source: FiatRepublic.
{
"UserId": "1a66db8f-4043-4035-91df-615b3a7ac073",
"Sequence": 123,
"Assets": [
{
"Name": "EUR",
"Available": 1500.5,
"Allocated": 100.0
},
{
"Name": "USD",
"Available": 2000.0,
"Allocated": 0.0
}
]
}
IndividualEndUser Object
Sent with EndUser.Created events (for individual users). Source: FiatRepublic.
{
"Id": "eus_ong3zm73p8dr0b2jkr",
"Person": {
"FirstName": "Mario",
"MiddleName": "",
"LastName": "Rossi",
"Email": "mario.rossi@example.com",
"Phone": "+393121212",
"Dob": "1990-01-01",
"BirthCountry": "IT",
"Nationality": ["IT"],
"Address": {
"Line1": "Via Roma 1022",
"Line2": "",
"City": "Milano",
"State": "MI",
"PostalCode": "20100",
"Country": "IT"
},
"IdentificationDocument": {
"Type": "PASSPORT",
"Number": "AX1234567222"
}
},
"IpAddress": "192.168.1.1",
"RegistrationStatus": "ACTIVE",
"CreatedAt": "2025-01-15T10:30:00Z",
"UpdatedAt": "2025-01-15T10:30:00Z"
}
Registration Status Values: PENDING, ACTIVE, REJECTED, SUSPENDED
BusinessEndUser Object
Sent with EndUser.Created, EndUser.Updated, and EndUser.StatusUpdated events. Source: FiatRepublic.
{
"Id": "eus_abc123def456ghi789",
"Business": {
"Id": "bus_xyz123abc456",
"CompanyName": "Acme Corporation Ltd",
"TradingName": "Acme Trading Co",
"Type": "LIMITED",
"RegistrationNumber": "12345678",
"RegistrationDate": "2020-01-01",
"RegisteredAddress": {
"Line1": "123 Business Street",
"Line2": "Suite 100",
"City": "London",
"State": "England",
"PostalCode": "SW1A 1AA",
"Country": "GB"
},
"TradingAddress": {
"Line1": "456 Trade Avenue",
"Line2": "",
"City": "London",
"State": "England",
"PostalCode": "EC1A 1BB",
"Country": "GB"
},
"Website": "https://acmecorp.com",
"Phone": "+442071234567"
},
"BusinessPersons": [
{
"Id": "bp_123",
"Person": {
"FirstName": "John",
"LastName": "Smith",
"Email": "john.smith@acmecorp.com"
},
"BusinessEndUserId": "eus_abc123def456ghi789",
"PersonTypes": ["DIRECTOR", "UBO"],
"Ownership": 75.0,
"CreatedAt": "2025-01-15T10:30:00Z",
"UpdatedAt": "2025-01-15T10:30:00Z"
}
],
"IpAddress": "192.168.1.100",
"RegistrationStatus": "ACTIVE",
"Sector": "OTHER_PRODUCTS_SERVICES",
"CreatedAt": "2025-01-15T10:30:00Z",
"UpdatedAt": "2025-01-15T10:30:00Z"
}
Registration Status Values: PENDING, ACTIVE, REJECTED, SUSPENDED
PersonTypes Values: DIRECTOR, SHAREHOLDER, UBO
Payee Object
Sent with Payee.Created events. Source: Hercle.
{
"Id": "payee-001",
"UserId": "user-123",
"EndUserId": "enduser-456",
"Type": "PERSON",
"Name": "John",
"Surname": "Doe",
"Company": "Company Inc.",
"Country": "IT",
"State": "MI",
"City": "Milano",
"Address": "Via Roma 123",
"PostalCode": "20100",
"Disabled": false,
"Deleted": false
}
Type Values:
0— PERSON (Individual)1— BUSINESS (Company)
PayeeAddress Object
Sent with Payee.StatusUpdated events. Source: Hercle.
{
"Id": "pa_123e4567",
"PayeeId": "payee-001",
"Name": "John Doe - EUR Account",
"Asset": "EUR",
"Address": "IT60X0542811101000000123456",
"AddressParams": { "Bic": "AABCDEFX" },
"Network": "SEPA",
"Status": "CREATED",
"Comment": "Verified by compliance team",
"ConfirmationStrategy": "API"
}
Status Values:
0— CREATED1— PENDING2— REFUSED3— REVIEWING4— APPROVED5— DELETED
ConfirmationStrategy Values: EMAIL, API
Shared Objects
Address Object
Used in Person, Business, and other objects.
{
"Line1": "123 Main Street",
"Line2": "Apt 4B",
"City": "Milano",
"State": "MI",
"PostalCode": "20100",
"Country": "IT"
}
BankDetails Object
Used in Transaction objects for Debtor/Creditor.
{
"RefAddressId": "ref_addr_123",
"BankName": "Example Bank",
"BankAddressCountry": "IT",
"AccountHolderName": "John Doe",
"Country": "IT",
"AccountNumber": "123456789",
"RoutingCodes": { "sort_code": "12-34-56" },
"Iban": "IT60X0542811101000000123456",
"Bic": "AABCDEFX"
}
Security & Signature Verification
Hercle uses RSA-SHA256 signatures to ensure webhook authenticity. When you register for webhooks, you'll receive a public key to verify incoming requests.
Verification Process
- Extract headers: Get
X-Webhook-SignatureandX-Webhook-Timestampfrom the request - Verify timestamp: Ensure the timestamp is within 5 minutes to prevent replay attacks
- Build the message: Concatenate
timestamp.rawBody(e.g.,1705329000.{"eventId":"..."}) - Hash the message: Create a SHA-256 hash of the message
- Verify signature: Use RSA-SHA256 with the public key to verify the signature
Node.js Example
const express = require('express');
const crypto = require('crypto');
const app = express();
const PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
-----END PUBLIC KEY-----`;
const MAX_AGE_MINUTES = 5;
// Preserve raw body for signature verification
app.use(
express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString('utf8');
},
}),
);
function verifyTimestamp(timestamp) {
const ts = Number(timestamp);
if (!Number.isFinite(ts)) return false;
const requestTime = new Date(ts * 1000);
const now = new Date();
const diffMinutes = Math.abs((now - requestTime) / 60000);
return diffMinutes <= MAX_AGE_MINUTES;
}
function verifySignature(rawBody, signatureBase64, timestamp) {
try {
const message = `${timestamp}.${rawBody}`;
const digest = crypto.createHash('sha256').update(message, 'utf8').digest();
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(digest);
verifier.end();
return verifier.verify(PUBLIC_KEY_PEM, signatureBase64, 'base64');
} catch (e) {
console.error('Signature verification error:', e.message);
return false;
}
}
app.post('/webhooks/hercle', (req, res) => {
const rawBody = req.rawBody || '';
const payload = req.body;
// Extract headers
const signature = req.get('X-Webhook-Signature') || '';
const timestamp = req.get('X-Webhook-Timestamp') || '';
const webhookId = req.get('X-Webhook-Id') || '';
// Verify timestamp (prevent replay attacks)
if (!verifyTimestamp(timestamp)) {
console.warn('Timestamp too old or invalid');
return res.status(400).json({ error: 'Timestamp too old or invalid' });
}
// Verify signature
if (!verifySignature(rawBody, signature, timestamp)) {
console.warn('Invalid signature');
return res.status(400).json({ error: 'Invalid signature' });
}
// Process the webhook
console.log('Webhook verified:', {
eventId: payload.EventId,
eventType: payload.EventType,
timestamp: payload.Timestamp,
});
// Parse the event data
const eventData = JSON.parse(payload.Data);
// Handle the event based on type
switch (payload.EventType) {
case 'Banking.Deposit.StatusUpdated':
handleDeposit(eventData);
break;
case 'Banking.Withdrawal.StatusUpdated':
handleWithdrawal(eventData);
break;
// ... handle other event types
}
// Always return 200 to acknowledge receipt
return res.status(200).json({ success: true });
});
app.listen(3000, () => {
console.log('Webhook receiver listening on port 3000');
});
Python Example
import hashlib
import time
from base64 import b64decode
from flask import Flask, request, jsonify
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
app = Flask(__name__)
PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
-----END PUBLIC KEY-----"""
MAX_AGE_MINUTES = 5
def verify_timestamp(timestamp_str):
try:
ts = int(timestamp_str)
request_time = ts
now = int(time.time())
diff_minutes = abs(now - request_time) / 60
return diff_minutes <= MAX_AGE_MINUTES
except (ValueError, TypeError):
return False
def verify_signature(raw_body, signature_base64, timestamp):
try:
message = f"{timestamp}.{raw_body}"
digest = hashlib.sha256(message.encode('utf-8')).digest()
public_key = serialization.load_pem_public_key(PUBLIC_KEY_PEM.encode())
signature = b64decode(signature_base64)
public_key.verify(
signature,
digest,
padding.PKCS1v15(),
hashes.SHA256()
)
return True
except Exception as e:
print(f"Signature verification error: {e}")
return False
@app.route('/webhooks/hercle', methods=['POST'])
def handle_webhook():
raw_body = request.get_data(as_text=True)
payload = request.get_json()
# Extract headers
signature = request.headers.get('X-Webhook-Signature', '')
timestamp = request.headers.get('X-Webhook-Timestamp', '')
webhook_id = request.headers.get('X-Webhook-Id', '')
# Verify timestamp
if not verify_timestamp(timestamp):
return jsonify({'error': 'Timestamp too old or invalid'}), 400
# Verify signature
if not verify_signature(raw_body, signature, timestamp):
return jsonify({'error': 'Invalid signature'}), 400
# Process the webhook
print(f"Webhook verified: {payload['EventType']}")
# Always return 200 to acknowledge receipt
return jsonify({'success': True}), 200
if __name__ == '__main__':
app.run(port=3000)
Best Practices
1. Respond Quickly
Return a 200 response as soon as possible. Process webhooks asynchronously if your handling logic takes time.
app.post('/webhooks/hercle', async (req, res) => {
// Verify signature first
if (!verifySignature(...)) {
return res.status(400).json({ error: 'Invalid signature' });
}
// Acknowledge immediately
res.status(200).json({ success: true });
// Process asynchronously
processWebhookAsync(req.body).catch(console.error);
});
2. Implement Idempotency
Use the EventId to ensure you don't process the same event twice. Store processed event IDs and check before processing.
const processedEvents = new Set(); // Use a database in production
function handleWebhook(payload) {
if (processedEvents.has(payload.EventId)) {
console.log('Event already processed:', payload.EventId);
return;
}
processedEvents.add(payload.EventId);
// Process the event...
}
3. Verify All Security Checks
Always verify:
- Timestamp is recent (within 5 minutes)
- Signature is valid
4. Handle Retries Gracefully
If your endpoint returns a non-2xx status code, Hercle may retry the webhook. Ensure your system can handle duplicate deliveries safely.
5. Log Webhook Deliveries
Store the X-Webhook-Id header for debugging and support purposes.
console.log('Received webhook:', {
webhookId: req.get('X-Webhook-Id'),
eventId: payload.EventId,
eventType: payload.EventType,
});
6. Use HTTPS
Always use HTTPS for your webhook endpoint to ensure data is encrypted in transit.
Testing Webhooks
Sandbox Environment
Use the sandbox environment to test your webhook integration without affecting real data.
Verify Your Endpoint
Test that your endpoint:
- Returns
200for valid webhooks - Returns
400for invalid signatures - Returns
400for expired timestamps - Handles duplicate events (idempotency)
Troubleshooting
"Invalid signature" Errors
- Ensure you're using the correct public key
- Verify you're preserving the raw request body (before JSON parsing)
- Check the message format is
timestamp.rawBody - Confirm you're hashing with SHA-256 before RSA verification
"Timestamp too old" Errors
- Check your server's clock is synchronized (use NTP)
- Ensure the
X-Webhook-Timestampheader is being read correctly
Not Receiving Webhooks
- Verify your endpoint is publicly accessible
- Check your firewall allows incoming connections
- Ensure your endpoint returns
200within a reasonable time
Need Help?
If you're having trouble with webhooks:
- Review your webhook logs for the
X-Webhook-Id - Contact support with the webhook ID for investigation