Balance Extracts Webhooks

Please note that the balance extracts webhooks are released as a BETA version for evaluation purposes only and are not recommended for production use.

Webhooks are a mechanism that allows Fynapse to automatically send real-time data to your system when a specific event occurs. It works by making an HTTP request (usually a POST) to a predefined URL, enabling event-driven communication without polling.

This section provides detailed instruction on how to configure webhooks for balance extracts.

The examples below assume the service is exposed through Ingress. Replace https://<base_url> with the external base URL of event-publisher.

Required REST API permissions

All endpoints under /api/** require authentication. The following permissions are required to work with webhooks:

EndpointOperationsRequired permission
/api/v1/event-deliveries/secretslist / get detailswebhook_subscription_read
/api/v1/event-deliveries/secretscreate, patch, rotate, deletewebhook_subscription_edit
/api/v1/event-deliveries/subscriptionslist / get detailswebhook_subscription_read
/api/v1/event-deliveries/subscriptionscreate, update, activate, deactivatewebhook_subscription_edit
/api/v1/event-deliveries/subscriptions/{id}/deliveriesview deliveries for one subscriptiondelivery_audit_read
/api/v1/event-deliveries/deliverieslist / get delivery detailsdelivery_audit_read
/api/v1/event-deliveries/env-variableslist / get detailswebhook_subscription_read
/api/v1/event-deliveries/env-variablescreate, update, deletewebhook_subscription_edit

The minimum permission set for a user who only configures webhooks:

  • webhook_subscription_read
  • webhook_subscription_edit

If the same user should also investigate delivery failures and retries, they also need:

  • delivery_audit_read

These permissions were included in the Event Publisher Client permission in the Fynapse REST API Client role available by default in Fynapse. Therefore, please ensure the user who will be working with webhooks has this role assigned or their assigned custom role includes these permissions.

Additionally, you also need a user with Extractions Editor role assigned, e.g. Editor, to create the extract on the Extracts screen.

Receiver Requirements

  • The endpoint must accept POST requests with Content-Type: application/json.
  • The endpoint should return 2xx once the message has been safely accepted.
  • The receiver should treat the Idempotency-Key header as the deduplication key.
  • The receiver should validate the Webhook-Signature before processing the payload.

How to Create a Secret?

The secret is used to sign every webhook request using HMAC-SHA256.

Important:

  • The secret value is generated by the service
  • The value is returned only in the response to create or rotate, and GET /api/v1/event-deliveries/secrets/{id}
  • The secretRef in the subscription points to name, not to id

Example:

$curl -X POST https://<base_url>/api/v1/event-deliveries/secrets \
> -H 'Content-Type: application/json' \
> -H 'Authorization: Bearer <token>' \
> -d '{
> "name": "balance-webhook-secret",
> "description": "Secret for balance webhook"
> }'

Example response:

1{
2 "id": "8df4f3f2-8c2d-4f4b-8d0e-0aa7d0a8c111",
3 "name": "balance-webhook-secret",
4 "value": "4b7d5f5d...",
5 "description": "Secret for balance webhook",
6 "hasPreviousValue": false,
7 "createdTime": "2026-04-14T10:00:00Z",
8 "updatedTime": "2026-04-14T10:00:00Z",
9 "createdBy": "user@example.com",
10 "updatedBy": "user@example.com"
11}

Store the value securely on the receiver side. This is the value required to validate webhook signatures.

If the secret must be rotated, use:

$curl -X POST https://<base_url>/api/v1/event-deliveries/secrets/<secret-id>/rotate \
> -H 'Authorization: Bearer <token>'

After rotation, the service may include two v1 values in the same signature header to support a safe transition to the new secret.

How to Create a Webhook Subscription?

A subscription binds together:

  • the HTTP endpoint
  • secretRef
  • the list of event types that should be delivered

Example:

$curl -X POST https://<base_url>/api/v1/event-deliveries/subscriptions \
> -H 'Content-Type: application/json' \
> -H 'Authorization: Bearer <token>' \
> -d '{
> "name": "balance-webhook-subscription",
> "endpointUrl": "https://example.com/webhooks/balance",
> "secretRef": "balance-webhook-secret",
> "eventTypes": ["balance.extracted"],
> "description": "Delivery of extracted balances"
> }'

Please note:

  • a new subscription is created as active
  • explicitly setting eventTypes is recommended
  • endpointUrl should be the fully resolved receiver URL that will accept the webhook request
  • secretRef is optional — if omitted, the service automatically creates a new secret with the same name as the subscription and returns its value in the secretValue field of the response

Example:

The example below does not contain secretRef.

In this case, the response contains fields with the generated value in secretValue and this value must be saved on the receiver side:

$curl -X POST https://<base_url>/api/v1/event-deliveries/subscriptions \
> -H 'Content-Type: application/json' \
> -H 'Authorization: Bearer <token>' \
> -d '{
> "name": "balance-webhook-subscription",
> "endpointUrl": "https://example.com/webhooks/balance",
> "eventTypes": ["balance.extracted"],
> "description": "Delivery of extracted balances"
> }'

How to Create an Extract with Webhook Target in the UI?

Once you create a secret and subscription, you need to go to the Fynapse UI and create an extract that publishes events for target WEBHOOK. This way an event is produced and delivered to the subscribers:

  1. Go to Operations > Extracts > Extracts in Fynapse.
  2. Follow the standard configuration steps for Balance Extracts available in Balances Tutorials.
  3. In Target name select Webhook.
  4. The only available Data source is Balances.
  5. Complete the remaining configuration and either activate the schedule or run the extract manually.

Outbound webhook format

The service sends a POST request with these headers:

  • Content-Type: application/json
  • Webhook-Signature: t=<unix_timestamp>,v1=<hex_hmac>[,v1=<hex_hmac_for_previous_secret>]
  • Idempotency-Key: <idempotency_key>

Example:

1Content-Type: application/json
2Webhook-Signature: t=1776160486,v1=2e1d2a85c39fb0830104f97123b0a48cc2c5e1861212932cf43d0529d1c9cd7e
3Idempotency-Key: 7f3a9b2e1d4c5f8a6b0e3d7c9a1f4b2e8d5c6a0f3e7b9d2c1a4f8e6b0d3c5a7f

Example body:

1{
2 "event_id": "a3efc743-ac6c-463b-aa19-4dd9c45b5b96",
3 "event_type": "balance.extracted",
4 "metadata": {
5 "eventType": "balance.extracted",
6 "startedOn": "2026-04-14T09:54:43.674940Z",
7 "extractConfigurationName": "test"
8 },
9 "event_timestamp": "2026-04-14T09:54:45.040Z",
10 "data": {
11 "node": "Node1",
12 "account": "222",
13 "balance": 1.0,
14 "productName": "",
15 "transactionCurrency": "GBP"
16 }
17}

where:

  • event_id: unique event identifier
  • event_type: event type, for example balance.extracted
  • metadata: additional technical metadata
  • event_timestamp: event timestamp
  • data: business payload

Retry behavior

Delivery statuses:

  • PENDING — awaiting delivery or scheduled for retry
  • DELIVERED — success (2xx received)
  • DEAD_LETTER — non-retryable HTTP status or all retries exhausted
  • CANCELLED — the subscription was inactive when the delivery was picked up for processing; no HTTP attempt is made

Receiver response semantics:

  • 2xx - success, no retry
  • 400, 401, 402, 405, 406, 410, 413 - no retry, the delivery is moved to DEAD_LETTER
  • 408 - retry, but skips micro-retry and goes directly to engine-level retry
  • all other HTTP errors and network / timeout failures - retry

Default retry policy:

  • micro-retry within a single execution: up to 2 attempts in total
  • micro-retry delay: with jitter, random between 200 ms and 2000 ms
  • queue-level retry: up to 20 attempts in total
  • retry window: up to 72h from delivery creation time
  • delay between queued retries: starts from approximately 30 s and grows up to 4 h (multiplier: 3x per attempt)
  • queue-level retry uses exponential backoff with jitter
  • for normal queued retry, jitter is symmetric, approximately +/-20%
  • if the endpoint returns Retry-After, the service uses that value as the base and adds positive jitter up to approximately +10%

If the endpoint returns the Retry-After header, the service takes it into account when scheduling the next attempt.

Practical recommendation on the receiver side:

  • return 2xx only after the message has been durably stored or safely buffered
  • deduplicate by Idempotency-Key, because the same event may be delivered again during retries

Validating the Webhook-Signature

The signature is calculated as HMAC-SHA256 over:

<timestamp>.<raw_request_body>

Important rules:

  • use the raw HTTP body exactly as received
  • do not recompute the signature from a re-serialized JSON object
  • compare signatures using a constant-time comparison
  • validate the timestamp with a tolerance window, for example 5 minutes
  • if the header contains multiple v1 values, accept the request if any one of them matches
  • the receiver normally keeps one active secret; multiple v1 values in the header are there so the sender can rotate its secret safely
  • do not compare the signature against the raw body alone; the input must always be timestamp + "." + rawBody

Java example:

1import java.nio.charset.StandardCharsets;
2import java.security.MessageDigest;
3import java.time.Duration;
4import java.time.Instant;
5import java.util.ArrayList;
6import java.util.HexFormat;
7import java.util.List;
8import javax.crypto.Mac;
9import javax.crypto.spec.SecretKeySpec;
10
11public final class WebhookSignatureVerifier {
12
13 private static final String HMAC_SHA256 = "HmacSHA256";
14 private static final HexFormat HEX = HexFormat.of();
15 private static final Duration MAX_CLOCK_SKEW = Duration.ofMinutes(5);
16
17 public static boolean verify(String signatureHeader, String rawBody, String secret) {
18 if (signatureHeader == null || signatureHeader.isBlank()) {
19 return false;
20 }
21 if (rawBody == null || secret == null || secret.isBlank()) {
22 return false;
23 }
24
25 ParsedSignature parsed = parseHeader(signatureHeader);
26
27 long now = Instant.now().getEpochSecond();
28 if (Math.abs(now - parsed.timestamp()) > MAX_CLOCK_SKEW.toSeconds()) {
29 return false;
30 }
31
32 String signingInput = parsed.timestamp() + "." + rawBody;
33 String expected = hmacSha256Hex(signingInput, secret);
34
35 for (String provided : parsed.signatures()) {
36 if (MessageDigest.isEqual(
37 expected.getBytes(StandardCharsets.UTF_8),
38 provided.getBytes(StandardCharsets.UTF_8))) {
39 return true;
40 }
41 }
42
43 return false;
44 }
45
46 private static String hmacSha256Hex(String value, String secret) {
47 try {
48 Mac mac = Mac.getInstance(HMAC_SHA256);
49 mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_SHA256));
50 return HEX.formatHex(mac.doFinal(value.getBytes(StandardCharsets.UTF_8)));
51 } catch (Exception e) {
52 throw new IllegalStateException("Cannot verify webhook signature", e);
53 }
54 }
55
56 private static ParsedSignature parseHeader(String header) {
57 Long timestamp = null;
58 List<String> signatures = new ArrayList<>();
59
60 for (String part : header.split(",")) {
61 String[] keyValue = part.trim().split("=", 2);
62 if (keyValue.length != 2) {
63 continue;
64 }
65 if ("t".equals(keyValue[0])) {
66 timestamp = Long.parseLong(keyValue[1]);
67 } else if ("v1".equals(keyValue[0])) {
68 signatures.add(keyValue[1]);
69 }
70 }
71
72 if (timestamp == null || signatures.isEmpty()) {
73 throw new IllegalArgumentException("Invalid Webhook-Signature header");
74 }
75
76 return new ParsedSignature(timestamp, signatures);
77 }
78
79 private record ParsedSignature(long timestamp, List<String> signatures) {}
80}

Usage example:

1boolean valid = WebhookSignatureVerifier.verify(
2 request.getHeader("Webhook-Signature"),
3 rawRequestBody,
4 currentSecret
5);