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#
- You register an endpoint: a public URL (
https, orhttpin test mode) and the event types it wants. - Something changes in your account, for example a PaymentIntent succeeds. Fluveo records an event.
- Fluveo sends the event as JSON to every enabled endpoint that asked for its type, signed with that endpoint's secret.
- Your server checks the signature, stores the event, answers
2xx, and then does the work. - 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.
{
"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"
}idstringUnique id of the event, starting with
evt_. Use it to skip duplicates.objectstringAlways
event.api_versionstringThe API version the body follows. Always
2026-05-27.dahlia, Fluveo's only version.createdintegerWhen the event happened, in Unix seconds.
dataobjectThe object the event is about.
2 child fields
objectobjectThe object the event is about, as it was at the change, buyer details included. Amounts are in the smallest currency unit:
2000is $20.00 inusdand R$ 20,00 inbrl.When present, old field values from before the change. This can also appear on events such as
charge.captured,charge.refundedandrefund.failed.
livemodebooleanfalsefor events made with ask_test_key.pending_webhooksintegerHow many endpoints have not yet received this event successfully, this delivery included.
requestobjectThe API request that caused the event.
2 child fields
The
req_id of that request, ornullwhen no API call caused it (a buyer paid a Pix code, a bank opened a dispute).The
Idempotency-Keythat request carried. For a POST sent without one, the key Fluveo made for it, which the answer'sIdempotency-Keyheader also shows.
typestringWhat happened, such as
payment_intent.succeededorcharge.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.
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 needshttps: anhttpURL is refused with a 400,Invalid URL: URL must use https.A test-mode endpoint takeshttpsor plainhttp, so you can try your handler before it has a TLS certificate. Whatever the scheme,localhost,*.localhost,*.localand*.internalnames, 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.Register it with the API#
Send
urland at least oneenabled_events[]. Use*to receive every type.Create a webhook endpointcurl 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.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 sameIdempotency-Keyand the same body within 24 hours gives back the same endpoint, secret included while the endpoint is unchanged, withIdempotent-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#
The public URL Fluveo posts to:
httpsin live mode,httpsorhttpin test mode.The event types to send, as
enabled_events[]=type, up to 256.*means every type. An unknown type is a 400 withparamset toenabled_events[n].A note for you, up to 5,000 characters.
Your own
metadata[key]=valuepairs: up to 50 keys, keys up to 40 characters, values up to 500.Only
2026-05-27.dahliais 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#
| 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.
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,4xxor5xxstatus (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.
Duplicates and order#
- Expect repeats. If your
200is lost on the way back, Fluveo sends the event again, and the repeat has the sameevt_id as the first copy. Keep the ids you have handled in a table whereidis unique, and treat a second insert of the same id as "already done". Theevt_id is the only safe key: two partial refunds of one charge give two events with the samedata.object.idand the sametype. - Expect any order. Deliveries run side by side and each retry keeps its own clock, so
payment_intent.succeededcan land beforecharge.succeeded, and an old event's retry after a newer event. When order matters, comparecreated, or read the object (GET /v1/payment_intents/{id}) and trust itsstatusnow. - 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:
- Read the raw body and check
Fluveo-Signature(how). Answer400if it fails. - Parse the JSON. If you already stored this
id, answer200and stop. - Store the event, answer
200. - Later, from your queue: switch on
type. Forpayment_intent.succeeded, find your order by the PaymentIntentid(or bymetadatayou set when you created it), check theamountandcurrencyagainst 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.