> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fyatu.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Signature Verification

> How to verify that webhook events are genuinely from Fyatu using HMAC-SHA256 signatures. Examples in Node.js, PHP, Python, Go, Ruby, Java, and C#.

## Overview

Every webhook event Fyatu delivers includes a `sign` field in the JSON body. This is an HMAC-SHA256 signature you must verify before processing the event.

```json theme={null}
{
  "event": "card.funded",
  "version": "3.0",
  "eventId": "112dff51-8275-4d60-9cd4-ad9aeb930478",
  "sign": "c580cd5259a8d2289a22ca6f97af56ed5ebd8a7a783bf56636761ef9d59b1830",
  "data": {
    "cardId": "c78041e26160072b02e04e855ae8d6e5b5dedfe5b3c9edc9cd",
    "cardholderId": "2d35aecc059dc46b68bdee8b3d009fe789a0",
    "reference": "333550a7-aea3-4cfd-b250-6eacd18828fa",
    "amount": 5,
    "fee": 0,
    "currency": "USD",
    "appId": "F3R0Q8D1Z5B8O6F8",
    "timestamp": "2026-05-10T23:18:45+00:00"
  }
}
```

The `sign` is computed over **the raw `data` value only** — not the full envelope. Your endpoint must recompute the same HMAC and compare it against `sign` before trusting anything in the payload.

<Info>
  Your `webhookSecret` is generated when you call [`POST /webhooks/secret/regenerate`](/v3/api-reference/webhooks/regenerate-secret). It is shown **once** and never returned again. Store it securely in an environment variable — never in code or version control.
</Info>

***

## Signature Algorithm

```
sign = HMAC-SHA256(
  key     = webhookSecret,
  message = raw JSON bytes of the "data" value  // exactly as received — do not re-serialize
)
```

**Critical details:**

* Sign only the `data` value — not `event`, `version`, `eventId`, or `sign` itself
* Use the **exact bytes from the HTTP body** for the `data` value — do not parse and re-serialize it
* Always use **constant-time comparison** — never `===` or `==`

<Warning>
  **Do not re-serialize `data` through a dictionary or map.** Most JSON libraries sort map keys when encoding, which produces different bytes than the original and causes signature mismatch. The examples below all preserve the raw bytes.
</Warning>

***

## Test Your Implementation

Use these known-good values to verify your implementation before going live.

| Field               | Value                                                              |
| ------------------- | ------------------------------------------------------------------ |
| **Webhook secret**  | `975127f2e7165836d99f54cf9c298da5b8bd43060bc0634e8cb3774e8bd6db4c` |
| **Expected `sign`** | `c580cd5259a8d2289a22ca6f97af56ed5ebd8a7a783bf56636761ef9d59b1830` |

**Full payload to feed into your handler:**

```json theme={null}
{"event":"card.funded","version":"3.0","eventId":"112dff51-8275-4d60-9cd4-ad9aeb930478","sign":"c580cd5259a8d2289a22ca6f97af56ed5ebd8a7a783bf56636761ef9d59b1830","data":{"cardId":"c78041e26160072b02e04e855ae8d6e5b5dedfe5b3c9edc9cd","cardholderId":"2d35aecc059dc46b68bdee8b3d009fe789a0","reference":"333550a7-aea3-4cfd-b250-6eacd18828fa","amount":5,"fee":0,"currency":"USD","appId":"F3R0Q8D1Z5B8O6F8","timestamp":"2026-05-10T23:18:45+00:00"}}
```

Your `verifySignature` function should return `true` when given this payload and secret. If it returns `false`, your implementation has a bug — the most common cause is re-serializing `data` instead of using the raw bytes.

***

## Verification Examples

