Docs
API 2026-05-27.dahlia Dashboard

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.

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)
{
  "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
  • idstring

    Unique id of the event, starting with evt_. Use it to skip duplicates.

  • objectstring

    Always event.

  • The API version the body follows. Always 2026-05-27.dahlia, Fluveo's only version.

  • createdinteger

    When the event happened, in Unix seconds.

  • dataobject

    The object the event is about.

    2 child fields
    • objectobject

      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_attributesobjectOptional

      When present, old field values from before the change. This can also appear on events such as charge.captured, charge.refunded and refund.failed.

  • livemodeboolean

    false for events made with a sk_test_ key.

  • How many endpoints have not yet received this event successfully, this delivery included.

  • requestobject

    The API request that caused the event.

    2 child fields
    • idstringNullable

      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_keystringNullable

      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.

  • typestring

    What happened, such as payment_intent.succeeded or charge.refunded. See all event types.

You can read the same events with the API for 30 days: GET /v1/events and GET /v1/events/{id}. See the events reference.

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.

  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.

  2. Register it with the API#

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

    Create a webhook endpoint
    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"
    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.
    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
    $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.
  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
    {
      "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
  • urlstringRequired

    The public URL Fluveo posts to: https in live mode, https or http in test mode.

  • enabled_eventsarray of stringsRequired

    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].

  • descriptionstringOptional

    A note for you, up to 5,000 characters.

  • metadataobjectOptional

    Your own metadata[key]=value pairs: up to 50 keys, keys up to 40 characters, values up to 500.

  • api_versionstringOptional

    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 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#

CallWhat it does
GET /v1/webhook_endpointsLists 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.

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:

ModeRetriesWait before each retry
Test (sk_test_)330 min, 1 h, 2 h
Live101 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.

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). 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.

Next#