Webhooks
Webhooks let Fifi tell your other systems the moment something changes: a contact is created, a deal is won, a task is completed, a floor plan is saved. Fifi sends a signed HTTPS POST to an address you choose.
Who can do it: owners and admins, in Settings → API → Webhooks, or through the API with a key that has webhooks:write.
Plan: part of the API module (Pro and Business). Business allows 10 endpoints per workspace, Pro allows 3. During the beta every workspace is on Business.
Add an endpoint
- Go to Settings → API and scroll to Webhooks.
- Click Add webhook.
- Enter the Endpoint URL, for example
https://example.com/fifi-webhooks. It must behttps://on the standard port (443), with a public host name, not an IP address. - Optionally add a Description, for example "Zapier: new contacts".
- Tick the Events to send.
- Click Add webhook. Fifi shows Copy the signing secret with the endpoint's secret (
whsec_…). It's shown once: copy it into your receiving app's settings.
What you'll see: the endpoint's page, with Send test event, Edit, Roll secret, Turn off / Turn on and Delete buttons, and its delivery log. The Webhooks list shows how many of your 10 you're using.
Events
| Record | Events |
|---|---|
| Contacts | contact.created, contact.updated, contact.deleted |
| Companies | company.created, company.updated, company.deleted |
| Deals | deal.created, deal.updated, deal.stage_changed, deal.won, deal.lost, deal.deleted |
| Tasks | task.created, task.updated, task.completed, task.deleted |
| Timeline | activity.created (a note, call or meeting was logged) |
| Floor plans | floor_plan.created, floor_plan.updated, floor_plan.deleted |
| Loads | load.created, load.updated, load.status_changed, load.delivered, load.deleted |
| Invoices | invoice.sent, invoice.voided, invoice.paid |
| Payments | payment.recorded, payment.voided |
deal.stage_changed,deal.won,deal.lostandtask.completedare sent as well as the matching*.updated, each with its own event ID.- Restoring a record from Trash sends
*.updatedwith nochanges. - Send test event sends a
pingevent. You can't subscribe toping. floor_plan.updatedis sent when a plan is saved in the editor (and for any plan a kennel or seat was moved away from), renamed, moved up or down the list (the two plans that swap places), or its background image is added, replaced or removed, and when a kennel or seat on it is deleted.floor_plan.deletedcarries the plan as it was just before it was deleted. Itsobjectis the plan's summary (name, grid, version, whether it has a background), without shapes or pods: fetchGET /api/v1/floor-plans/{id}for those. When only the plan's place in the list changed, or its background image was replaced by another, it comes withoutchanges; adding or removing a background sendschanges: { "has_background": … }.load.updatedis sent for any change to a load: its fields, carrier, stops (arrivals and departures too) or status, and on a restore. A status move also sendsload.status_changed(itschanges.statusis the status before;object.statusthe new one), and arriving at Delivered also sendsload.delivered, each with its own event ID. Theobjectis the API's load less its notes, its cancel reason (typed text, private as a lost deal's note), its stops' notes and its documents. Check calls and documents send nothing.invoice.sentis sent when an invoice is issued (sent by email or marked as sent), andinvoice.voidedwhen it's voided. Theirobjectis the API's invoice without its lines, notes and billing address: fetchGET /api/v1/invoices/{id}for those.invoice.paidis sent when an invoice's payments add up to its total (as well aspayment.recordedfor that payment).payment.recordedis sent for each payment and each refund (kindisrefund;amount_centsis always positive) andpayment.voidedwhen one is voided as entered in error; theirobjectis the API's payment without its notes. Drafts, edits and corrections send nothing.- When you create an endpoint through the API, each event needs the key's read scope for that area (
floor_plan.*needsfloor_plans:read,load.*needsloads:read,invoice.*andpayment.*needinvoices:read).
What Fifi sends
POST /fifi-webhooks HTTP/1.1
Content-Type: application/json
User-Agent: FifiCRM-Webhooks/1.0 (+https://fetchfifi.com/developers)
Fifi-Event-Id: 6d1f…
Fifi-Event-Type: deal.won
Fifi-Signature: t=1791234567,v1=5f2b…
{
"id": "6d1f…",
"type": "deal.won",
"created_at": "2026-10-05T14:03:11.000Z",
"data": {
"object": { "id": "…", "name": "Acme renewal", "value_cents": 1250000, "status": "won", … },
"changes": { "status": "open" }
}
}data.objectis the record as the API returns it, except that a company's notes, a deal's lost-reason note and a note's text are left out. Email content and files are never sent.data.changesholds the previous values of the fields that changed.- A very large payload is cut short and marked
"truncated": true. Fetch the record from the API if you need all of it.
Verify the signature
Always check Fifi-Signature before trusting a webhook. The header is t=<unix seconds>,v1=<signature>. The signature is HMAC-SHA256, in lowercase hex, of the text <t>. followed by the raw request body, keyed with your whole signing secret (including whsec_).
This Node.js function needs no packages. It's the same code shown on /developers, and Fifi's tests run it against real signatures.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as it arrived (a Buffer or string), before any JSON parsing.
// header: the Fifi-Signature header. secret: the endpoint's signing secret (whsec_…).
export function verifyFifiSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = String(header ?? "").split(",").map((p) => p.trim().split("="));
const t = Number(parts.find(([k]) => k === "t")?.[1]);
// Reject anything older (or newer) than 5 minutes: a replayed request.
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
// During a secret roll there are two v1 signatures: either may match.
return parts.some(([k, v]) => k === "v1" && /^[0-9a-f]{64}$/.test(v ?? "") && timingSafeEqual(Buffer.from(v, "hex"), expected));
}Using it with Express:
import express from "express";
const app = express();
// The raw body: a parsed-and-reserialized JSON body won't match the signature.
app.post("/fifi-webhooks", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyFifiSignature(req.body, req.get("Fifi-Signature"), process.env.FIFI_WEBHOOK_SECRET)) {
return res.status(400).send("bad signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// Fifi may send an event more than once: skip ones you've already handled.
if (alreadyHandled(event.id)) return res.sendStatus(200);
queueForProcessing(event); // answer within 10 seconds; do slow work afterwards
res.sendStatus(200);
});Tips:
- Verify against the raw body. Parsing and re-serializing the JSON changes it and breaks the signature.
- Use the event's
id(also inFifi-Event-Id) to skip duplicates. Fifi may send an event more than once. - Events can arrive out of order. Use
created_at, or fetch the latest record from the API.
Deliveries and retries
- Fifi waits 10 seconds for an answer. Only a
2xxstatus counts as delivered. Fifi doesn't follow redirects. - A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours: 7 tries over about 21 hours.
- Fifi sends at most 2 requests at once and 60 a minute to one endpoint.
- If every delivery to an endpoint has failed for 24 hours, Fifi turns it off (reason "failing"), marks its waiting deliveries failed, and emails the person who created it (or the owners). Turning it back on doesn't resend what was missed.
The delivery log
Open an endpoint from Settings → API to see the last 30 days of deliveries, newest first, 50 to a page. Each row shows the event type, a status (Delivered, Retrying, Waiting or Failed), the time, the number of tries, the HTTP status or error, and how long it took.
Click Redeliver on a row to send that event again, with the same event ID. It's unavailable while the endpoint is off.
Roll the secret
If a secret may have leaked:
- Open the endpoint and click Roll secret.
- Copy the new secret (shown once) into your receiving app.
For 24 hours Fifi signs with both the new and the old secret, so the header has two v1= values. The function above accepts either.
Endpoints created through the API
An endpoint created with an API key is tied to that key. Its page says it was added through the API. It turns off for good when:
- the key is revoked or expires (reason
key_revoked); or - the person who created the key leaves or is no longer an owner or admin (reason
creator_removed).
Troubleshooting
| Problem | What to check |
|---|---|
| "Use an https:// address on the standard port (443) with a public host name, not an IP address." | Change the URL. Private, local and IP addresses are refused, at save time and again at every delivery. |
| Signature never matches | Verify the raw body, use the whole secret including whsec_, and check your server clock (5-minute tolerance). |
| Deliveries show Retrying | Your endpoint didn't answer 2xx within 10 seconds. Answer quickly and do slow work afterwards. |
| Endpoint turned off | Fix the receiver, then click Turn on. Use Redeliver for events you need. |
| No events arrive | Check the endpoint is on, the right events are ticked, and the API module is on in Settings → Modules. |