# Guide to Felloh Webhooks: Features and Best Practices

Felloh handles card payments and customer money for UK travel businesses, allocating a protected portion of each payment to trust until the date of travel and keeping it tied to the booking for ATOL reporting. A tour operator or travel agent using Felloh needs its booking system to know when a payment succeeds, when a refund moves on, or when a booking is added or removed. Felloh's webhooks send those changes as they happen, so the booking platform doesn't have to poll the API.

This guide covers how to register a Felloh webhook, how to tell its payloads apart when none of them names its event, how to verify `X-Signature`, and how to cope with a retry policy that applies only to transaction events.

## What are Felloh webhooks?

A Felloh webhook is created in the dashboard with a URL, a set of event kinds and, optionally, child organisations, so that a parent business can receive events for the brands or branches under it. Felloh generates a signing key when you save and shows it once.

Every delivery is a JSON `POST` signed with that key in `X-Signature`, a bare hex HMAC-SHA256 of the body. The body is the resource itself, with no event name and no shared envelope, so your receiver has to recognise a transaction, refund, booking or user from its fields.

## Felloh webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard, **Developer Configuration** |
| Scope | Your organisation, optionally with child organisations |
| Signature | hex HMAC-SHA256 |
| Signature header | `x-signature` |
| Signed content | The raw request body |
| Secret | Per webhook signing key, shown once at creation |
| Body | JSON, `Content-Type: application/json`, no event field |
| Timeout | 30 seconds; any non-`2xx` is a failure |
| Retries | Transaction status only: up to 5, about a minute apart |
| Logs | Delivery logs kept for 30 days |
| Testing | **Webhook Playground**, with `X-Felloh-Test: true` on test events |

## Events and payloads

| Event (label) | Triggered When | Common Use Cases |
| --- | --- | --- |
| `transaction-status` | A transaction changes status | Mark an order paid, reconcile payments |
| `refund-status` | A refund changes status | Update refund state, notify the customer |
| `booking` | A booking is created | Sync bookings into your CRM |
| `user-create` | A Felloh user is created | Provision access, audit new users |

The booking kind covers two documented events, Booking Creation (`booking:create`) and Booking Deletion (`booking:delete`), and both use the same payload. A deletion is recognisable only by a non-null `deleted_at`.

Since no payload says what it is, check the shape in this order:

1. `user` is an object: a user creation.
2. `transaction` and `status` are both objects: a refund status change (refunds also carry an `authorisation_code`).
3. `transaction` is an object and `status` is a string: a transaction status change.
4. `booking` is an object and there is no `transaction`: a booking, deleted if `deleted_at` is set.

Acknowledge and log anything that matches none of these. This is the `booking` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/felloh/latest/booking.json):

```json
{
  "booking": {
    "id": "3d38e53b-867a-43bd-ad5d-4af786dd1b33",
    "booking_reference": "FE123456"
  },
  "customer_name": "Caroline Rennie",
  "email": "caroline@example.com",
  "amount": 250000,
  "currency": "GBX",
  "departure_date": "2027-05-01",
  "return_date": "2027-05-14",
  "customer": {
    "id": "5c2d8e1f-4a6b-4c8d-9e0f-1a2b3c4d5e6f"
  },
  "created_at": "2026-10-16T11:04:27.000Z",
  "deleted_at": null
}

```

Felloh gives `amount` in the lowest denomination of the currency, which is pence for `GBX`, and `amount` can be `null` for a booking created without a value. Felloh publishes no deletion example, so capture a `booking:delete` delivery in testing before relying on its other fields.

## Setting up Felloh webhooks

1. In the Felloh dashboard, open **Developer Configuration** and create a webhook.
2. Enter the **URL** of your endpoint, for example `https://example.com/webhooks/felloh`.
3. Select the events to listen for: transaction status, refund status, booking and user events.
4. Optionally add child organisations whose events should also be sent.
5. Save, and copy the **signing key** straight away into your server's configuration, for example as `FELLOH_SIGNING_KEY`. It is never shown again, so if you lose it you have to create a new webhook. The key is used as typed, not decoded.

Use the dashboard's **Webhook Playground** to send a test event of any kind, either with sample data or as a replay of a real event. Test deliveries carry `X-Felloh-Test: true`, are signed with the real key, and are never retried.

To use Hookdeck, create a source with the Felloh source type, paste the signing key as its verification secret, and register the source URL in Felloh. For local development, run `npx hookdeck-cli listen 3000 felloh --path /webhooks/felloh` with the [Hookdeck CLI](/docs/cli).

## Securing Felloh webhooks

Take the whole `X-Signature` value, check it is 64 hex characters, and compare it in constant time with a hex HMAC-SHA256 of the raw body keyed with your signing key. There is no prefix, list or timestamp part. Felloh's [webhook documentation](https://developers.felloh.com/webhooks) includes the same check in several languages.

