Hercle

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

  1. You provide Hercle with a webhook endpoint URL
  2. Hercle generates a public/private key pair and shares the public key with you
  3. When an event occurs, Hercle signs the payload and sends it to your endpoint
  4. 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 Data field is a JSON string that needs to be parsed. The structure depends on the EventType.

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-Typeapplication/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

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 — CREATED
  • 1 — PENDING
  • 2 — REFUSED
  • 3 — REVIEWING
  • 4 — APPROVED
  • 5 — 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

  1. Extract headers: Get X-Webhook-Signature and X-Webhook-Timestamp from the request
  2. Verify timestamp: Ensure the timestamp is within 5 minutes to prevent replay attacks
  3. Build the message: Concatenate timestamp.rawBody (e.g., 1705329000.{"eventId":"..."})
  4. Hash the message: Create a SHA-256 hash of the message
  5. 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:

  1. Returns 200 for valid webhooks
  2. Returns 400 for invalid signatures
  3. Returns 400 for expired timestamps
  4. 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-Timestamp header is being read correctly

Not Receiving Webhooks

  • Verify your endpoint is publicly accessible
  • Check your firewall allows incoming connections
  • Ensure your endpoint returns 200 within 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