Skip to main content

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:
HeaderExampleDescription
X-Fyatu-Signaturet=1716372000,v1=abc123...Unix timestamp + HMAC-SHA256 signature
X-Fyatu-Timestamp1716372000Unix timestamp (same as t= in signature)
X-Fyatu-EventCARD_ISSUEDEvent type in UPPERCASE_SNAKE format
X-Fyatu-Event-IDevt_01HXY123456ABCDEFUnique event ID — use for deduplication
X-Fyatu-EnvironmentLIVELIVE or SANDBOX
User-AgentFyatu-Webhook/3.20Sender identification
Event names use UPPERCASE_SNAKE format: CARD_ISSUED, TRANSACTION_AUTHORIZED, 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.

Webhook Payload Envelope

All events share the same outer envelope:
{
  "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"
  }
}
FieldDescription
eventEvent type — matches X-Fyatu-Event header
eventIdUnique event ID — matches X-Fyatu-Event-ID header
businessIdYour business identifier
environmentLIVE or SANDBOX
timestampISO 8601 event timestamp
dataEvent-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

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_AUTHORIZED':
      // 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 });
});

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:
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;
}
Always enforce the 5-minute window. Without it, an attacker who captures a delivery can replay it hours or days later.

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:
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:
AttemptDelay 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.
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.

Best Practices

Use raw body

Always capture the raw request body bytes before JSON parsing. Re-serializing parsed JSON can change whitespace or key order, breaking the signature.

Respond quickly

Return 200 within 30 seconds. Queue events for async processing if your handler does database writes or external API calls.

Reject stale timestamps

Enforce a 5-minute window on t= to prevent replay attacks where captured requests are redelivered later.

Deduplicate with Event-ID

Store processed X-Fyatu-Event-ID values. A retry arriving after your handler already ran should return 200 immediately without re-processing.

Rotate secrets safely

If your webhook secret is compromised, delete the webhook endpoint and create a new one. The old secret stops working immediately.

Test with SANDBOX first

Use the portal’s Send Test Event feature to fire a sample payload to your endpoint before going live.