DevelopersDocs › 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:

  • sku is 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_at is the marketplace's timestamp, not when we noticed. It can be minutes or hours before fired_at, depending on how often that channel is synced.
  • items[] is the whole order. For a bundle (several items, one order) the flat sku, title and price fields describe the first line only — read items if you need them all. order_total covers the whole order including shipping.
  • price is what the buyer actually paid, accepted offers included — not the listing price.
  • channel is the marketplace it sold on; connection_id tells accounts apart if you have more than one on the same channel.

Verify every delivery

Each request carries these headers:

HeaderMeaning
X-FLUF-EventEvent key, e.g. new_sale
X-FLUF-Signaturesha256=
X-FLUF-TimestampUnix seconds, signed together with the body
X-FLUF-DeliveryThe event's id — the same on every retry of that event. Use it to deduplicate. Also in the body as event_id.
X-FLUF-Attempt1 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

CodeMeaning
400 https_requiredThe URL wasn't https
400 private_hostHost resolves to a private/local address, or doesn't resolve at all
400 unknown_eventEvent key not recognised — see GET /events
401Token invalid or revoked
403 no_active_subscriptionThe API needs an active FLUF plan
403 api_read_onlyRegistering a webhook is a write. The plan allows reads only, so it needs Pro or above
403 api_not_in_planThe plan has no API access
404 not_foundNo 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

Something missing? Email [email protected] — we prioritise by what people actually ask for.

Scroll to Top