---
title: "Test webhooks"
description: "Receive test events on your own computer and make Fluveo send each event type with test API calls."
url: https://docs.fluveo.com/webhooks/testing/
---

> 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/testing.curl.md (cURL), https://docs.fluveo.com/webhooks/testing.node.md (Node.js), https://docs.fluveo.com/webhooks/testing.python.md (Python), https://docs.fluveo.com/webhooks/testing.php.md (PHP).

# Test webhooks

Receive test events on your own computer and make Fluveo send each event type with test API calls.

Everything on this page uses a `sk_test_...` key. Test events are real events: they are signed, retried and
listed by `GET /v1/events` exactly like live ones, with `"livemode": false`.

> **For AI agents:**
>
> - A test-mode webhook URL may be `https` or plain `http`, but it must be public: `http://localhost` and private IPs are refused with a 400 on `url`. Use a tunnel the developer runs on their own machine.
> - To make events, call the API with test values (`pm_card_visa` and the other `pm_card_*` names). There is no CLI `trigger` and no `/v1/test_helpers`.
> - After a test call, check the event with `GET /v1/events?type=<type>&limit=1` before you wait for the webhook.
> - Delete test endpoints that point at a tunnel when you are done: a dead URL just fails and retries.

## Receive events on your computer

Your laptop has no public address, and Fluveo delivers only to one. A test endpoint takes `http` as well as
`https`, so the scheme is not the problem: being reachable is. The usual bridge is a tunnel: a
small program on your machine that opens a public address and passes every request it gets to a port
on your laptop. Pick a tunnel your team already trusts. Shut it down when you finish: while it runs, anyone who
learns the address can send requests to your machine.

### Step 1: Start your handler

