Skip to content
Fifi CRMHelp

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

  1. Go to Settings → API and scroll to Webhooks.
  2. Click Add webhook.
  3. Enter the Endpoint URL, for example https://example.com/fifi-webhooks. It must be https:// on the standard port (443), with a public host name, not an IP address.
  4. Optionally add a Description, for example "Zapier: new contacts".
  5. Tick the Events to send.
  6. 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

RecordEvents
Contactscontact.created, contact.updated, contact.deleted
Companiescompany.created, company.updated, company.deleted
Dealsdeal.created, deal.updated, deal.stage_changed, deal.won, deal.lost, deal.deleted
Taskstask.created, task.updated, task.completed, task.deleted
Timelineactivity.created (a note, call or meeting was logged)
Floor plansfloor_plan.created, floor_plan.updated, floor_plan.deleted
Loadsload.created, load.updated, load.status_changed, load.delivered, load.deleted
Invoicesinvoice.sent, invoice.voided, invoice.paid
Paymentspayment.recorded, payment.voided
  • deal.stage_changed, deal.won, deal.lost and task.completed are sent as well as the matching *.updated, each with its own event ID.
  • Restoring a record from Trash sends *.updated with no changes.
  • Send test event sends a ping event. You can't subscribe to ping.
  • floor_plan.updated is 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.deleted carries the plan as it was just before it was deleted. Its object is the plan's summary (name, grid, version, whether it has a background), without shapes or pods: fetch GET /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 without changes; adding or removing a background sends changes: { "has_background": … }.
  • load.updated is 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 sends load.status_changed (its changes.status is the status before; object.status the new one), and arriving at Delivered also sends load.delivered, each with its own event ID. The object is 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.sent is sent when an invoice is issued (sent by email or marked as sent), and invoice.voided when it's voided. Their object is the API's invoice without its lines, notes and billing address: fetch GET /api/v1/invoices/{id} for those. invoice.paid is sent when an invoice's payments add up to its total (as well as payment.recorded for that payment). payment.recorded is sent for each payment and each refund (kind is refund; amount_cents is always positive) and payment.voided when one is voided as entered in error; their object is 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.* needs floor_plans:read, load.* needs loads:read, invoice.* and payment.* need invoices: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.object is 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.changes holds 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 in Fifi-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 2xx status 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:

  1. Open the endpoint and click Roll secret.
  2. 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

ProblemWhat 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 matchesVerify the raw body, use the whole secret including whsec_, and check your server clock (5-minute tolerance).
Deliveries show RetryingYour endpoint didn't answer 2xx within 10 seconds. Answer quickly and do slow work afterwards.
Endpoint turned offFix the receiver, then click Turn on. Use Redeliver for events you need.
No events arriveCheck the endpoint is on, the right events are ticked, and the API module is on in Settings → Modules.