Developers › Docs › Webhooks
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"
}'
{
"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"
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, not something the marketplace sent back. Most marketplaces don't return a SKU on orders at all, so we resolve it from your FLUF product. Put your own reference code in the SKU field when you create the listing and it comes back to you on the sale.sold_atis the marketplace's timestamp, not when we noticed. It can be minutes or hours beforefired_at, depending on how often that channel is synced.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)) < 300
const 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, not exactly-once. If your server processes a request but fails to reply in time, the retry arrives and you'll see it twice. Deduplicate on event_id ā it identifies the event itself and stays the same across every retry, so storing the ones you've handled is all you need. It's also in the X-FLUF-Delivery header if you'd rather not parse the body first.
Events carry a seq too. Delivery order isn't guaranteed ā a retried update can land after something newer ā so if you apply changes to an item, keep the highest seq you've seen for it and ignore anything lower. Compare seq only between events about the same item.
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"
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"
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.
