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": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"eventType": "Banking.Deposit.StatusUpdated",
"timestamp": "2025-01-15T14:30:00Z",
"data": {
"id": "a12f5e4d-3c6e-4b2a-9f4d-8e2b1c3d4e5f",
"userId": "c56e7f8a-9b0c-4d1e-2f3a-4b5c6d7e8f9a",
"type": "DEPOSIT",
"status": "SUCCESS",
"asset": "EUR",
"amount": 1000.0
}
}
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
- data (object) — Event-specific data object (structure depends on the
eventType)
Note: The
datafield is a JSON object embedded directly in the envelope — it does not need to be parsed separately.
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; charset=utf-8
Event Types
Banking Events
Banking.Deposit.StatusUpdated
- Sent when a deposit is processed and completed
- Event Data: Transaction
Banking.Withdrawal.Created
- Sent when a new withdrawal request is created (for Individual and Business accounts)
- Event Data: Withdrawal
Banking.Withdrawal.StatusUpdated
- Sent when withdrawal status changes (CONFIRMING, SUCCESS, FAILURE, REJECTED, etc.)
- Event Data: Withdrawal
Banking.Payout.Created
- Sent when a new payout/transfer request is created (for Premium Business or Enterprise accounts)
- Event Data: Transfer
Banking.Payout.StatusUpdated
- Sent when payout/transfer status changes (CONFIRMING, CONFIRMED, SUCCESS, FAILURE, REJECTED)
- Event Data: Transfer
Virtual Account Events
VirtualAccount.Registered
- Sent when a new virtual account is created and activated (status =
ACTIVE) - Event Data: VirtualAccount
VirtualAccount.StatusUpdated
- Sent when a virtual account status changes to
CREATED,BLOCKED,CLOSED,ACTIVATION_FAILED, orUNBLOCKING - Event Data: VirtualAccount
Note: For the
ACTIVEstatus, you will receive aVirtualAccount.Registeredevent instead.VirtualAccount.StatusUpdatedcovers all other status transitions.
Balance Events
Balance.Updated
- Sent when account balance changes
- Event Data: AccountBalance
End User Events
EndUser.Created
- Sent when a new end user (Individual or Business) is registered
- Event Data: IndividualEndUser or BusinessEndUser
EndUser.Updated
- Sent when end user info is updated (non-status fields)
- Event Data: IndividualEndUser or BusinessEndUser
EndUser.StatusUpdated
- Sent when end user status changes (PENDING, ACTIVE, REJECTED, SUSPENDED)
- Event Data: IndividualEndUser or BusinessEndUser
Note: The payload shape depends on the end user type. Individual end users receive the
IndividualEndUserobject, while business end users receive theBusinessEndUserobject.
Payee Events
Payee.Created
- Sent when a new payee is registered
- Event Data: Payee
Payee.StatusUpdated
- Sent when payee address status changes (PENDING, REVIEWING, APPROVED, REFUSED, DELETED)
- Event Data: PayeeAddress
Event Data Models
Transaction Object
Sent with Banking.Deposit.StatusUpdated events.
{
"id": "a12f5e4d-3c6e-4b2a-9f4d-8e2b1c3d4e5f",
"userId": "c56e7f8a-9b0c-4d1e-2f3a-4b5c6d7e8f9a",
"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",
"address": {
"line1": "Via Roma 1",
"city": "Roma",
"postalCode": "00100",
"country": "IT"
}
},
"creditor": {
"refAddressId": "ref-456",
"bankName": "ACME Bank",
"bankAddressCountry": "DE",
"accountHolderName": "Acme Corporation",
"country": "DE",
"accountNumber": "",
"routingCodes": {},
"iban": "DE123A456B789C012D345E678F90",
"bic": "AABCDEFFXXX",
"address": {
"line1": "Friedrichstr. 100",
"city": "Berlin",
"postalCode": "10117",
"country": "DE"
}
},
"source": "FiatRepublic",
"sourceRefId": "fr_txn_123456",
"createdTimestamp": "2025-01-15T12:00:00.000Z",
"approvedTimestamp": "2025-01-15T12:30:00.000Z",
"processedTimestamp": "2025-01-15T12:56:36.000Z"
}
Withdrawal Object
Sent with Banking.Withdrawal.Created events. (Will also be used by Banking.Withdrawal.StatusUpdated when available.)
{
"id": "987e6543-e21b-12d3-a456-426614174000",
"userId": "987e6543-e21b-12d3-a456-426614174999",
"description": "Monthly payment",
"destination": "IT60X0542811101000000123456",
"destinationParams": { "bic": "AABCDEFX" },
"network": "SEPA",
"amount": 500.0,
"fee": 1.5,
"asset": "EUR",
"status": "SUCCESS",
"isInstant": false,
"timestamp": "2025-01-15T12:00:00.000Z"
}
Status Values: CREATED, CONFIRMING, CONFIRMED, EXECUTING, SUCCESS, FAILURE, REJECTED
Transfer Object
Sent with Banking.Payout.Created and Banking.Payout.StatusUpdated events.
{
"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": {
"rate": 1.0
},
"status": "SUCCESS",
"isInstant": false,
"createdTimestamp": "2025-01-15T10:00:00.000Z",
"approvedTimestamp": "2025-01-15T10:05:00.000Z",
"processedTimestamp": "2025-01-15T10:30:00.000Z"
}
Status Values: CREATED, CONFIRMING, CONFIRMED, PENDING, SUCCESS, FAILURE, REJECTED
VirtualAccount Object
Sent with VirtualAccount.Registered and VirtualAccount.StatusUpdated events.
{
"id": "vac_abc123def456",
"masterFiatAccountId": "fac_789xyz",
"owner": {
"id": "eus_owner123",
"type": "END_USER"
},
"currency": "EUR",
"bankDetails": {
"bankName": "Example Bank",
"accountHolderName": "Acme Corporation",
"country": "DE",
"bankAddressCountry": "DE",
"accountNumber": "",
"routingCodes": [{ "type": "sortcode", "value": "12-34-56" }],
"iban": "DE89370400440532013000",
"bic": "COBADEFFXXX"
},
"businessId": "biz_xyz456",
"label": "EUR Deposit Account",
"status": "ACTIVE",
"createdAt": "2025-01-15T12:00:00.000Z",
"updatedAt": "2025-01-15T12:30:00.000Z",
"userId": "user_123",
"endUserId": "eus_owner123"
}
Status Values: CREATED, ACTIVE, BLOCKED, CLOSED, ACTIVATION_FAILED, UNBLOCKING
Owner Type Values: END_USER, BUSINESS
AccountBalance Object
Sent with Balance.Updated events.
{
"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).
{
"id": "eus_ong3zm73p8dr0b2jkr",
"userId": "c56e7f8a-9b0c-4d1e-2f3a-4b5c6d7e8f9a",
"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"
}
},
"tags": [],
"status": "ACTIVE",
"riskRating": "LOW",
"createdAt": "2025-01-15T10:30:00.000Z",
"updatedAt": "2025-01-15T10:30:00.000Z"
}
Status Values: PENDING, ACTIVE, REJECTED, SUSPENDED
BusinessEndUser Object
Sent with EndUser.Created, EndUser.Updated, and EndUser.StatusUpdated events.
{
"id": "eus_abc123def456ghi789",
"userId": "987e6543-e21b-12d3-a456-426614174999",
"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"
},
"businessPersons": [
{
"id": "bp_123",
"businessEndUserId": "eus_abc123def456ghi789",
"person": {
"firstName": "John",
"middleName": "",
"lastName": "Smith",
"email": "john.smith@acmecorp.com",
"phone": "+442071234567",
"dob": "1980-05-15",
"birthCountry": "GB",
"nationality": ["GB"],
"address": {
"line1": "123 Business Street",
"line2": "Suite 100",
"city": "London",
"state": "England",
"postalCode": "SW1A 1AA",
"country": "GB"
}
},
"personTypes": ["DIRECTOR", "UBO"],
"ownership": "75.0",
"createdAt": "2025-01-15T10:30:00.000Z",
"updatedAt": "2025-01-15T10:30:00.000Z"
}
],
"status": "ACTIVE",
"riskRating": "LOW",
"sector": "OTHER_PRODUCTS_SERVICES",
"website": "https://acmecorp.com",
"phone": "+442071234567",
"createdAt": "2025-01-15T10:30:00.000Z",
"updatedAt": "2025-01-15T10:30:00.000Z"
}
Status Values: PENDING, ACTIVE, REJECTED, SUSPENDED
PersonTypes Values: DIRECTOR, SHAREHOLDER, UBO
Payee Object
Sent with Payee.Created events.
{
"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: PERSON, BUSINESS
PayeeAddress Object
Sent with Payee.StatusUpdated events.
{
"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: CREATED, PENDING, REFUSED, REVIEWING, APPROVED, 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": { "sortcode": "12-34-56" },
"iban": "IT60X0542811101000000123456",
"bic": "AABCDEFX",
"address": {
"line1": "123 Main Street",
"city": "Milano",
"postalCode": "20100",
"country": "IT"
}
}
Virtual Account Creation Flow
Virtual account creation is an asynchronous process. Understanding the lifecycle helps you integrate webhooks correctly.
Lifecycle
- Client calls Create Deposit Account (
POST /api/v1/deposit-accounts) with a fiat asset — Returns an immediate response with statusPENDING. No bank details are available yet. - FiatRepublic processes the request — The virtual account is created in the background. An email is sent to the end user confirming the request.
- Account activated — When processing completes successfully, the account status becomes
ACTIVE:- A
VirtualAccount.Registeredwebhook is sent with the full payload, includingBankDetails(IBAN, BIC, etc.) - An activation email is sent to the end user
- A
- Non-active status changes — If the account transitions to
BLOCKED,CLOSED,ACTIVATION_FAILED, orUNBLOCKING, aVirtualAccount.StatusUpdatedwebhook is sent
Sequence
Client Hercle API FiatRepublic | | | |-- Create Deposit Account --->| | |<--- 200 OK (PENDING) --------| | | |--- Create VA --------------->| | | | | |<--- VA Active ---------------| |<--- Webhook: VirtualAccount.Registered (ACTIVE) ------------| | (includes BankDetails) | | | | | | ... later ... | | | |<-- VA Blocked ---------------| |<--- Webhook: VirtualAccount.StatusUpdated (BLOCKED) --------|
Key Points
- Do not poll for virtual account status — use webhooks to receive updates
BankDetailsare only available after the account reachesACTIVEstatus- The
Idin the webhook payload is the FiatRepublic VirtualAccountId, not the Hercle internal ID - Both creation and activation trigger email notifications to the end user
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,
});
// Event data is already a parsed object
const eventData = payload.data;
// Handle the event based on type
switch (payload.eventType) {
case 'Banking.Deposit.StatusUpdated':
handleDeposit(eventData);
break;
case 'VirtualAccount.Registered':
handleVirtualAccount(eventData);
break;
case 'VirtualAccount.StatusUpdated':
handleVirtualAccountStatus(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)
.NET (ASP.NET Core) Example
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Hercle exposes the public key as Base64-encoded DER (SubjectPublicKeyInfo).
// Paste it here as-is — the helper below wraps it as PEM. If your key already
// has -----BEGIN PUBLIC KEY----- headers, the helper will pass it through.
const string PublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...";
const int MaxAgeMinutes = 5;
static string ToPem(string base64OrPem)
{
if (base64OrPem.Contains("-----BEGIN")) return base64OrPem;
var lines = new StringBuilder();
for (var i = 0; i < base64OrPem.Length; i += 64)
lines.AppendLine(base64OrPem.Substring(i, Math.Min(64, base64OrPem.Length - i)));
return $"-----BEGIN PUBLIC KEY-----\n{lines}-----END PUBLIC KEY-----";
}
bool VerifyTimestamp(string timestamp)
{
if (!long.TryParse(timestamp, out var ts)) return false;
var requestTime = DateTimeOffset.FromUnixTimeSeconds(ts);
var diffMinutes = Math.Abs((DateTimeOffset.UtcNow - requestTime).TotalMinutes);
return diffMinutes <= MaxAgeMinutes;
}
bool VerifySignature(string rawBody, string signatureBase64, string timestamp)
{
try
{
// The signed message is `${timestamp}.${rawBody}`, pre-hashed with SHA-256.
// RSA.VerifyData below hashes again, matching the Node/Python implementations
// (which feed a SHA-256 digest into an RSA-SHA256 verifier).
var message = $"{timestamp}.{rawBody}";
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(message));
var signature = Convert.FromBase64String(signatureBase64);
using var rsa = RSA.Create();
rsa.ImportFromPem(ToPem(PublicKey));
return rsa.VerifyData(
digest,
signature,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Signature verification error: {ex.Message}");
return false;
}
}
app.MapPost("/webhooks/hercle", async (HttpRequest request) =>
{
// Read the raw body exactly as received — do not re-serialize
using var reader = new StreamReader(request.Body, Encoding.UTF8);
var rawBody = await reader.ReadToEndAsync();
var signature = request.Headers["X-Webhook-Signature"].ToString();
var timestamp = request.Headers["X-Webhook-Timestamp"].ToString();
var webhookId = request.Headers["X-Webhook-Id"].ToString();
if (!VerifyTimestamp(timestamp))
return Results.BadRequest(new { error = "Timestamp too old or invalid" });
if (!VerifySignature(rawBody, signature, timestamp))
return Results.BadRequest(new { error = "Invalid signature" });
var payload = JsonSerializer.Deserialize<JsonElement>(rawBody);
var eventType = payload.GetProperty("eventType").GetString();
var eventId = payload.GetProperty("eventId").GetString();
Console.WriteLine($"Webhook verified: {eventType} ({eventId})");
// Event data is already a parsed object — no extra deserialization needed
var eventData = payload.GetProperty("data");
// Handle the event based on type
switch (eventType)
{
case "Banking.Deposit.StatusUpdated":
// HandleDeposit(eventData);
break;
case "VirtualAccount.Registered":
// HandleVirtualAccount(eventData);
break;
// ... handle other event types
}
// Always return 200 to acknowledge receipt
return Results.Ok(new { success = true });
});
app.Run();
Note: Requires .NET 6 or later (uses
RSA.ImportFromPem,SHA256.HashData, and minimal APIs). Ensure no middleware readsrequest.Bodybefore your handler — the raw bytes must be preserved exactly as received, since any re-serialization will invalidate the signature.
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 will retry the webhook delivery using exponential backoff:
| Attempt | Delay after failure |
|---|---|
| 1 | 2 seconds |
| 2 | 4 seconds |
| 3 | 8 seconds |
| 4 | 16 seconds |
| 5 | 32 seconds |
| 6 | 64 seconds |
| 7 | 128 seconds |
| 8 | 256 seconds |
| 9 | 512 seconds |
| 10 | Permanently failed |
After 10 failed attempts (~17 minutes total), the delivery is marked as permanently failed and will not be retried. Ensure your system can handle duplicate deliveries safely, as the same event may be delivered more than once during retries.
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.
7. Disabling Webhooks
When webhooks are disabled, any events that occur during the disabled period are permanently discarded — they are not queued for later delivery. If you re-enable webhooks, you will not receive events that occurred while disabled.
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 from Hercle's egress IPs (see below)
- Ensure your endpoint returns
200within a reasonable time
IP Allowlisting
If your firewall restricts inbound traffic, whitelist the following IPs:
| Environment | Egress IP |
|---|---|
| Sandbox | 18.195.31.165 |
| Production | 18.159.77.31 |
| Production | 3.248.76.1 |
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