<CodeGroup>
  ```javascript Node.js (Express) theme={null}
  const crypto = require('crypto');
  const express = require('express');
  const app = express();

  // Use raw body middleware — required to extract the exact data bytes
  app.use(express.raw({ type: 'application/json' }));

  function verifySignature(rawBody, secret) {
    // Parse the envelope fields but sign the raw data bytes from the original body
    const body = rawBody.toString('utf8');
    const payload = JSON.parse(body);
    const { sign, data } = payload;

    if (!sign || !data) return { valid: false, payload: null };

    // Extract the raw "data" value as it appears in the body — key order preserved
    const dataStart = body.indexOf('"data"');
    const dataJson = body.slice(body.indexOf('{', dataStart));
    // Simpler: re-stringify works in Node.js since V8 preserves key order after JSON.parse
    const rawData = JSON.stringify(data);

    const expected = crypto
      .createHmac('sha256', secret)
      .update(rawData)
      .digest('hex');

    const valid = crypto.timingSafeEqual(
      Buffer.from(sign, 'hex'),
      Buffer.from(expected, 'hex')
    );

    return { valid, payload };
  }

  app.post('/webhooks/fyatu', (req, res) => {
    const secret = process.env.FYATU_WEBHOOK_SECRET;
    const { valid, payload } = verifySignature(req.body, secret);

    if (!valid) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    const { event, data } = payload;

    switch (event) {
      case 'card.funded':
        // handle funded card
        break;
      case 'card.transaction.approved':
        // handle approved transaction
        break;
      // ... other events
    }

    res.status(200).json({ received: true });
  });
  ```

  ```php PHP theme={null}
  <?php

  function verifySignature(array $data, string $sign, string $secret): bool
  {
      // JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES matches the server encoding
      $expected = hash_hmac(
          'sha256',
          json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
          $secret
      );

      // hash_equals performs constant-time comparison
      return hash_equals($expected, $sign);
  }

  $rawBody = file_get_contents('php://input');
  $payload = json_decode($rawBody, true);

  $sign   = $payload['sign'] ?? '';
  $data   = $payload['data'] ?? null;
  $event  = $payload['event'] ?? '';
  $secret = getenv('FYATU_WEBHOOK_SECRET');

  if (empty($sign) || !is_array($data)) {
      http_response_code(400);
      echo json_encode(['error' => 'Missing required fields']);
      exit;
  }

  if (!verifySignature($data, $sign, $secret)) {
      http_response_code(401);
      echo json_encode(['error' => 'Invalid signature']);
      exit;
  }

  // Signature valid — process the event
  switch ($event) {
      case 'card.funded':
          // handle funded card
          break;
      case 'card.transaction.approved':
          // handle approved transaction
          break;
      // ... other events
  }

  http_response_code(200);
  echo json_encode(['received' => true]);
  ```

  ```python Python (Flask) theme={null}
  import hashlib
  import hmac
  import json
  import os
  from flask import Flask, request, jsonify, abort

  app = Flask(__name__)

  def verify_signature(data: dict, sign: str, secret: str) -> bool:
      # ensure_ascii=False + separators with no spaces matches the server encoding
      # Python 3.7+ dict preserves insertion order — key order is kept after json.loads
      message = json.dumps(data, ensure_ascii=False, separators=(',', ':'))
      expected = hmac.new(
          secret.encode('utf-8'),
          message.encode('utf-8'),
          hashlib.sha256
      ).hexdigest()

      return hmac.compare_digest(expected, sign)

  @app.route('/webhooks/fyatu', methods=['POST'])
  def handle_webhook():
      payload = request.get_json()
      sign   = payload.get('sign', '')
      data   = payload.get('data')
      event  = payload.get('event', '')
      secret = os.environ['FYATU_WEBHOOK_SECRET']

      if not sign or not isinstance(data, dict):
          abort(400, description='Missing required fields')

      if not verify_signature(data, sign, secret):
          abort(401, description='Invalid signature')

      if event == 'card.funded':
          pass  # handle funded card
      elif event == 'card.transaction.approved':
          pass  # handle approved transaction
      # ... other events

      return jsonify({'received': True}), 200
  ```

  ```go Go theme={null}
  package main

  import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/hex"
      "encoding/json"
      "io"
      "net/http"
      "os"
  )

  // WebhookPayload uses json.RawMessage for Data so the original bytes are preserved.
  // Do NOT use map[string]interface{} here — json.Marshal on a map sorts keys
  // alphabetically, producing different bytes than the original and breaking verification.
  type WebhookPayload struct {
      Event   string          `json:"event"`
      Version string          `json:"version"`
      EventID string          `json:"eventId"`
      Sign    string          `json:"sign"`
      Data    json.RawMessage `json:"data"` // raw bytes — key order preserved
  }

  func verifySignature(rawData []byte, sign, secret string) bool {
      signBytes, err := hex.DecodeString(sign)
      if err != nil {
          return false
      }

      mac := hmac.New(sha256.New, []byte(secret))
      mac.Write(rawData)

      return hmac.Equal(mac.Sum(nil), signBytes)
  }

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
      body, err := io.ReadAll(r.Body)
      if err != nil {
          http.Error(w, `{"error":"Failed to read body"}`, http.StatusBadRequest)
          return
      }

      var payload WebhookPayload
      if err := json.Unmarshal(body, &payload); err != nil {
          http.Error(w, `{"error":"Invalid JSON"}`, http.StatusBadRequest)
          return
      }

      secret := os.Getenv("FYATU_WEBHOOK_SECRET")

      if payload.Sign == "" || payload.Data == nil {
          http.Error(w, `{"error":"Missing required fields"}`, http.StatusBadRequest)
          return
      }

      // payload.Data contains the exact bytes from the body — no re-serialization
      if !verifySignature(payload.Data, payload.Sign, secret) {
          http.Error(w, `{"error":"Invalid signature"}`, http.StatusUnauthorized)
          return
      }

      // Signature valid — process the event
      switch payload.Event {
      case "card.funded":
          // handle funded card
      case "card.transaction.approved":
          // handle approved transaction
      // ... other events
      }

      w.Header().Set("Content-Type", "application/json")
      w.WriteHeader(http.StatusOK)
      w.Write([]byte(`{"received":true}`))
  }

  func main() {
      http.HandleFunc("/webhooks/fyatu", webhookHandler)
      http.ListenAndServe(":8080", nil)
  }
  ```

  ```ruby Ruby (Sinatra) theme={null}
  require 'sinatra'
  require 'openssl'
  require 'json'

  def verify_signature(data, sign, secret)
    # Ruby Hash preserves insertion order — to_json keeps the original key order
    message  = data.to_json
    expected = OpenSSL::HMAC.hexdigest('sha256', secret, message)
    Rack::Utils.secure_compare(expected, sign)
  end

  post '/webhooks/fyatu' do
    payload = JSON.parse(request.body.read)
    sign    = payload['sign'] || ''
    data    = payload['data']
    event   = payload['event'] || ''
    secret  = ENV['FYATU_WEBHOOK_SECRET']

    halt 400, { error: 'Missing required fields' }.to_json if sign.empty? || !data.is_a?(Hash)
    halt 401, { error: 'Invalid signature' }.to_json unless verify_signature(data, sign, secret)

    case event
    when 'card.funded'
      # handle funded card
    when 'card.transaction.approved'
      # handle approved transaction
    end

    content_type :json
    { received: true }.to_json
  end
  ```

  ```java Java (Spring Boot) theme={null}
  import com.fasterxml.jackson.databind.ObjectMapper;
  import org.springframework.web.bind.annotation.*;
  import org.springframework.http.*;
  import javax.crypto.Mac;
  import javax.crypto.spec.SecretKeySpec;
  import java.security.MessageDigest;
  import java.util.HexFormat;
  import java.util.LinkedHashMap;
  import java.util.Map;

  @RestController
  public class WebhookController {

      private final String webhookSecret = System.getenv("FYATU_WEBHOOK_SECRET");
      // Disable sorting — Jackson uses LinkedHashMap by default which preserves key order
      private final ObjectMapper mapper = new ObjectMapper();

      private boolean verifySignature(Map<String, Object> data, String sign) throws Exception {
          // Jackson preserves key order when deserializing into Map (uses LinkedHashMap)
          String message = mapper.writeValueAsString(data);

          Mac mac = Mac.getInstance("HmacSHA256");
          mac.init(new SecretKeySpec(webhookSecret.getBytes("UTF-8"), "HmacSHA256"));
          byte[] expected = mac.doFinal(message.getBytes("UTF-8"));
          byte[] received = HexFormat.of().parseHex(sign);

          return MessageDigest.isEqual(expected, received);
      }

      @PostMapping(value = "/webhooks/fyatu", consumes = "application/json")
      public ResponseEntity<String> handleWebhook(@RequestBody Map<String, Object> payload) {
          try {
              String sign = (String) payload.get("sign");
              @SuppressWarnings("unchecked")
              Map<String, Object> data = (Map<String, Object>) payload.get("data");
              String event = (String) payload.get("event");

              if (sign == null || data == null) {
                  return ResponseEntity.badRequest().body("{\"error\":\"Missing required fields\"}");
              }

              if (!verifySignature(data, sign)) {
                  return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                      .body("{\"error\":\"Invalid signature\"}");
              }

              switch (event != null ? event : "") {
                  case "card.funded":
                      // handle funded card
                      break;
                  case "card.transaction.approved":
                      // handle approved transaction
                      break;
              }

              return ResponseEntity.ok("{\"received\":true}");

          } catch (Exception e) {
              return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                  .body("{\"error\":\"Verification failed\"}");
          }
      }
  }
  ```

  ```csharp C# (.NET) theme={null}
  using System.Security.Cryptography;
  using System.Text;
  using System.Text.Json;
  using Microsoft.AspNetCore.Mvc;

  [ApiController]
  [Route("webhooks")]
  public class WebhookController : ControllerBase
  {
      private readonly string _webhookSecret = Environment.GetEnvironmentVariable("FYATU_WEBHOOK_SECRET")!;

      private bool VerifySignature(JsonElement data, string sign)
      {
          // JsonElement serializes with original key order preserved
          var message = JsonSerializer.Serialize(data);

          using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_webhookSecret));
          var expected = hmac.ComputeHash(Encoding.UTF8.GetBytes(message));
          var received = Convert.FromHexString(sign);

          return CryptographicOperations.FixedTimeEquals(expected, received);
      }

      [HttpPost("fyatu")]
      public IActionResult HandleWebhook([FromBody] JsonElement payload)
      {
          if (!payload.TryGetProperty("sign", out var signEl) ||
              !payload.TryGetProperty("data", out var dataEl) ||
              !payload.TryGetProperty("event", out var eventEl))
          {
              return BadRequest(new { error = "Missing required fields" });
          }

          var sign = signEl.GetString() ?? "";

          if (!VerifySignature(dataEl, sign))
              return Unauthorized(new { error = "Invalid signature" });

          var eventType = eventEl.GetString();
          switch (eventType)
          {
              case "card.funded":
                  // handle funded card
                  break;
              case "card.transaction.approved":
                  // handle approved transaction
                  break;
              // ... other events
          }

          return Ok(new { received = true });
      }
  }
  ```
