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

# Signature Verification

> Verify that webhook events are genuinely from FYATU using HMAC-SHA256 signatures. Step-by-step algorithm and examples in Node.js, Python, PHP, Go, and Ruby.

# Webhook Signature Verification

Every webhook delivery from FYATU includes an `X-Fyatu-Signature` header. Verify this signature before processing any event — it proves the request originated from FYATU and the body has not been tampered with.

```
X-Fyatu-Signature: t=1716372000,v1=3a5b8c2d1e4f9a0b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b
```

## Delivery Headers

Every webhook delivery includes these headers:

| Header                | Example                     | Description                                |
| --------------------- | --------------------------- | ------------------------------------------ |
| `X-Fyatu-Signature`   | `t=1716372000,v1=abc123...` | Unix timestamp + HMAC-SHA256 signature     |
| `X-Fyatu-Timestamp`   | `1716372000`                | Unix timestamp (same as `t=` in signature) |
| `X-Fyatu-Event`       | `CARD_ISSUED`               | Event type in UPPERCASE\_SNAKE format      |
| `X-Fyatu-Event-ID`    | `evt_01HXY123456ABCDEF`     | Unique event ID — use for deduplication    |
| `X-Fyatu-Environment` | `LIVE`                      | `LIVE` or `SANDBOX`                        |
| `User-Agent`          | `Fyatu-Webhook/3.20`        | Sender identification                      |

<Note>
  Event names use `UPPERCASE_SNAKE` format: `CARD_ISSUED`, `TRANSACTION_PROCESSED`, `CARDHOLDER_KYC_APPROVED`. Earlier docs may reference dot-notation names such as `card.issued` — those are deprecated. Always use the header value as the canonical event name.
</Note>

## Webhook Payload Envelope

All events share the same outer envelope:

```json theme={null}
{
  "event":       "CARD_ISSUED",
  "eventId":     "evt_01HXY123456ABCDEF",
  "businessId":  "BUS1A2B3C4D5E6F",
  "environment": "LIVE",
  "timestamp":   "2026-05-22T10:00:00Z",
  "data": {
    "cardId":    "crd_01HXYZ5555ABCDEF1111",
    "status":    "ACTIVE",
    "programId": "prg_01HXYZ9876ABCDEF0000"
  }
}
```

| Field         | Description                                         |
| ------------- | --------------------------------------------------- |
| `event`       | Event type — matches `X-Fyatu-Event` header         |
| `eventId`     | Unique event ID — matches `X-Fyatu-Event-ID` header |
| `businessId`  | Your business identifier                            |
| `environment` | `LIVE` or `SANDBOX`                                 |
| `timestamp`   | ISO 8601 event timestamp                            |
| `data`        | Event-specific payload — varies by event type       |

## The Signing Algorithm

FYATU signs every webhook using a two-step process.

**Step 1 — Derive the signing key**

Your raw webhook secret (`whsec_...`) is never used directly as the HMAC key. Instead, FYATU computes:

```
signingKey = hex( SHA-256( rawSecret ) )
```

**Step 2 — Sign the message**

The signed message is `{timestamp}.{fullRequestBody}`:

```
signature = hex( HMAC-SHA256( signingKey, "{timestamp}.{body}" ) )
```

Where `timestamp` is the Unix timestamp from the `t=` field in `X-Fyatu-Signature`, and `body` is the **raw request body bytes** before any JSON parsing.

**Step 3 — Compare**

Extract `v1` from the header and compare with your computed signature using a **constant-time** comparison function to prevent timing attacks.

