Webhooks
Register an endpoint, verify a signature, and handle retries correctly.
Webhooks let FLUF POST to your own server the moment something happens in your account — most usefully, when an item sells on any connected marketplace. Instead of re-entering sales by hand, your system finds out immediately.
You'll need somewhere to receive an HTTP request, and a token.
Base URL: https://fluf.io/wp-json/fc/api/v1
This page covers registering an endpoint and handling deliveries correctly. For what each delivered payload contains, see Webhook events.
Register your endpoint
curl -X POST "https://fluf.io/wp-json/fc/api/v1/webhooks" \
-H "Authorization: Bearer fluf_pat_your_token" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.example.com/hooks/fluf",
"events": ["new_sale"],
"description": "Inventory sync"
}'const res = await fetch("https://fluf.io/wp-json/fc/api/v1/webhooks", {
method: "POST",
headers: {
"Authorization": "Bearer fluf_pat_your_token",
"Content-Type": "application/json",
},
body: JSON.stringify({
"url": "https://yourapp.example.com/hooks/fluf",
"events": [
"new_sale"
],
"description": "Inventory sync"
}),
});
const data = await res.json();import requests
res = requests.post(
"https://fluf.io/wp-json/fc/api/v1/webhooks",
headers={
"Authorization": "Bearer fluf_pat_your_token",
},
json={
"url": "https://yourapp.example.com/hooks/fluf",
"events": ["new_sale"],
"description": "Inventory sync",
},
)
data = res.json()<?php
// composer require guzzlehttp/guzzle
$client = new GuzzleHttp\Client();
$res = $client->request('POST', 'https://fluf.io/wp-json/fc/api/v1/webhooks', [
'headers' => [
'Authorization' => 'Bearer fluf_pat_your_token',
],
'json' => [
'url' => 'https://yourapp.example.com/hooks/fluf',
'events' => ['new_sale'],
'description' => 'Inventory sync',
],
]);
$data = json_decode((string) $res->getBody(), true);{
"id": 1,
"url": "https://yourapp.example.com/hooks/fluf",
"events": ["new_sale"],
"secret": "9f8c…",
"note": "Store this secret now — it is not shown again."
}Save the secret — you need it to verify deliveries, and it is never shown again. Omit events to receive everything.
Your URL must be https and publicly reachable. Private, loopback and local addresses are rejected. For local development use a tunnel such as ngrok or Cloudflare Tunnel.
Test it before you rely on it
curl -X POST "https://fluf.io/wp-json/fc/api/v1/webhooks/1/test" \
-H "Authorization: Bearer fluf_pat_your_token"const res = await fetch("https://fluf.io/wp-json/fc/api/v1/webhooks/1/test", {
method: "POST",
headers: {
"Authorization": "Bearer fluf_pat_your_token",
},
});
const data = await res.json();import requests
res = requests.post(
"https://fluf.io/wp-json/fc/api/v1/webhooks/1/test",
headers={
"Authorization": "Bearer fluf_pat_your_token",
},
)
data = res.json()<?php
// composer require guzzlehttp/guzzle
$client = new GuzzleHttp\Client();
$res = $client->request('POST', 'https://fluf.io/wp-json/fc/api/v1/webhooks/1/test', [
'headers' => [
'Authorization' => 'Bearer fluf_pat_your_token',
],
]);
$data = json_decode((string) $res->getBody(), true);This fires a real POST at your URL (with "test": true in the body) and returns the status code your server gave back — so you can build and debug your receiver without waiting for a sale.
The new_sale payload
{
"event": "new_sale",
"fired_at": "2026-07-29T14:03:11+00:00",
"fluf_user_id": 1234,
"order_id": "abc-123-def",
"channel": "depop",
"connection_id": 208,
"sku": "T-001",
"title": "Vintage Levi's 501 jeans",
"price": 45.00,
"currency": "GBP",
"quantity": 1,
"buyer_username": "a_buyer",
"sold_at": "2026-07-28T14:03:11+00:00",
"order_total": 48.99,
"items": [
{
"sku": "T-001",
"title": "Vintage Levi's 501 jeans",
"price": 45.00,
"quantity": 1,
"external_id": "123456789",
"fluf_id": 987654
}
]
}Worth knowing:
skuis your SKU — the reference code on the product in FLUF. Put your own code in the SKU field and it comes back to you on the sale.sold_atis the marketplace's timestamp, not when we noticed. It can be earlier thanfired_at.items[]is the whole order. For a bundle (several items, one order) the flatsku,titleandpricefields describe the first line only — readitemsif you need them all.order_totalcovers the whole order including shipping.priceis what the buyer actually paid, accepted offers included — not the listing price.channelis the marketplace it sold on;connection_idtells accounts apart if you have more than one on the same channel.
Verify every delivery
Each request carries these headers:
| Header | Meaning |
|---|---|
X-FLUF-Event | Event key, e.g. new_sale |
X-FLUF-Signature | sha256= |
X-FLUF-Timestamp | Unix seconds, signed together with the body |
X-FLUF-Delivery | The event's id — the same on every retry of that event. Use it to deduplicate. Also in the body as event_id. |
X-FLUF-Attempt | 1 on the first try, higher on retries |
The signature is HMAC-SHA256 of "{timestamp}.{raw request body}", keyed with your secret. Sign the raw body — re-serialising the JSON changes the bytes and the signatures won't match.
import hmac, hashlib, time
def verify(secret: str, body: bytes, signature: str, timestamp: str) -> bool:
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(f"sha256={expected}", signature):
return False
# Reject anything older than 5 minutes to stop replays.
return abs(time.time() - int(timestamp)) < 300const crypto = require('crypto');
function verify(secret, rawBody, signature, timestamp) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody) // Buffer — do not JSON.stringify a parsed object
.digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
return ok && Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
}Reject anything that fails verification. Without this check, anyone who learns your URL could post fake sales into your system.
Retries and reliability
Respond 2xx quickly. Anything else — or a timeout beyond 10 seconds — counts as a failure. Do the minimum inline (write to a queue or a table) and process afterwards.
Failed deliveries are retried after 1 minute → 5 minutes → 30 minutes → 2 hours → 12 hours, then stop. X-FLUF-Attempt tells you which try you're on.
Deliveries are at-least-once, and order isn't guaranteed. Deduplicate on event_id (also in the X-FLUF-Delivery header) and use seq to ignore stale updates — Webhook events explains both.
After 20 consecutive failures a webhook is automatically disabled, so a dead endpoint doesn't retry forever. Check GET /webhooks for is_active and last_error, and inspect individual attempts:
curl "https://fluf.io/wp-json/fc/api/v1/webhooks/1/deliveries" \
-H "Authorization: Bearer fluf_pat_your_token"const res = await fetch("https://fluf.io/wp-json/fc/api/v1/webhooks/1/deliveries", {
headers: {
"Authorization": "Bearer fluf_pat_your_token",
},
});
const data = await res.json();import requests
res = requests.get(
"https://fluf.io/wp-json/fc/api/v1/webhooks/1/deliveries",
headers={
"Authorization": "Bearer fluf_pat_your_token",
},
)
data = res.json()<?php
// composer require guzzlehttp/guzzle
$client = new GuzzleHttp\Client();
$res = $client->request('GET', 'https://fluf.io/wp-json/fc/api/v1/webhooks/1/deliveries', [
'headers' => [
'Authorization' => 'Bearer fluf_pat_your_token',
],
]);
$data = json_decode((string) $res->getBody(), true);That returns the last 50 attempts with status codes and errors — so "did it actually fire?" always has an answer.
Errors
| Code | Meaning |
|---|---|
400 https_required | The URL wasn't https |
400 private_host | Host resolves to a private/local address, or doesn't resolve at all |
400 unknown_event | Event key not recognised — see GET /events |
| 401 | Token invalid or revoked |
403 no_active_subscription | The API needs an active FLUF plan |
403 api_read_only | Registering a webhook is a write. The plan allows reads only, so it needs Pro or above |
403 api_not_in_plan | The plan has no API access |
404 not_found | No webhook with that id on your account |
When you fall behind
Nothing covers an event we never managed to send you. If your endpoint was down past the retry window, or you're starting cold with no stored position, don't try to replay — ask for current state:
curl "https://fluf.io/wp-json/fc/api/v1/items?updated_since=2026-08-01T00:00:00Z" \
-H "Authorization: Bearer fluf_pat_your_token"const res = await fetch("https://fluf.io/wp-json/fc/api/v1/items?updated_since=2026-08-01T00:00:00Z", {
headers: {
"Authorization": "Bearer fluf_pat_your_token",
},
});
const data = await res.json();import requests
res = requests.get(
"https://fluf.io/wp-json/fc/api/v1/items?updated_since=2026-08-01T00:00:00Z",
headers={
"Authorization": "Bearer fluf_pat_your_token",
},
)
data = res.json()<?php
// composer require guzzlehttp/guzzle
$client = new GuzzleHttp\Client();
$res = $client->request('GET', 'https://fluf.io/wp-json/fc/api/v1/items?updated_since=2026-08-01T00:00:00Z', [
'headers' => [
'Authorization' => 'Bearer fluf_pat_your_token',
],
]);
$data = json_decode((string) $res->getBody(), true);That returns only products that genuinely changed, and reports state rather than a log — so it recovers an outage, a consumer bug, or a cold start alike. See the REST API reference.
Next
- Webhook events — every event, what it carries, when it fires
events.yaml— the same thing, machine-readable
Something missing? Email [email protected] — we prioritise by what people actually ask for.