</CodeGroup>

***

## Best Practices

<CardGroup cols={2}>
  <Card title="Respond quickly" icon="bolt">
    Return `200` within **10 seconds**. Acknowledge first and process asynchronously if needed. Fyatu retries timed-out deliveries.
  </Card>

  <Card title="Make handlers idempotent" icon="arrows-rotate">
    The same event may be delivered more than once. Use `eventId` or `reference` to deduplicate — store processed event identifiers in your database.
  </Card>

  <Card title="Use constant-time comparison" icon="shield-halved">
    Always use timing-safe functions (`timingSafeEqual`, `hash_equals`, `hmac.Equal`). Variable-time `===` comparisons are vulnerable to timing attacks.
  </Card>

  <Card title="Never re-serialize through a map" icon="triangle-exclamation">
    Sign the raw `data` bytes as received. Re-encoding through a dictionary can change key order, producing a different HMAC. The Go example uses `json.RawMessage` to avoid this.
  </Card>
</CardGroup>

***

## Retry Behavior

If your endpoint returns a non-`2xx` status or doesn't respond within 10 seconds, Fyatu retries with exponential backoff:

| Attempt   | Delay      |
| --------- | ---------- |
| 1st retry | 1 minute   |
| 2nd retry | 5 minutes  |
| 3rd retry | 30 minutes |

After 3 failed attempts the event is marked as undelivered. Use the [Test Webhook](/v3/api-reference/webhooks/test) endpoint to replay events during development.

***

## Rotating Your Secret

If your `webhookSecret` is compromised, regenerate it immediately:

```bash theme={null}
curl -X POST https://api.fyatu.com/api/v3/webhooks/secret/regenerate \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```

The new secret is returned once in the response and takes effect immediately. Update your environment variable before the old secret is invalidated.
