Customer Webhooks
Customer webhooks let Heron send event notifications to your HTTPS endpoint when transfers, transfer legs, or provider entities change.
Webhook delivery is asynchronous and at least once. Your receiver should acknowledge each request quickly, verify the signature, and use the webhook event id to deduplicate work.
Quickstart
1. Create the endpoint
curl "https://api.tryheron.com/v1/webhook-endpoints" \
--request POST \
--header "Content-Type: application/json" \
--header "X-API-Key: YOUR_API_KEY" \
--data '{
"url": "https://payments.example.com/webhooks/heron",
"description": "Production payment events",
"enabled": true,
"eventTypes": ["*"]
}'The response includes the signing secret once. Store it in your secret manager before discarding the response.
2. Verify the raw request
Implement signature verification before processing event data. Use the exact raw body bytes and the timestamp from Heron-Signature; parsing and re-serializing JSON before verification changes the signed payload. See Verify signatures for Node.js and Python examples.
3. Send a test event
Call POST /v1/webhook-endpoints/{id}/send-test or select Send test in the dashboard. Confirm that the receiver returns 2xx, the signature verifies, and the delivery appears as DELIVERED.
4. Make processing idempotent
Persist the top-level event id before performing side effects. Return 2xx for an event already processed successfully, and retrieve the current resource when event ordering matters.
Configure an endpoint
Create and manage webhook endpoints from the Heron dashboard, or use the Webhook Endpoints API.
Each endpoint has:
- an HTTPS URL that receives
POSTrequests - an enabled/disabled setting
- event subscriptions
- a signing secret
Outside local development, endpoint URLs must be public HTTPS URLs. Heron rejects endpoints that use credentials in the URL, unsafe ports, localhost, private networks, link-local ranges, or cloud metadata addresses. Heron resolves and checks the endpoint host when you save it, and again before each delivery.
When you create an endpoint or rotate its secret, Heron shows the signing secret once. Store it in your own secret manager before closing the dialog.
Event subscriptions
If every event type is selected, the endpoint is subscribed to all current and future customer webhook events. You can also subscribe to a subset of event types:
transfer.createdtransfer.status_changedtransfer.leg_status_changedprovider_entity.status_changed
In the API, send eventTypes: ["*"] to subscribe an endpoint to all current and future customer webhook event types.
The webhook_endpoint.test event is sent only when you use Send test for a specific endpoint. It does not need to be selected in the endpoint's event subscriptions.
Delivery request
Heron sends each delivery as an HTTPS POST with an application/json body. The request includes these headers:
| Header | Description |
|---|---|
Heron-Signature | HMAC signature header. See Verify signatures. |
Heron-Webhook-Id | The webhook event id. This matches the top-level id in the JSON body. |
Heron-Webhook-Delivery-Id | The delivery attempt group id. This stays the same across retries. |
Heron-Webhook-Event-Type | The event type, for example transfer.created. |
Heron-Webhook-Attempt | The 1-based delivery attempt number. |
User-Agent | Heron's webhook delivery user agent. |
Return a 2xx response when you accept the event. Any non-2xx response, timeout, or network error is treated as a failed delivery and may be retried.
Heron does not follow redirects for webhook deliveries. A 3xx response is treated as a non-2xx delivery failure, so configure the endpoint URL to point directly at the receiver that should process the event.
Delivery statuses
The Webhook Deliveries API and dashboard expose each delivery's lifecycle with these status values:
PENDING: The delivery is queued and waiting for its next attempt time.CLAIMED: A delivery worker has an active lease on the delivery and is currently attempting it.RETRYING: The last attempt failed and another automatic retry has been scheduled.DELIVERED: A delivery attempt received a2xxresponse from your endpoint.FAILED: The automatic retry window ended without a successful2xxresponse.
Each attempt also has its own status:
IN_PROGRESSSUCCEEDEDFAILED
Event envelope
Every webhook body uses the same envelope:
{
"id": "00000000-0000-4000-8000-000000000100",
"type": "transfer.created",
"apiVersion": "v1",
"environment": "production",
"organizationId": "00000000-0000-4000-8000-000000000001",
"createdAt": "2026-01-01T00:00:00.000Z",
"data": {
"type": "transfer",
"id": "00000000-0000-4000-8000-000000000201"
}
}| Field | Description |
|---|---|
id | Unique webhook event id. Store this id to deduplicate processing. |
type | Event type. |
apiVersion | Webhook contract version. Current value is v1. |
environment | sandbox or production. Use this to separate sandbox and live events. |
organizationId | Heron organization id associated with the event. |
createdAt | ISO-8601 time when Heron created the webhook event. |
data | Event-specific resource snapshot. |
previousAttributes | Present only on change events. Contains changed fields and their previous values. |
Delivery ordering
Heron does not guarantee webhook ordering. Events for the same resource can arrive out of order or be retried after a later event has already been delivered. Use the event id, resource ids, and timestamps in your own state model.
Verify signatures
Heron signs every webhook delivery with a Heron-Signature header. The signature header uses this format:
Heron-Signature: t=<unix_timestamp>,v1=<hex_signature>To verify a delivery:
- Read the raw request body exactly as Heron sent it.
- Read the
tvalue from theHeron-Signatureheader and build the signing payload as<t>.<raw_body>, where<t>is the timestamp from the header. - Compute an HMAC-SHA256 digest using the endpoint signing secret.
- Compare the digest to one of the
v1values using a constant-time comparison. - Reject signatures when the
ttimestamp is outside a short tolerance window. Heron uses a five-minute tolerance by default.
HMAC verification proves that the request came from someone who has the endpoint signing secret and that the body was not modified in transit. It does not encrypt the payload. Treat webhook payloads as plain JSON sent over HTTPS.
Node.js example
import crypto from "node:crypto";
import express from "express";
const app = express();
const endpointSecret = process.env.HERON_WEBHOOK_SIGNING_SECRET;
app.post("/webhooks/heron", express.raw({ type: "application/json" }), (req, res) => {
const signatureHeader = req.header("Heron-Signature");
if (!endpointSecret || !signatureHeader) {
return res.sendStatus(400);
}
let timestamp;
const signatures = [];
for (const part of signatureHeader.split(",")) {
const [key, value] = part.trim().split("=", 2);
if (key === "t") timestamp = Number(value);
if (key === "v1" && value) signatures.push(value);
}
const now = Math.floor(Date.now() / 1000);
if (!Number.isInteger(timestamp) || Math.abs(now - timestamp) > 5 * 60) {
return res.sendStatus(400);
}
const signingPayload = Buffer.concat([Buffer.from(`${timestamp}.`, "utf8"), req.body]);
const expected = crypto.createHmac("sha256", endpointSecret).update(signingPayload).digest("hex");
const valid = signatures.some((signature) => {
if (!/^[a-f0-9]{64}$/i.test(signature)) return false;
return crypto.timingSafeEqual(Buffer.from(signature, "hex"), Buffer.from(expected, "hex"));
});
if (!valid) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString("utf8"));
// Use event.id for idempotency before doing side effects.
return res.sendStatus(204);
});Python example
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
app = Flask(__name__)
endpoint_secret = os.environ["HERON_WEBHOOK_SIGNING_SECRET"]
@app.post("/webhooks/heron")
def heron_webhook():
signature_header = request.headers.get("Heron-Signature", "")
timestamp = None
signatures = []
for part in signature_header.split(","):
if "=" not in part:
continue
key, value = part.strip().split("=", 1)
if key == "t":
try:
timestamp = int(value)
except ValueError:
timestamp = None
if key == "v1":
signatures.append(value)
now = int(time.time())
if timestamp is None or abs(now - timestamp) > 5 * 60:
return ("", 400)
raw_body = request.get_data()
signing_payload = str(timestamp).encode("utf-8") + b"." + raw_body
expected = hmac.new(
endpoint_secret.encode("utf-8"),
signing_payload,
hashlib.sha256,
).hexdigest()
if not any(hmac.compare_digest(signature.lower(), expected) for signature in signatures):
return ("", 400)
event = json.loads(raw_body)
# Use event["id"] for idempotency before doing side effects.
return ("", 204)Static outbound IPs
If your webhook receiver uses IP allowlisting, allow inbound HTTPS requests from:
35.189.192.150Heron still recommends verifying the Heron-Signature header on every request. IP allowlisting is an additional network control, not a replacement for signature verification.
Sandbox and production
Sandbox webhook events have "environment": "sandbox" and production webhook events have "environment": "production". Use this field to keep sandbox test activity separate from live automation, especially if both environments send to the same receiver during development.
Testing
Use Send test from an endpoint in the dashboard to send a webhook_endpoint.test event to that specific endpoint. Test deliveries do not depend on the endpoint's event subscriptions. They appear in the delivery log with the same status, attempt, and response metadata as production event deliveries. See webhook_endpoint.test for the test event payload.
Delivery guarantees and retries
Webhook delivery is at least once. Heron may send the same event more than once, especially after a timeout, network error, or non-2xx response. Use the top-level event id as your idempotency key.
Heron automatically retries failed deliveries for up to 72 hours. Retry timing uses backoff and can vary by delivery. If a delivery is still failing after the automatic retry window, it is marked failed.
Delivered and failed deliveries can be manually replayed from the dashboard for 30 days. Manual replay creates a new delivery cycle for the same webhook event and resets the delivery back to PENDING before the next attempt begins.
Webhook events are retained for 30 days.
Endpoint health
Heron does not automatically disable endpoints after repeated failures. Use the endpoint health and delivery log to decide whether to pause or update an endpoint.
Event catalog
webhook_endpoint.test
Sent when you use Send test from an endpoint in the dashboard. This event is endpoint-specific and does not depend on the endpoint's event subscriptions.
{
"id": "00000000-0000-4000-8000-000000000099",
"type": "webhook_endpoint.test",
"apiVersion": "v1",
"environment": "sandbox",
"organizationId": "00000000-0000-4000-8000-000000000001",
"createdAt": "2026-01-01T00:00:00.000Z",
"data": {
"type": "webhook_endpoint",
"id": "00000000-0000-4000-8000-000000000010",
"message": "Test webhook from Heron"
}
}transfer.created
Sent when a transfer is created.
{
"id": "00000000-0000-4000-8000-000000000100",
"type": "transfer.created",
"apiVersion": "v1",
"environment": "production",
"organizationId": "00000000-0000-4000-8000-000000000001",
"createdAt": "2026-01-01T00:00:00.000Z",
"data": {
"type": "transfer",
"id": "00000000-0000-4000-8000-000000000201",
"status": "AWAITING_FUNDS",
"reasonCode": null,
"blockingParty": "NONE",
"clientReference": "invoice-2026-001",
"idempotencyKey": "idem_transfer_001",
"entityId": "00000000-0000-4000-8000-000000000301",
"quoteOptionId": "00000000-0000-4000-8000-000000000401",
"destinationFiatAccountId": "00000000-0000-4000-8000-000000000501",
"destinationFiatAccountVersionId": "00000000-0000-4000-8000-000000000502",
"destinationCryptoWalletId": null,
"destinationCryptoWalletVersionId": null,
"source": {
"amount": "100.00",
"currency": "EUR",
"rail": {
"type": "FIAT",
"country": "FR",
"currency": "EUR",
"method": "SEPA"
}
},
"destination": {
"amount": "108.00",
"currency": "USD",
"rail": {
"type": "FIAT",
"country": "US",
"currency": "USD",
"method": "ACH"
}
},
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
}
}transfer.status_changed
Sent when a transfer status or related transfer-level state changes. The previousAttributes object contains the previous values for fields that changed.
{
"id": "00000000-0000-4000-8000-000000000101",
"type": "transfer.status_changed",
"apiVersion": "v1",
"environment": "production",
"organizationId": "00000000-0000-4000-8000-000000000001",
"createdAt": "2026-01-01T00:01:00.000Z",
"data": {
"type": "transfer",
"id": "00000000-0000-4000-8000-000000000201",
"status": "COMPLETED",
"reasonCode": null,
"blockingParty": "NONE",
"clientReference": "invoice-2026-001",
"idempotencyKey": "idem_transfer_001",
"entityId": "00000000-0000-4000-8000-000000000301",
"quoteOptionId": "00000000-0000-4000-8000-000000000401",
"destinationFiatAccountId": "00000000-0000-4000-8000-000000000501",
"destinationFiatAccountVersionId": "00000000-0000-4000-8000-000000000502",
"destinationCryptoWalletId": null,
"destinationCryptoWalletVersionId": null,
"source": {
"amount": "100.00",
"currency": "EUR",
"rail": {
"type": "FIAT",
"country": "FR",
"currency": "EUR",
"method": "SEPA"
}
},
"destination": {
"amount": "108.00",
"currency": "USD",
"rail": {
"type": "FIAT",
"country": "US",
"currency": "USD",
"method": "ACH"
}
},
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:01:00.000Z"
},
"previousAttributes": {
"status": "IN_PROGRESS",
"blockingParty": "HERON_OPS"
}
}transfer.leg_status_changed
Sent when a transfer leg status or related leg-level state changes.
{
"id": "00000000-0000-4000-8000-000000000102",
"type": "transfer.leg_status_changed",
"apiVersion": "v1",
"environment": "production",
"organizationId": "00000000-0000-4000-8000-000000000001",
"createdAt": "2026-01-01T00:01:00.000Z",
"data": {
"type": "transfer_leg",
"id": "00000000-0000-4000-8000-000000000701",
"transferId": "00000000-0000-4000-8000-000000000201",
"position": 0,
"providerCode": "BRIDGE",
"providerEntityId": "00000000-0000-4000-8000-000000000901",
"providerAccountId": null,
"providerExternalId": "provider_transfer_123",
"status": "COMPLETED",
"progressStage": "PAYMENT_PROCESSED",
"reasonCode": null,
"blockingParty": "NONE",
"source": {
"amount": "100.00",
"currency": "EUR",
"rail": {
"type": "FIAT",
"country": "FR",
"currency": "EUR",
"method": "SEPA"
}
},
"destination": {
"amount": "108.00",
"currency": "USD",
"rail": {
"type": "FIAT",
"country": "US",
"currency": "USD",
"method": "ACH"
}
},
"actualSourceAmount": "100.00",
"actualDestinationAmount": "108.00",
"createdAt": "2026-01-01T00:00:05.000Z",
"updatedAt": "2026-01-01T00:01:00.000Z"
},
"previousAttributes": {
"status": "IN_PROGRESS",
"progressStage": "PAYMENT_SUBMITTED"
}
}provider_entity.status_changed
Sent when a provider entity status or related provider KYC state changes.
{
"id": "00000000-0000-4000-8000-000000000103",
"type": "provider_entity.status_changed",
"apiVersion": "v1",
"environment": "production",
"organizationId": "00000000-0000-4000-8000-000000000001",
"createdAt": "2026-01-01T00:01:00.000Z",
"data": {
"type": "provider_entity",
"id": "00000000-0000-4000-8000-000000000901",
"entityId": "00000000-0000-4000-8000-000000000301",
"providerId": "00000000-0000-4000-8000-000000000801",
"providerCode": "BRIDGE",
"externalId": "bridge_customer_123",
"status": "ACTIVE",
"providerStatus": "active",
"capabilitiesRequested": ["onramp", "offramp"],
"capabilitiesActive": ["onramp"],
"kycLevel": "STANDARD",
"providerKycLevel": "tier_1",
"lastSyncedAt": "2026-01-01T00:00:30.000Z",
"lastChangedAt": "2026-01-01T00:00:30.000Z",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:30.000Z"
},
"previousAttributes": {
"status": "IN_PROGRESS",
"providerStatus": "under_review"
}
}Resource snapshots
Transfer snapshots include transfer-level status and both payment endpoints:
| Field | Description |
|---|---|
id | Transfer id. |
status | Transfer status, for example PENDING_SETUP, AWAITING_FUNDS, IN_PROGRESS, COMPLETED, or FAILED. |
reasonCode | Optional reason code for status-specific context. |
blockingParty | CUSTOMER, HERON_OPS, PROVIDER, NONE, or null. |
clientReference | Optional reference supplied when creating the transfer. |
idempotencyKey | Optional idempotency key supplied when creating the transfer. |
entityId | Entity associated with the transfer. |
destinationFiatAccountId / destinationCryptoWalletId | Stable saved destination object selected for the transfer, when one was used. |
destinationFiatAccountVersionId / destinationCryptoWalletVersionId | Immutable destination version used to execute the transfer. Later edits to the saved destination create new versions and do not change this transfer snapshot. |
source / destination | Amount, currency, and rail details. |
createdAt / updatedAt | ISO-8601 timestamps for the transfer. |
Transfer leg snapshots include leg-level provider and progress details:
| Field | Description |
|---|---|
id | Transfer leg id. |
transferId | Parent transfer id. |
position | Leg position within the transfer. |
providerCode | Provider code, when available. |
providerEntityId / providerAccountId | Provider resources associated with the leg, when available. |
providerExternalId | Provider-side transfer or payment id, when available. |
status | Leg status. |
progressStage | More detailed leg progress stage, when available. |
actualSourceAmount / actualDestinationAmount | Actual processed amounts, when known. |
Provider entity snapshots include provider onboarding and KYC status:
| Field | Description |
|---|---|
id | Provider entity id. |
entityId | Heron entity id. |
providerId / providerCode | Provider identity. |
externalId | Provider-side entity id, when available. |
status | Heron-normalized provider entity status. |
providerStatus | Raw or provider-specific status, when available. |
capabilitiesRequested / capabilitiesActive | Provider capabilities requested and active. |
kycLevel / providerKycLevel | Heron-normalized and provider-specific KYC levels. |
lastSyncedAt / lastChangedAt | Provider sync/change timestamps, when available. |