---
title: "Receive webhooks"
description: "Get a signed call from Fluveo each time something happens in your account, check it, and act on it."
url: https://docs.fluveo.com/webhooks/
---

> Index of every page: https://docs.fluveo.com/llms.txt. Integrating Fluveo? Read https://docs.fluveo.com/agents/integrate.md first.

> Too long? This page with code in one language only: https://docs.fluveo.com/webhooks.curl.md (cURL), https://docs.fluveo.com/webhooks.node.md (Node.js), https://docs.fluveo.com/webhooks.python.md (Python), https://docs.fluveo.com/webhooks.php.md (PHP).

# Receive webhooks

Get a signed call from Fluveo each time something happens in your account, check it, and act on it.

Many things in a payment's life happen after your API call has already returned. The buyer finishes a 3D Secure
check on their phone. A Pix code gets paid from a bank app ten minutes later, a Boleto three days later. A refund
that was accepted bounces back. A cardholder questions a sale a month on. For each of these, Fluveo records an
**event** and delivers it to your server as a **webhook**: a signed `POST` with the event as JSON, sent to a URL
you chose.

Use webhooks to fulfil orders, send receipts, and keep your own records in step with Fluveo. Never fulfil an
order because the buyer came back to your success page: the browser can close, and a return URL proves
nothing. Fulfil when the event arrives.

> **For AI agents:**
>
> - Fulfil an order only on a verified event (`payment_intent.succeeded`), never on a browser redirect or on the response of the create call.
> - Every delivery carries the `Fluveo-Signature` header. Verify it on the raw body before you parse it.
> - Store each `evt_` id and skip an id you already handled: the same event can arrive twice.
> - Do not assume events arrive in order. Read `created`, or fetch the object again with GET.
> - Answer 2xx within a few seconds, before any slow work. Fluveo waits at most 25 seconds.
> - A live-mode webhook URL must be public `https`. A test-mode URL may also be plain `http`. In both modes `localhost`, private IPs and internal host names are refused.
> - There is no CLI and no SDK. Use plain HTTP.

## How it works

1. You register an endpoint: a public URL (`https`, or `http` in test mode) and the event types it wants.
2. Something changes in your account, for example a PaymentIntent succeeds. Fluveo records an event.
3. Fluveo sends the event as JSON to every enabled endpoint that asked for its type, signed with that
   endpoint's secret.
4. Your server checks the signature, stores the event, answers `2xx`, and then does the work.
5. If your server does not answer `2xx`, Fluveo tries again later, on a fixed schedule.

## The event object

Every webhook body is one event. `data.object` is the object at the moment of the change, with the same fields and
values `GET` on the object returned then, the buyer's name, e-mail, phone, address, shipping and tax id included,
and a Charge's `receipt_url`. No field is cut or sent `null` for the event.
Some events also include `data.previous_attributes` with field values from before the change.

*A payment_intent.succeeded event (trimmed)*

```json
{
  "id": "evt_...",
  "object": "event",
  "api_version": "2026-05-27.dahlia",
  "created": 1762500000,
  "data": {
    "object": {
      "id": "pi_...",
      "object": "payment_intent",
      "amount": 2000,
      "currency": "usd",
      "status": "succeeded"
    }
  },
  "livemode": false,
  "pending_webhooks": 1,
  "request": {
    "id": "req_...",
    "idempotency_key": "order-1042-pay"
  },
  "type": "payment_intent.succeeded"
}
```

**Event fields**

- `id` (string): Unique id of the event, starting with `evt_`. Use it to skip duplicates.
- `object` (string): Always `event`.
- `api_version` (string): The API version the body follows. Always `2026-05-27.dahlia`, Fluveo's only version.
- `created` (integer): When the event happened, in Unix seconds.
- `data` (object): The object the event is about.
  - `object` (object): The object the event is about, as it was at the change, buyer details included. Amounts are in the smallest currency unit: `2000` is $20.00 in `usd` and R$ 20,00 in `brl`.
  - `previous_attributes` (object, optional): When present, old field values from before the change. This can also appear on events such as `charge.captured`, `charge.refunded` and `refund.failed`.