## Verification Examples

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

  // Must be set BEFORE express.json() to capture raw bytes
  app.use('/webhooks/fyatu', express.raw({ type: 'application/json' }));

  function verifyFyatuSignature(rawBody, signatureHeader, rawSecret) {
    const parts = Object.fromEntries(
      signatureHeader.split(',').map(p => p.split('='))
    );
    const timestamp = parts['t'];
    const v1        = parts['v1'];

    if (!timestamp || !v1) return false;

    // Derive signing key: hex(SHA-256(rawSecret))
    const signingKey = crypto.createHash('sha256')
      .update(rawSecret, 'utf8')
      .digest('hex');

    // Sign: HMAC-SHA256(signingKey, "{timestamp}.{body}")
    const message  = `${timestamp}.${rawBody}`;
    const expected = crypto.createHmac('sha256', signingKey)
      .update(message, 'utf8')
      .digest('hex');

    // Constant-time compare
    return crypto.timingSafeEqual(
      Buffer.from(v1, 'hex'),
      Buffer.from(expected, 'hex')
    );
  }

  app.post('/webhooks/fyatu', (req, res) => {
    const sigHeader = req.headers['x-fyatu-signature'];
    const rawSecret = process.env.FYATU_WEBHOOK_SECRET;

    if (!sigHeader) return res.status(400).json({ error: 'Missing signature' });

    if (!verifyFyatuSignature(req.body, sigHeader, rawSecret)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    const payload   = JSON.parse(req.body);
    const eventId   = req.headers['x-fyatu-event-id'];
    const eventType = payload.event;

    switch (eventType) {
      case 'TRANSACTION_PROCESSED':
        // show pending transaction in cardholder's history
        break;
      case 'CARDHOLDER_KYC_APPROVED':
        // unlock card issuance for this cardholder
        break;
      case 'CARD_ISSUED':
        // mark card as ready in your system
        break;
    }

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

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

  app = Flask(__name__)

  def verify_fyatu_signature(raw_body: bytes, sig_header: str, raw_secret: str) -> bool:
      parts     = dict(p.split('=', 1) for p in sig_header.split(','))
      timestamp = parts.get('t')
      v1        = parts.get('v1')

      if not timestamp or not v1:
          return False

      # Derive signing key: hex(SHA-256(rawSecret))
      signing_key = hashlib.sha256(raw_secret.encode('utf-8')).hexdigest()

      # Sign: HMAC-SHA256(signingKey, "{timestamp}.{body}")
      message  = f'{timestamp}.{raw_body.decode("utf-8")}'.encode('utf-8')
      expected = hmac.new(
          signing_key.encode('utf-8'),
          message,
          hashlib.sha256
      ).hexdigest()

      return hmac.compare_digest(expected, v1)

  @app.route('/webhooks/fyatu', methods=['POST'])
  def handle_webhook():
      sig_header = request.headers.get('X-Fyatu-Signature', '')
      raw_secret = os.environ['FYATU_WEBHOOK_SECRET']

      if not verify_fyatu_signature(request.get_data(), sig_header, raw_secret):
          abort(401)

      payload    = request.get_json()
      event_type = payload['event']
      event_id   = request.headers.get('X-Fyatu-Event-ID')

      if event_type == 'TRANSACTION_PROCESSED':
          pass  # handle authorization
      elif event_type == 'CARDHOLDER_KYC_APPROVED':
          pass  # unlock card issuance

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

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

  function verifyFyatuSignature(string $rawBody, string $sigHeader, string $rawSecret): bool
  {
      $parts = [];
      foreach (explode(',', $sigHeader) as $part) {
          [$k, $v] = explode('=', $part, 2);
          $parts[$k] = $v;
      }
      if (empty($parts['t']) || empty($parts['v1'])) {
          return false;
      }

      // Derive signing key: hex(SHA-256(rawSecret))
      $signingKey = hash('sha256', $rawSecret);

      // Sign: HMAC-SHA256(signingKey, "{timestamp}.{body}")
      $message  = $parts['t'] . '.' . $rawBody;
      $expected = hash_hmac('sha256', $message, $signingKey);

      return hash_equals($expected, $parts['v1']);
  }

  $rawBody   = file_get_contents('php://input');
  $sigHeader = $_SERVER['HTTP_X_FYATU_SIGNATURE'] ?? '';
  $rawSecret = getenv('FYATU_WEBHOOK_SECRET');

  if (!verifyFyatuSignature($rawBody, $sigHeader, $rawSecret)) {
      http_response_code(401);
      echo json_encode(['error' => 'Invalid signature']);
      exit;
  }

  $payload   = json_decode($rawBody, true);
  $eventType = $payload['event'];
  $eventId   = $_SERVER['HTTP_X_FYATU_EVENT_ID'] ?? '';

  switch ($eventType) {
      case 'TRANSACTION_PROCESSED':
          // handle authorization
          break;
      case 'CARDHOLDER_KYC_APPROVED':
          // unlock card issuance
          break;
  }

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

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

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

  func verifyFyatuSignature(rawBody []byte, sigHeader, rawSecret string) bool {
      parts := map[string]string{}
      for _, p := range strings.Split(sigHeader, ",") {
          kv := strings.SplitN(p, "=", 2)
          if len(kv) == 2 {
              parts[kv[0]] = kv[1]
          }
      }
      timestamp, v1 := parts["t"], parts["v1"]
      if timestamp == "" || v1 == "" {
          return false
      }

      // Derive signing key: hex(SHA-256(rawSecret))
      h          := sha256.Sum256([]byte(rawSecret))
      signingKey := hex.EncodeToString(h[:])

      // Sign: HMAC-SHA256(signingKey, "{timestamp}.{body}")
      message := fmt.Sprintf("%s.%s", timestamp, rawBody)
      mac     := hmac.New(sha256.New, []byte(signingKey))
      mac.Write([]byte(message))
      expected := hex.EncodeToString(mac.Sum(nil))

      expectedBytes, err1 := hex.DecodeString(expected)
      v1Bytes, err2       := hex.DecodeString(v1)
      if err1 != nil || err2 != nil {
          return false
      }
      return hmac.Equal(v1Bytes, expectedBytes)
  }

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
      rawBody, _ := io.ReadAll(r.Body)
      sigHeader  := r.Header.Get("X-Fyatu-Signature")
      rawSecret  := os.Getenv("FYATU_WEBHOOK_SECRET")

      if !verifyFyatuSignature(rawBody, sigHeader, rawSecret) {
          http.Error(w, `{"error":"Invalid signature"}`, http.StatusUnauthorized)
          return
      }

      eventType := r.Header.Get("X-Fyatu-Event")
      switch eventType {
      case "TRANSACTION_PROCESSED":
          // handle authorization
      case "CARDHOLDER_KYC_APPROVED":
          // unlock card issuance
      }

      w.Header().Set("Content-Type", "application/json")
      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_fyatu_signature(raw_body, sig_header, raw_secret)
    parts     = sig_header.split(',').map { |p| p.split('=', 2) }.to_h
    timestamp = parts['t']
    v1        = parts['v1']
    return false if timestamp.nil? || v1.nil?

    # Derive signing key: hex(SHA-256(rawSecret))
    signing_key = OpenSSL::Digest::SHA256.hexdigest(raw_secret)

    # Sign: HMAC-SHA256(signingKey, "{timestamp}.{body}")
    message  = "#{timestamp}.#{raw_body}"
    expected = OpenSSL::HMAC.hexdigest('sha256', signing_key, message)

    Rack::Utils.secure_compare(expected, v1)
  end

  post '/webhooks/fyatu' do
    raw_body   = request.body.read
    sig_header = request.env['HTTP_X_FYATU_SIGNATURE'] || ''
    raw_secret = ENV['FYATU_WEBHOOK_SECRET']

    halt 401, { error: 'Invalid signature' }.to_json \
      unless verify_fyatu_signature(raw_body, sig_header, raw_secret)

    payload    = JSON.parse(raw_body)
    event_type = payload['event']

    case event_type
    when 'TRANSACTION_PROCESSED' then # handle it
    when 'CARDHOLDER_KYC_APPROVED' then # handle it
    end

    content_type :json
    { received: true }.to_json
  end
  ```
</CodeGroup>

## Replay Attack Prevention

The `t=` timestamp in the signature header lets you reject stale requests. Reject any delivery where the timestamp is more than **5 minutes** old:

```javascript theme={null}
const MAX_AGE_SECONDS = 5 * 60; // 5 minutes

function isFresh(sigHeader) {
  const parts = Object.fromEntries(
    sigHeader.split(',').map(p => p.split('='))
  );
  const ts = parseInt(parts['t'], 10);
  return Math.abs(Date.now() / 1000 - ts) <= MAX_AGE_SECONDS;
}
```

<Warning>
  Always enforce the 5-minute window. Without it, an attacker who captures a delivery can replay it hours or days later.
</Warning>

## Idempotency — Deduplicating Retries

FYATU retries failed deliveries up to 5 times. The same event may arrive more than once. Use `X-Fyatu-Event-ID` (also available as `eventId` in the body) to deduplicate:

```javascript theme={null}
const eventId = req.headers['x-fyatu-event-id'];

// Check if already processed
if (await db.processedEvents.exists(eventId)) {
  return res.status(200).json({ received: true }); // idempotent — already handled
}

// Mark as processing before doing work (prevent race conditions)
await db.processedEvents.insert(eventId);

// ... process the event ...
```

## Retry Schedule

FYATU retries failed deliveries (any response other than `2xx`) on this schedule:

| Attempt   | Delay after previous failure |
| --------- | ---------------------------- |
| 1st retry | \~5 minutes                  |
| 2nd retry | \~30 minutes                 |
| 3rd retry | \~2 hours                    |
| 4th retry | \~8 hours                    |
| 5th retry | \~24 hours                   |

After 5 failed attempts the delivery is marked `FAILED` permanently. You can view and manually replay failed deliveries from the portal under **Developer → Webhooks**.

<Tip>
  Respond `200 OK` within **30 seconds** — process events asynchronously using a queue. Long-running handlers that time out are treated as failures and trigger a retry.
</Tip>

## Best Practices

<CardGroup cols={2}>
  <Card title="Use raw body" icon="code">
    Always capture the raw request body bytes before JSON parsing. Re-serializing parsed JSON can change whitespace or key order, breaking the signature.
  </Card>

  <Card title="Respond quickly" icon="bolt">
    Return `200` within 30 seconds. Queue events for async processing if your handler does database writes or external API calls.
  </Card>

  <Card title="Reject stale timestamps" icon="clock">
    Enforce a 5-minute window on `t=` to prevent replay attacks where captured requests are redelivered later.
  </Card>

  <Card title="Deduplicate with Event-ID" icon="copy">
    Store processed `X-Fyatu-Event-ID` values. A retry arriving after your handler already ran should return `200` immediately without re-processing.
  </Card>

  <Card title="Rotate secrets safely" icon="arrows-rotate">
    If your webhook secret is compromised, delete the webhook endpoint and create a new one. The old secret stops working immediately.
  </Card>

  <Card title="Test with SANDBOX first" icon="flask">
    Use the portal's **Send Test Event** feature to fire a sample payload to your endpoint before going live.
  </Card>
</CardGroup>