```javascript
const crypto = require('node:crypto');

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature];
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

The handler classifies the verified body by shape:

```javascript
const express = require('express');

const app = express();

const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);

function classify(body) {
  if (isObject(body.user)) return 'user';
  if (isObject(body.transaction) && isObject(body.status)) return 'refund';
  if (isObject(body.transaction) && typeof body.status === 'string') return 'transaction';
  if (isObject(body.booking)) return body.deleted_at ? 'booking_deleted' : 'booking_created';
  return 'unknown';
}

app.post('/webhooks/felloh', express.raw({ type: '*/*' }), async (req, res) => {
  const signingKey = process.env.FELLOH_SIGNING_KEY;
  if (!signingKey) return res.sendStatus(500);
  if (!verifySignature(req.body, req.headers, signingKey)) return res.sendStatus(401);

  let body;
  try {
    body = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }
  if (!body || typeof body !== 'object' || Array.isArray(body)) return res.sendStatus(400);
  try {
    const isTest = req.get('x-felloh-test') === 'true';
    await storeFellohEvent(classify(body), body, { isTest });
  } catch {
    return res.sendStatus(503);
  }

  res.sendStatus(200);
});

```

`storeFellohEvent` writes the event somewhere durable for processing after the response. Keep test events out of your real booking data by checking `isTest`.

Felloh signs no timestamp, so a matching signature doesn't prove the request is new. Hookdeck's Felloh source checks the same `x-signature` HMAC. It acknowledges Felloh with `200` straight away and flags any request whose signature doesn't match as failed verification, rather than returning `401`.

> **Make Felloh webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies `X-Signature`, stores every transaction, refund, booking and user event, and retries delivery to your booking system, including for the events Felloh itself sends only once.

## Felloh webhook limitations and pain points

### Most events are sent once

**The Problem:** A refund, booking or user event that hits an outage on your side is lost.

**Why It Happens:** Felloh retries transaction status events up to 5 times, about a minute apart. Refund status, booking and user events are sent once and never retried. Any non-`2xx` or a response slower than 30 seconds counts as failed.

**Workarounds:**

* Keep the endpoint highly available and respond quickly.
* Check the delivery logs, kept for 30 days, after any incident, and reconcile refunds and bookings through the Felloh API.

**How Hookdeck Can Help:** Hookdeck receives Felloh's single attempt and stores it, then retries your service as often as you configure, so a one-shot event survives your downtime.

### No event name

**The Problem:** Your handler has to guess what kind of event it received.

**Why It Happens:** Bodies are bare resources. None carries an event, type or action field, and the booking creation and deletion events share a payload.

**Workarounds:**

* Classify by shape, in a fixed order, as shown above.
* Use a separate webhook URL per event kind if you'd rather route by path.

**How Hookdeck Can Help:** Hookdeck filters can match on the presence of fields such as `transaction` or `booking`, so each kind can go to its own destination.

### The signing key is shown once

**The Problem:** A lost or leaked key means creating a new webhook.

**Why It Happens:** Felloh displays the key only when the webhook is created.

**Workarounds:**

* Copy the key into a secret manager before leaving the page.
* If you need a new key, create the replacement webhook first and remove the old one once the new key is deployed.

**How Hookdeck Can Help:** With Hookdeck in front, the key lives on the Hookdeck source, and replacing it doesn't involve redeploying every service that consumes Felloh events.

### No delivery ID

**The Problem:** Duplicate transaction retries are hard to detect.

**Why It Happens:** There is no event or delivery ID in the body or headers.

**Workarounds:**

* Deduplicate on the resource ID plus its status, for example transaction ID and status.

**How Hookdeck Can Help:** Hookdeck's deduplication can compare those fields and drop repeats before they reach you.

## Best practices

### Verify the raw body

Use `express.raw({ type: '*/*' })` in Express, `await request.arrayBuffer()` in Next.js and `await request.body()` in FastAPI. Middleware that re-encodes or decompresses and rewrites the body breaks the signature.

### Respond within 30 seconds

Store the event and return `200` first. With no retries for most event kinds, a slow handler means a lost event. For the pattern, see [handling webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Treat test events as real requests

Test events are signed with the live key, so they pass verification. Use `X-Felloh-Test` to keep them out of production data, not to skip the signature check.

### Make updates idempotent

Retried transaction events repeat exactly. Our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency) explains how to store a key alongside the change it caused.

## Conclusion

Felloh sends transaction, refund, booking and user changes as bare JSON resources, each signed with a hex HMAC-SHA256 in `X-Signature`. Verify against the raw body, classify payloads by shape, answer within 30 seconds, and remember that only transaction status events are retried, so every other kind gets one chance to reach you.

[Hookdeck Event Gateway](https://hookdeck.com) verifies each Felloh delivery and keeps it until your booking system has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Felloh webhooks reliably in minutes.