- `livemode` (boolean): `false` for events made with a `sk_test_` key.
- `pending_webhooks` (integer): How many endpoints have not yet received this event successfully, this delivery included.
- `request` (object): The API request that caused the event.
  - `id` (string, nullable): The `req_` id of that request, or `null` when no API call caused it (a buyer paid a Pix code, a bank opened a dispute).
  - `idempotency_key` (string, nullable): The `Idempotency-Key` that request carried. For a POST sent without one, the key Fluveo made for it, which the answer's `Idempotency-Key` header also shows.
- `type` (string): What happened, such as `payment_intent.succeeded` or `charge.refunded`. See [all event types](https://docs.fluveo.com/webhooks/events.md).

You can read the same events with the API for 30 days: `GET /v1/events` and `GET /v1/events/{id}`. See
[the events reference](https://docs.fluveo.com/api/events.md).

## Create an endpoint

Each endpoint has its own URL, its own list of event types and its own signing secret. A store may have up to
16 endpoints, in test mode and in live mode separately.

### Step 1: Pick a public URL

The URL must be one that anyone on the internet can reach, up to 2,048 characters, with no user name, password or
`#fragment`. A live-mode endpoint needs `https`: an `http` URL is refused with a 400, `Invalid URL: URL must use https.` A test-mode endpoint takes `https` or plain `http`, so you can try your handler before it has a TLS
certificate. Whatever the scheme, `localhost`, `*.localhost`, `*.local` and `*.internal` names, and private,
loopback, link-local or other reserved IP addresses written in the URL, are refused when you create the
endpoint. A host name is not looked up then: if it resolves to such an address, every delivery to it fails.
To receive events on your own computer, see [Test webhooks](https://docs.fluveo.com/webhooks/testing.md).

### Step 2: Register it with the API

Send `url` and at least one `enabled_events[]`. Use `*` to receive every type.

**Create a webhook endpoint**

cURL:

```bash
curl https://api.fluveo.com/v1/webhook_endpoints \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: webhook-orders-v1" \
  -d url="https://example.com/webhooks/fluveo" \
  -d "enabled_events[]"=payment_intent.succeeded \
  -d "enabled_events[]"=payment_intent.payment_failed \
  -d "enabled_events[]"=charge.refunded \
  -d description="Order fulfilment"
```

Node.js:

```javascript
const response = await fetch("https://api.fluveo.com/v1/webhook_endpoints", {
  method: "POST",
  headers: {
    Authorization: "Bearer sk_test_...",
    "Idempotency-Key": "webhook-orders-v1",
  },
  body: new URLSearchParams([
    ["url", "https://example.com/webhooks/fluveo"],
    ["enabled_events[]", "payment_intent.succeeded"],
    ["enabled_events[]", "payment_intent.payment_failed"],
    ["enabled_events[]", "charge.refunded"],
    ["description", "Order fulfilment"],
  ]),
});
const endpoint = await response.json();
// Save endpoint.secret (whsec_...) in your secret store now.
```

Python:

```python
import requests

response = requests.post(
    "https://api.fluveo.com/v1/webhook_endpoints",
    headers={
        "Authorization": "Bearer sk_test_...",
        "Idempotency-Key": "webhook-orders-v1",
    },
    data=[
        ("url", "https://example.com/webhooks/fluveo"),
        ("enabled_events[]", "payment_intent.succeeded"),
        ("enabled_events[]", "payment_intent.payment_failed"),
        ("enabled_events[]", "charge.refunded"),
        ("description", "Order fulfilment"),
    ],
)
endpoint = response.json()
# Save endpoint["secret"] (whsec_...) in your secret store now.
```

PHP:

```php
<?php
$ch = curl_init('https://api.fluveo.com/v1/webhook_endpoints');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer sk_test_...',
        'Idempotency-Key: webhook-orders-v1',
    ],
    CURLOPT_POSTFIELDS => implode('&', [
        'url=' . urlencode('https://example.com/webhooks/fluveo'),
        'enabled_events[]=payment_intent.succeeded',
        'enabled_events[]=payment_intent.payment_failed',
        'enabled_events[]=charge.refunded',
        'description=' . urlencode('Order fulfilment'),
    ]),
]);
$endpoint = json_decode(curl_exec($ch), true);
// Save $endpoint['secret'] (whsec_...) in your secret store now.
```

### Step 3: Save the secret from the answer

The answer carries the endpoint's `secret` (`whsec_...`): **save it now.** GET and list responses never include
it. Put it in your secret store or an environment variable, never in code or a repository: your handler needs it
to check every delivery. A repeat of the create with the same `Idempotency-Key` and the same body within 24 hours
gives back the same endpoint, secret included while the endpoint is unchanged, with `Idempotent-Replayed: true`,
so a retried call never makes a second endpoint.

*Response*

```json
{
  "id": "we_...",
  "object": "webhook_endpoint",
  "api_version": null,
  "application": null,
  "created": 1762500000,
  "description": "Order fulfilment",
  "enabled_events": [
    "payment_intent.succeeded",
    "payment_intent.payment_failed",
    "charge.refunded"
  ],
  "livemode": false,
  "metadata": {},
  "status": "enabled",
  "url": "https://example.com/webhooks/fluveo",
  "secret": "whsec_..."
}
```

### Body parameters

**Create parameters**

- `url` (string, required): The public URL Fluveo posts to: `https` in live mode, `https` or `http` in test mode.
- `enabled_events` (array of strings, required): The event types to send, as `enabled_events[]=type`, up to 256. `*` means every type. An unknown type is a 400 with `param` set to `enabled_events[n]`.
- `description` (string, optional): A note for you, up to 5,000 characters.
- `metadata` (object, optional): Your own `metadata[key]=value` pairs: up to 50 keys, keys up to 40 characters, values up to 500.
- `api_version` (string, optional): Only `2026-05-27.dahlia` is accepted. Any other value is a 400.

`connect=true` is accepted, but that endpoint receives no events from your account, except one you resend to it
yourself: leave `connect` out, or send `connect=false`, for an endpoint that receives your events. `connect` is not
returned and an update cannot change it. `disabled` cannot be set on create.

### In the Dashboard

Open **Webhooks** in the [Dashboard](https://dash.fluveo.com) and choose **Add endpoint**. Enter the URL, pick the
event types and save. The Dashboard shows the `whsec_` secret once, right after you create the endpoint: copy
it then. The Dashboard form follows the same URL rule as the API: `https`, or plain `http` for a test endpoint, and
never `localhost`, a private address or an internal name.

## Manage endpoints

| Call | What it does |
| --- | --- |
| `GET /v1/webhook_endpoints` | Lists your endpoints, newest first. Accepts `limit` (1 to 100, default 10), `starting_after` and `ending_before`. |
| `GET /v1/webhook_endpoints/{id}` | Reads one endpoint. The secret is never in the answer. |
| `POST /v1/webhook_endpoints/{id}` | Changes `url`, `enabled_events[]`, `description`, `metadata` or `disabled` (`true` or `false`). New `enabled_events` replace the old list. |
| `DELETE /v1/webhook_endpoints/{id}` | Deletes the endpoint. Pending deliveries to it stop. |

A disabled endpoint (`status: "disabled"`) receives nothing: deliveries still waiting for it fail, and events
that happen while it is disabled are not queued for it, so they are not sent when you enable it again. Read
what you missed with `GET /v1/events?created[gte]=<unix time it was disabled>`.

Fluveo also disables an endpoint on its own after its deliveries have kept failing for 9 days in a row. The 9
days count from the oldest delivery that failed since the endpoint's last successful delivery or its last
change, so a success or an update of the endpoint starts the count again. Send `disabled=false` to turn it
back on. It keeps the same secret.

To get a new secret, use **Rotate secret** on the endpoint in the Dashboard, and choose when the current secret
stops working: **Now**, or in 1 hour, 24 hours, 3 days or 7 days. Until then both secrets sign every delivery. The
new secret is shown once. The API cannot rotate a secret. See [Rotating the
secret](https://docs.fluveo.com/webhooks/signatures.md#rotating-the-secret).

## Answer quickly with a 2xx

A delivery is done only when your server replies with a status between `200` and `299`. Fluveo treats every
other outcome as a failed try and schedules another:

- any `3xx`, `4xx` or `5xx` status (Fluveo does not follow redirects);
- no answer within 25 seconds;
- a connection or TLS error.

Keep the request short: check the signature, write the event down, reply `200`, and leave e-mails, stock
updates and calls to other systems to a job that runs after the reply. Reply `2xx` to types you do not use too.
Reply `400` only to a bad signature.

## Retries

When a delivery fails, Fluveo sends the same event again later, with a fresh signature each time. The wait
after each failed try is at least:

| Mode | Retries | Wait before each retry |
| --- | --- | --- |
| Test (`sk_test_`) | 3 | 30 min, 1 h, 2 h |
| Live | 10 | 1 min, 5 min, 30 min, 2 h, 5 h, 10 h, then 12 h four times |

After the last retry the delivery is marked failed and Fluveo does not send it again on its own; **Resend** on the
delivery in the Dashboard sends it once more, for an event of the last 30 days. A test event is tried 4 times in
about 3.5 hours; a live event 11 times in about 2.7 days. To catch up after an outage, list the events that
still have a delivery not received with `GET /v1/events?delivery_success=false`, or every event since a time
with `created[gte]=<unix time>`. Events stay readable for 30 days.

> **Live mode:** Only test mode is open today: a `sk_live_` key is refused with a `403`, so every delivery follows the test row.

## Duplicates and order

- **Expect repeats.** If your `200` is lost on the way back, Fluveo sends the event again, and the repeat has the
  same `evt_` id as the first copy. Keep the ids you have handled in a table where `id` is unique, and treat a
  second insert of the same id as "already done". The `evt_` id is the only safe key: two partial refunds of one
  charge give two events with the same `data.object.id` and the same `type`.
- **Expect any order.** Deliveries run side by side and each retry keeps its own clock, so
  `payment_intent.succeeded` can land before `charge.succeeded`, and an old event's retry after a newer event.
  When order matters, compare `created`, or read the object (`GET /v1/payment_intents/{id}`) and trust its
  `status` now.
- **Each endpoint gets its own copy.** Two endpoints that both ask for a type each receive the event, each
  signed with its own secret.

## Fulfil on the event

A good handler, in order:

1. Read the raw body and check `Fluveo-Signature` ([how](https://docs.fluveo.com/webhooks/signatures.md)). Answer `400` if it fails.
2. Parse the JSON. If you already stored this `id`, answer `200` and stop.
3. Store the event, answer `200`.
4. Later, from your queue: switch on `type`. For `payment_intent.succeeded`, find your order by the
   PaymentIntent `id` (or by `metadata` you set when you created it), check the `amount` and `currency`
   against the order, and mark it paid exactly once.

Useful types to start with: `payment_intent.succeeded` (paid: fulfil), `payment_intent.payment_failed` (ask
the buyer to try again), `charge.refunded` and `refund.failed` (money going back), `charge.dispute.created`
(a dispute opened). The full list, with what each one means, is on [Event types](https://docs.fluveo.com/webhooks/events.md).

## Next

- [Check webhook signatures](https://docs.fluveo.com/webhooks/signatures.md): Verify `Fluveo-Signature` with a few lines of Node, Python or PHP.
- [Test webhooks](https://docs.fluveo.com/webhooks/testing.md): Receive test events on your own machine and trigger each event type.
- [Event types](https://docs.fluveo.com/webhooks/events.md): Every event Fluveo sends and when.
- [Webhook endpoints reference](https://docs.fluveo.com/api/webhook_endpoints.md): Every parameter of the endpoint calls.
