Docs
API 2026-05-27.dahlia Dashboard

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.

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.

  1. Start your handler#

    Run one of the signature handlers. Each listens on port 4242 at /webhooks/fluveo.

    bash
    export FLUVEO_WEBHOOK_SECRET=whsec_placeholder   # replaced in step 4
    node server.mjs
  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.

  3. Create a test endpoint with that URL#

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

    Create a test endpoint
    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"
    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();
    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
    $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);
  4. Give the handler the secret#

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

  5. Make an event#

    Run any call from the table below. 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}.

Send a test event from the Dashboard#

On an endpoint's page under Webhooks in the Dashboard, 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 doCallEvents
A card payment that succeedsPOST /v1/payment_intents with amount=2000, currency=usd, payment_method_types[]=card, payment_method=pm_card_visa, confirm=truepayment_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 declinedThe 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 laterThe success call plus capture_method=manualpayment_intent.created, charge.succeeded, payment_intent.amount_capturable_updated
Capture itPOST /v1/payment_intents/{id}/capturecharge.captured, payment_intent.succeeded
Cancel an authorized paymentPOST /v1/payment_intents/{id}/cancelpayment_intent.canceled
RefundPOST /v1/refunds with payment_intent=pi_...refund.created, charge.refunded
A refund that fails laterPay with payment_method=pm_card_refundFail, then refund itrefund.created, charge.refunded, then refund.failed and charge.refund.updated
A disputePay with payment_method=pm_card_createDisputethe payment events and charge.dispute.created: the dispute is already open when the payment answers
CustomersPOST /v1/customers, POST /v1/customers/{id}, DELETE /v1/customers/{id}customer.created, customer.updated, customer.deleted
A Checkout Session that expiresPOST /v1/checkout/sessions/{id}/expire on an open sessioncheckout.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.

Make a payment_intent.succeeded event
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
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();
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
$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:

cURL
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 and Boleto.

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#