Skip to content
Login

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

bash
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 POST requests
  • 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.created
  • transfer.status_changed
  • transfer.leg_status_changed
  • provider_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:

HeaderDescription
Heron-SignatureHMAC signature header. See Verify signatures.
Heron-Webhook-IdThe webhook event id. This matches the top-level id in the JSON body.
Heron-Webhook-Delivery-IdThe delivery attempt group id. This stays the same across retries.
Heron-Webhook-Event-TypeThe event type, for example transfer.created.
Heron-Webhook-AttemptThe 1-based delivery attempt number.
User-AgentHeron'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 a 2xx response from your endpoint.
  • FAILED: The automatic retry window ended without a successful 2xx response.

Each attempt also has its own status:

  • IN_PROGRESS
  • SUCCEEDED
  • FAILED

Event envelope

Every webhook body uses the same envelope:

json
{
  "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"
  }
}
FieldDescription
idUnique webhook event id. Store this id to deduplicate processing.
typeEvent type.
apiVersionWebhook contract version. Current value is v1.
environmentsandbox or production. Use this to separate sandbox and live events.
organizationIdHeron organization id associated with the event.
createdAtISO-8601 time when Heron created the webhook event.
dataEvent-specific resource snapshot.
previousAttributesPresent 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:

http
Heron-Signature: t=<unix_timestamp>,v1=<hex_signature>

To verify a delivery:

  1. Read the raw request body exactly as Heron sent it.
  2. Read the t value from the Heron-Signature header and build the signing payload as <t>.<raw_body>, where <t> is the timestamp from the header.
  3. Compute an HMAC-SHA256 digest using the endpoint signing secret.
  4. Compare the digest to one of the v1 values using a constant-time comparison.
  5. Reject signatures when the t timestamp 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

js
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

python
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:

text
35.189.192.150

Heron 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.

json
{
  "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.

json
{
  "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.

json
{
  "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.

json
{
  "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.

json
{
  "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:

FieldDescription
idTransfer id.
statusTransfer status, for example PENDING_SETUP, AWAITING_FUNDS, IN_PROGRESS, COMPLETED, or FAILED.
reasonCodeOptional reason code for status-specific context.
blockingPartyCUSTOMER, HERON_OPS, PROVIDER, NONE, or null.
clientReferenceOptional reference supplied when creating the transfer.
idempotencyKeyOptional idempotency key supplied when creating the transfer.
entityIdEntity associated with the transfer.
destinationFiatAccountId / destinationCryptoWalletIdStable saved destination object selected for the transfer, when one was used.
destinationFiatAccountVersionId / destinationCryptoWalletVersionIdImmutable destination version used to execute the transfer. Later edits to the saved destination create new versions and do not change this transfer snapshot.
source / destinationAmount, currency, and rail details.
createdAt / updatedAtISO-8601 timestamps for the transfer.

Transfer leg snapshots include leg-level provider and progress details:

FieldDescription
idTransfer leg id.
transferIdParent transfer id.
positionLeg position within the transfer.
providerCodeProvider code, when available.
providerEntityId / providerAccountIdProvider resources associated with the leg, when available.
providerExternalIdProvider-side transfer or payment id, when available.
statusLeg status.
progressStageMore detailed leg progress stage, when available.
actualSourceAmount / actualDestinationAmountActual processed amounts, when known.

Provider entity snapshots include provider onboarding and KYC status:

FieldDescription
idProvider entity id.
entityIdHeron entity id.
providerId / providerCodeProvider identity.
externalIdProvider-side entity id, when available.
statusHeron-normalized provider entity status.
providerStatusRaw or provider-specific status, when available.
capabilitiesRequested / capabilitiesActiveProvider capabilities requested and active.
kycLevel / providerKycLevelHeron-normalized and provider-specific KYC levels.
lastSyncedAt / lastChangedAtProvider sync/change timestamps, when available.

Released under the Commercial License.