Run one of the [signature handlers](https://docs.fluveo.com/webhooks/signatures.md#complete-handlers). Each listens on port 4242 at
`/webhooks/fluveo`.

```bash
export FLUVEO_WEBHOOK_SECRET=whsec_placeholder   # replaced in step 4
node server.mjs
```

### Step 2: Start a tunnel to port 4242

Start your tunnel tool and point it at `http://localhost:4242`. It prints a public address such as
`https://random-name.example-tunnel.dev`. Your webhook URL is that address plus `/webhooks/fluveo`.

### Step 3: Create a test endpoint with that URL

Ask for `*` while you explore, so every event reaches you.

**Create a test endpoint**

cURL:

```bash
curl https://api.fluveo.com/v1/webhook_endpoints \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: local-tunnel-endpoint-1" \
  -d url="https://random-name.example-tunnel.dev/webhooks/fluveo" \
  -d "enabled_events[]=*" \
  -d description="Local testing"
```

Node.js:

```javascript
const response = await fetch("https://api.fluveo.com/v1/webhook_endpoints", {
  method: "POST",
  headers: { Authorization: "Bearer sk_test_...", "Idempotency-Key": "local-tunnel-endpoint-1" },
  body: new URLSearchParams([
    ["url", "https://random-name.example-tunnel.dev/webhooks/fluveo"],
    ["enabled_events[]", "*"],
    ["description", "Local testing"],
  ]),
});
const { id, secret } = await response.json();
```

Python:

```python
import requests

response = requests.post(
    "https://api.fluveo.com/v1/webhook_endpoints",
    headers={"Authorization": "Bearer sk_test_...", "Idempotency-Key": "local-tunnel-endpoint-1"},
    data=[
        ("url", "https://random-name.example-tunnel.dev/webhooks/fluveo"),
        ("enabled_events[]", "*"),
        ("description", "Local testing"),
    ],
)
endpoint = response.json()
```

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: local-tunnel-endpoint-1'],
    CURLOPT_POSTFIELDS => 'url=' . urlencode('https://random-name.example-tunnel.dev/webhooks/fluveo')
        . '&enabled_events[]=*&description=' . urlencode('Local testing'),
]);
$endpoint = json_decode(curl_exec($ch), true);
```

### Step 4: Give the handler the secret

Copy `secret` (`whsec_...`) from the answer, set `FLUVEO_WEBHOOK_SECRET` to it and restart the handler.

### Step 5: Make an event

Run any call from [the table below](https://docs.fluveo.com/webhooks/testing.md#trigger). Your handler prints `received payment_intent.succeeded evt_...`
within seconds.

When the tunnel address changes, move the endpoint instead of making a new one:
`POST /v1/webhook_endpoints/{id}` with the new `url`. When you are finished, delete it with
`DELETE /v1/webhook_endpoints/{id}`.

> **Missed a delivery?:** A test delivery that failed is tried again after 30 minutes, 1 hour and 2 hours. You do not have to wait: read
> the event with `GET /v1/events/{id}` and feed its JSON to your code, or list what is still undelivered with
> `GET /v1/events?delivery_success=false`.

## Send a test event from the Dashboard

On an endpoint's page under **Webhooks** in the [Dashboard](https://dash.fluveo.com), **Send test event** posts one
signed sample event of a type you pick to the endpoint's URL and shows the HTTP status and the first part of
your answer. Use it to check that your URL is reachable and that your signature check passes.

A Dashboard test event is not a real event: it is not listed by `GET /v1/events` and it is never retried. It goes
where a real test delivery goes: an `http` or `https` URL on any port, never `localhost`, a private address or an
internal name. To test your business logic, make real test events with the API calls below.

You can send one only in test mode, only of a type the endpoint subscribes to, and at most 5 per endpoint
and 20 per store each minute; more are refused until the minute passes. Fluveo waits up to 10 seconds for
your answer. The test event is signed with the endpoint's own secret and carries the same headers as a real
delivery. Tell it apart by its body: the top-level `fluveo_test_event` is `true`, `livemode` is `false`, the
event id starts with `evt_test_`. Object ids, when present, carry `_test_`, such as `pi_test_...`.

## Make each event with test calls

Each row is an API call made with `sk_test_...` and the events it causes, in the usual order. Remember that
your endpoint may receive them in another order. Amounts are in the smallest currency unit: `2000` is $20.00.

| What to do | Call | Events |
| --- | --- | --- |
| A card payment that succeeds | `POST /v1/payment_intents` with `amount=2000`, `currency=usd`, `payment_method_types[]=card`, `payment_method=pm_card_visa`, `confirm=true` | `payment_intent.created`, `charge.succeeded`, `payment_intent.succeeded`, then `charge.updated` a few seconds later, when the payment's balance line is ready |
| A card that is declined | The same call with `payment_method=pm_card_visa_chargeDeclined` (or another decline card) | `payment_intent.created`, `charge.failed`, `payment_intent.payment_failed` |
| Authorize now, capture later | The success call plus `capture_method=manual` | `payment_intent.created`, `charge.succeeded`, `payment_intent.amount_capturable_updated` |
| Capture it | `POST /v1/payment_intents/{id}/capture` | `charge.captured`, `payment_intent.succeeded` |
| Cancel an authorized payment | `POST /v1/payment_intents/{id}/cancel` | `payment_intent.canceled` |
| Refund | `POST /v1/refunds` with `payment_intent=pi_...` | `refund.created`, `charge.refunded` |
| A refund that fails later | Pay with `payment_method=pm_card_refundFail`, then refund it | `refund.created`, `charge.refunded`, then `refund.failed` and `charge.refund.updated` |
| A dispute | Pay with `payment_method=pm_card_createDispute` | the payment events and `charge.dispute.created`: the dispute is already open when the payment answers |
| Customers | `POST /v1/customers`, `POST /v1/customers/{id}`, `DELETE /v1/customers/{id}` | `customer.created`, `customer.updated`, `customer.deleted` |
| A Checkout Session that expires | `POST /v1/checkout/sessions/{id}/expire` on an open session | `checkout.session.expired` |

The decline cards are `pm_card_visa_chargeDeclined`, `pm_card_visa_chargeDeclinedInsufficientFunds`,
`pm_card_chargeDeclinedExpiredCard`, `pm_card_chargeDeclinedIncorrectCvc` and
`pm_card_chargeDeclinedProcessingError`. The dispute cards are `pm_card_createDispute`,
`pm_card_createDisputeProductNotReceived` and `pm_card_createDisputeInquiry`. For what each event means, see
[Event types](https://docs.fluveo.com/webhooks/events.md).

**Make a payment_intent.succeeded event**

cURL:

```bash
curl https://api.fluveo.com/v1/payment_intents \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: webhook-test-order-1" \
  -d amount=2000 \
  -d currency=usd \
  -d "payment_method_types[]=card" \
  -d payment_method=pm_card_visa \
  -d confirm=true
