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

# Webhooks

> We POST an event to your server when money moves, so you never have to poll. Each one is signed, so you can tell it came from us.

## Set up an endpoint

Tell us an `https` URL for each mode at [support@flipeet.io](mailto:support@flipeet.io). If you don't give a sandbox URL, sandbox events go to your live URL, signed with the sandbox secret. You can also pass `callbackUrl` on a single deposit or withdrawal to send its events somewhere else.

Reply with any `2xx` status within 10 seconds. Do slow work, like sending emails, after you reply.

## Events

| Event | When |
| - | - |
| `checkout.completed` | A checkout was paid. |
| `transaction.crypto.completed` | A crypto deposit arrived. |
| `transaction.fiat.completed` | A bank deposit cleared and was settled. |
| `transaction.fiat.failed` | A bank deposit failed or expired. |
| `transaction.withdrawal.completed` | A withdrawal was delivered. |
| `transaction.withdrawal.failed` | A withdrawal failed and was returned to your balance. |
| `webhook.test` | You sent a test event. |

The event name is in the body's `event` field and in the `x-flipeetpay-event` header. Every event has `reference` and `livemode`. [Ramp orders](https://docs.flipeet.io/guides/ramps.md#3-follow-its-status) are described by their `status` instead.

## Headers

| Header | Holds |
| - | - |
| `x-flipeetpay-signature-v2` | `t=<unix seconds>,v1=<signature>`. Verify this one. |
| `x-flipeetpay-signature` | HMAC-SHA256 of the body alone, for integrations built before v2. |
| `x-flipeetpay-timestamp` | When we signed it, in Unix seconds. |
| `x-flipeetpay-event` | The event name. |
| `x-flipeetpay-mode` | `live` or `sandbox`. |
| `x-flipeetpay-delivery` | A unique ID for this delivery. |

## Verify signatures

Each mode has its own signing secret, which we send with your key. The v2 signature is an HMAC-SHA256, keyed with that secret, of the timestamp, a full stop, and the raw body. Because the timestamp is signed, you can refuse old deliveries that someone replays.

```javascript Node.js theme={null}
import crypto from "node:crypto";

/**
 * header: the x-flipeetpay-signature-v2 header, "t=<seconds>,v1=<hex>"
 * rawBody: the request body exactly as received, before JSON parsing
 */
export function verifyFlipeetWebhook(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    (header ?? "").split(",").map((part) => part.split("=")),
  );
  const timestamp = Number(parts.t);
  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
    return false; // missing, or too old: could be a replay
  }
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  const given = Buffer.from(parts.v1 ?? "");
  return given.length === expected.length &&
    crypto.timingSafeEqual(given, Buffer.from(expected));
}
```

```python Python theme={null}
import hashlib
import hmac
import time

def verify_flipeet_webhook(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    """header is x-flipeetpay-signature-v2: "t=<seconds>,v1=<hex>"."""
    parts = dict(part.split("=", 1) for part in (header or "").split(",") if "=" in part)
    try:
        timestamp = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - timestamp) > tolerance:
        return False  # too old: could be a replay
    expected = hmac.new(
        secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

* Use the raw body. Parsing the JSON and encoding it again changes the bytes, and the check fails. In Express, use `express.raw()` on this route.
* Compare in constant time, as above, not with `===`.
* Reject anything older than five minutes.

> [!WARNING]
>
> When we roll your webhook secret, the old one stops working at once. Ask us to roll it only when you're ready to put the new one on your server.

## Retries and duplicates

If your endpoint doesn't answer with `2xx`, we try again: deposit, withdrawal and ramp events after 5 and 30 seconds, and checkout events after 2 seconds. After that the delivery is marked failed. Find it with [List webhook deliveries](https://docs.flipeet.io/api-reference/webhooks/list-webhook-deliveries.md) and send it again with [Resend a webhook](https://docs.flipeet.io/api-reference/webhooks/resend-a-webhook.md).

You may get the same event more than once. Use `reference` and `event` to make handling it twice harmless, for example by marking an order paid only if it isn't already.

## Test your endpoint

```bash cURL theme={null}
curl -X POST "https://api.pay.flipeet.io/api/v1/webhook/test" \
  -H "x-api-key: $FLIPEET_SECRET_KEY"
```

```javascript Node.js theme={null}
const response = await fetch("https://api.pay.flipeet.io/api/v1/webhook/test", {
  method: "POST",
  headers: {
    "x-api-key": process.env.FLIPEET_SECRET_KEY,
  },
});
const { data } = await response.json();
```

```python Python theme={null}
import os
import requests

response = requests.post(
    "https://api.pay.flipeet.io/api/v1/webhook/test",
    headers={"x-api-key": os.environ["FLIPEET_SECRET_KEY"]},
)
data = response.json()["data"]
```

```json webhook.test theme={null}
{
  "event": "webhook.test",
  "livemode": false,
  "reference": "webhook-test-1762250400000",
  "message": "This is a test webhook from Flipeet Pay.",
  "sentAt": "2026-11-04T10:00:00.000Z"
}
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.