```

Node.js:

```javascript
const response = await fetch("https://api.fluveo.com/v1/payment_intents", {
  method: "POST",
  headers: { Authorization: "Bearer sk_test_...", "Idempotency-Key": "webhook-test-order-1" },
  body: new URLSearchParams({
    amount: "2000",
    currency: "usd",
    "payment_method_types[]": "card",
    payment_method: "pm_card_visa",
    confirm: "true",
  }),
});
const paymentIntent = await response.json();
```

Python:

```python
import requests

payment_intent = requests.post(
    "https://api.fluveo.com/v1/payment_intents",
    headers={"Authorization": "Bearer sk_test_...", "Idempotency-Key": "webhook-test-order-1"},
    data={"amount": 2000, "currency": "usd", "payment_method_types[]": "card", "payment_method": "pm_card_visa", "confirm": "true"},
).json()
```

PHP:

```php
<?php
$ch = curl_init('https://api.fluveo.com/v1/payment_intents');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_test_...', 'Idempotency-Key: webhook-test-order-1'],
    CURLOPT_POSTFIELDS => http_build_query([
        'amount' => 2000,
        'currency' => 'usd',
        'payment_method_types' => ['card'],
        'payment_method' => 'pm_card_visa',
        'confirm' => 'true',
    ]),
]);
$paymentIntent = json_decode(curl_exec($ch), true);
```

Then confirm that Fluveo recorded the event, without waiting for the webhook:

```bash
curl -G https://api.fluveo.com/v1/events \
  -H "Authorization: Bearer sk_test_..." \
  -d type=payment_intent.succeeded \
  -d limit=1
```

The event's `data.object.id` is your PaymentIntent, and its `request.idempotency_key` is
`webhook-test-order-1`.

### Pix and Boleto

Creating and confirming a test Pix or Boleto sends `payment_intent.created`, then
`payment_intent.requires_action`. Fluveo's test bank then pays it, and the payer email in
`payment_method_data[billing_details][email]` sets the moment:

- `succeed_immediately@example.com` is paid within seconds. Your endpoint gets `charge.succeeded` and
  `payment_intent.succeeded`, then `charge.updated`.
- Any other address, such as `ada@example.com`, sends the same events about 3 minutes after the confirm.
- `expire_immediately@example.com` is never paid. Give a Pix
  `payment_method_options[pix][expires_after_seconds]=60` and, a few minutes later, your endpoint gets
  `charge.failed` and `payment_intent.payment_failed`.

To pay one that is still waiting, open its test page from `hosted_instructions_url` (Pix) or
`hosted_voucher_url` (Boleto). See [Pix](https://docs.fluveo.com/payments/pix.md#test-it) and [Boleto](https://docs.fluveo.com/payments/boleto.md#test-it).

## Before you go live

- Your handler checks `Fluveo-Signature` and answers `400` on a bad signature. Send it a request with a
  wrong signature and check that it is refused.
- It answers `2xx` within a few seconds, and does slow work later.
- Sending the same event twice changes nothing the second time.
- It handles events in any order.
- The endpoint's `enabled_events` holds the types your code acts on and nothing more, instead of `*`.

## Next

- [Receive webhooks](https://docs.fluveo.com/webhooks.md): Retries, duplicates, ordering and fulfilment.
- [Check webhook signatures](https://docs.fluveo.com/webhooks/signatures.md): The full verification recipe.
- [Testing](https://docs.fluveo.com/start/testing.md): Every test card and test value.
