Skip to content
Fifi CRMHelp

API keys and the REST API

Use the Fifi API to connect other tools to your workspace: read and write contacts, companies, deals, tasks and timeline entries, manage webhooks, and read your floor plans and seats and your service records.

The full reference, with every field and an example for every endpoint, is the public /developers page in Fifi (also linked from Settings → API). The machine-readable OpenAPI 3.1 document is at /api/v1/openapi.json. This guide covers the essentials.

Who can do it: owners and admins create and revoke keys. Members don't see Settings → API.

Plan: the API is part of the Pro and Business plans. During the beta every workspace is on Business. On Free, every request gets 403 module_disabled. The API module can also be turned off in Settings → Modules.

Create a key

  1. Go to Settings → API.
  2. Click Create API key.
  3. Enter a Name so you know which integration uses it, for example "Zapier".
  4. Under Access, choose for each area: No access, Read or Read and write. The areas are Contacts, Companies, Deals, Tasks, "Notes, calls and meetings", Webhooks, "Floor plans and seats", "Service records", "Loads" and "Invoices and payments". The last four offer only No access or Read: the API can't change them. Give the key only what the integration needs.
  5. Choose when it Expires: In 30 days, In 90 days (the default), In a year, or Never.
  6. Click Create key.
  7. Copy the key from Copy your API key and store it somewhere safe, like a password manager. You won't see it again.

A key looks like fifi_k3x9q2ab_ followed by 43 letters and digits.

What you'll see: the key appears in the list with its status (Active, Paused, Expired or Revoked), its prefix, its access, who created it, when it expires, and when and from where it was last used. The counter shows how many of your 10 active keys you're using.

What a key can do

  • It only ever reaches your workspace.
  • It acts as you, the person who created it, limited to the access you gave it. The activity log shows its changes as "‹you› via API key “‹name›”".
  • It stops working when it's revoked or expires, while the workspace is suspended or being deleted, or if you're deactivated, removed or no longer an owner or admin. In those last cases Fifi revokes it for good.

Revoke a key

  1. In Settings → API, find the key and click Revoke.
  2. Confirm with Revoke key.

Anything using it stops working straight away. This can't be undone; create a new key instead.

Make a request

Send the key in the Authorization header:

export FIFI=https://app.fetchfifi.com    # your Fifi address
export FIFI_API_KEY=fifi_xxxxxxxx_...    # from Settings → API

curl "$FIFI/api/v1/me" \
  -H "Authorization: Bearer $FIFI_API_KEY"

/me tells you the workspace, the key's name, prefix, scopes and expiry, and the rate limits.

Rules for every request:

  • Never send a Fifi cookie with a key. A request with both gets 400 cookie_not_allowed.
  • Bodies are JSON with Content-Type: application/json, at most 256 KB.
  • Unknown fields are refused (400).
  • Times are ISO 8601 in UTC. Dates are YYYY-MM-DD. Money is whole cents (value_cents).

List contacts

curl "$FIFI/api/v1/contacts?limit=50&lead_status=qualified" \
  -H "Authorization: Bearer $FIFI_API_KEY"

Lists return { "data": [...], "next_cursor": "..." }. To get the next page, pass cursor=<next_cursor>. When next_cursor is null you have everything.

  • limit: 1 to 100, default 25.
  • sort: created_at (default) or updated_at, newest first.
  • updated_since: an ISO time with a time zone, for syncing changes.
  • Deleted records are never listed.

Create a contact

curl -X POST "$FIFI/api/v1/contacts" \
  -H "Authorization: Bearer $FIFI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"first_name":"Ada","last_name":"Lovelace","email":"ada@example.com","lead_status":"new","source":"inbound"}'

Fifi answers 201 with { "data": { ... } }. The owner defaults to the key's creator unless you send owner_id.

Update and delete

# Change only the fields you send; null clears an optional field.
curl -X PATCH "$FIFI/api/v1/contacts/<id>" \
  -H "Authorization: Bearer $FIFI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"CTO"}'

# Contacts, companies and deals go to Trash (restorable for 30 days in Fifi).
curl -X DELETE "$FIFI/api/v1/contacts/<id>" \
  -H "Authorization: Bearer $FIFI_API_KEY"

Deleting a task or an activity is permanent, as it is in the app.

Concurrent changes. A PATCH changes only the fields you send, and the last write wins. To be sure you don't overwrite a change made since you read a contact, company or deal (in Fifi or by another integration), send the updated_at you read as expected_updated_at, exactly as Fifi returned it. If the record has changed since, nothing changes and Fifi answers 409 conflict with details.reason "stale": fetch it again and retry. A value that isn't a time Fifi could have returned (before 1970, or past the year 9999) is a 400. Every change moves updated_at on, including a PATCH that sends the values the record already has.

curl -X PATCH "$FIFI/api/v1/contacts/<id>" \
  -H "Authorization: Bearer $FIFI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"CTO","expected_updated_at":"2026-10-02T09:30:00.000Z"}'

Close a deal

curl -X PATCH "$FIFI/api/v1/deals/<id>" \
  -H "Authorization: Bearer $FIFI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status":"lost","lost_reason":"timing"}'

lost_reason is one of price, timing, competitor, no_decision or other. With other, also send lost_reason_note. Send "status":"open" to reopen.

Endpoints

All paths start with /api/v1.

EndpointScope needed
GET /meany key
GET /members (id, name, role; never email addresses)the read scope of contacts, companies, deals, tasks, activities or webhooks (floor_plans:read, service_records:read, loads:read or invoices:read alone isn't enough)
GET /tagscontacts:read
GET /pipeline (stages, statuses, lost reasons)deals:read
GET, POST /contacts; GET, PATCH, DELETE /contacts/{id}contacts:read / contacts:write
GET, POST /companies; GET, PATCH, DELETE /companies/{id}companies:read / companies:write
GET, POST /deals; GET, PATCH, DELETE /deals/{id}deals:read / deals:write
GET, POST /tasks; GET, PATCH, DELETE /tasks/{id}tasks:read / tasks:write
GET, POST /activities; GET, PATCH, DELETE /activities/{id}activities:read / activities:write
GET, POST /webhooks; GET, DELETE /webhooks/{id}webhooks:read / webhooks:write
GET /floor-plans; GET /floor-plans/{id}; GET /seatsfloor_plans:read (read only)
GET /service-records; GET /service-records/{id}; GET /service-productsservice_records:read (read only)
GET /loads; GET /loads/{id}loads:read (read only)
GET /invoices; GET /invoices/{id}; GET /paymentsinvoices:read (read only)
GET /openapi.jsonnone (public)

Filters by list:

table 2
ListFilters
Contactsowner_id, lead_status, tag_id, company_id, email
Companiesowner_id, domain
Dealsowner_id, stage, status, company_id, contact_id
Tasksassignee_id, completed, due_from, due_to, record_type with record_id
Activitiesrecord_type with record_id, type, author_id

Activities are notes, calls and meetings (type: note, call, meeting). Only the author can edit one.

If the Deals module is off, deal endpoints and /pipeline answer 403 module_disabled.

Floor plans and seats

Read your floor plans, what's on them, and your appointment seats (tables, tubs, chairs) with a key that has Floor plans and seats: Read. There's no way to change them through the API yet.

curl "$FIFI/api/v1/floor-plans" -H "Authorization: Bearer $FIFI_API_KEY"
curl "$FIFI/api/v1/floor-plans/<id>" -H "Authorization: Bearer $FIFI_API_KEY"
curl "$FIFI/api/v1/seats" -H "Authorization: Bearer $FIFI_API_KEY"
  • A floor plan: id, name, columns and rows (its grid, in cells), version, has_background, created_at, updated_at. version starts at 1 and goes up by one each time its layout or grid is saved, a kennel or seat leaves it, or it's deleted.
  • One plan (/floor-plans/{id}) adds its shapes (walls, doors, areas, tables and labels) and its pods: each kennel ("kind": "kennel") and seat ("kind": "seat") placed on it, with its id and name.
  • A seat: id, name, kind (Table, Tub…), active (false when switched off), order, and where it's placed: floor_plan_id and its position, all null when it isn't on a plan.
  • Positions and sizes (x, y, w, h) are in quarter cells (4 to a grid cell): x and y are the top-left corner, w and h the size before turning, and rotation is 0, 90, 180 or 270 degrees.
  • Kennels show on plans only while Boarding is on, and seats only while Online booking is on (/seats is empty while it's off). With both off, all three answer 403 module_disabled.
  • The background image itself isn't available through the API.

Service records

Read your service records (what was applied on each visit) and your product list with a key that has Service records: Read. There's no way to change them through the API, and no service_records:write scope.

curl "$FIFI/api/v1/service-records?status=completed&service_after=2026-10-01T00:00:00Z" -H "Authorization: Bearer $FIFI_API_KEY"
curl "$FIFI/api/v1/service-records/<id>" -H "Authorization: Bearer $FIFI_API_KEY"
curl "$FIFI/api/v1/service-products" -H "Authorization: Bearer $FIFI_API_KEY"
  • A record: id, status (draft, completed, voided), booking_id and contact_id (null once deleted for good, or when written for a customer), technician_id, service_at, service_name, customer_name, service_address, technician_name and licence_number (as written when completed), conditions, notes, completed_at, voided_at, void_reason, created_at, updated_at, and its applications: product_id, product_name, epa_reg_no, amount (a number with up to 3 decimals), unit, target_pests, areas, method, rate.
  • Filters: contact_id, technician_id, status, service_after, service_before, plus the usual paging. Records in the Trash aren't listed.
  • While Service records is off, all three answer 403 module_disabled (nothing is deleted).

Loads

Read your loads (with their stops and their documents' details) with a key that has Loads: Read. There's no way to change them through the API yet, and no loads:write scope.

curl "$FIFI/api/v1/loads?status=in_transit&pickup_after=2026-10-01T00:00:00Z" -H "Authorization: Bearer $FIFI_API_KEY"
curl "$FIFI/api/v1/loads/<id>" -H "Authorization: Bearer $FIFI_API_KEY"
  • A load: id, number, status (open, covered, dispatched, in_transit, delivered, cancelled), owner_id, shipper_id, shipper_contact_id, deal_id (its lane), carrier_id, driver_contact_id, truck_number, trailer_number, equipment, temp_min_f, temp_max_f, weight_lb, pieces, commodity, hazmat, reference, the money in cents (rate_cents, carrier_pay_cents, and margin_cents, worked out), pickup_at, delivery_at, origin, destination, delivered_at, cancelled_at, cancel_reason, notes, created_at, updated_at;
  • its stops in order: id, position, kind (pickup or delivery), facility, address, locality, timezone (each stop's own), window_start, window_end, appointment_ref, booking_id (a dock appointment), arrived_at, departed_at, notes;
  • its documents: id, kind (pod, rate_confirmation, bol, other), stop_id, content_type, byte_size, name, created_at. The files themselves aren't available through the API: download them from the load's page.
  • Filters: status, shipper_id, carrier_id, deal_id, pickup_after, pickup_before, plus the usual paging and updated_since (for "changed since"). Loads in the Trash aren't listed.
  • While Loads is off, both answer 403 module_disabled (nothing is deleted).

Invoices

Read your invoices and their payments with a key that has Invoices and payments: Read (invoices:read). There's no way to create or change them through the API yet, and no invoices:write scope.

curl "$FIFI/api/v1/invoices?status=sent&overdue=true" -H "Authorization: Bearer $FIFI_API_KEY"
curl "$FIFI/api/v1/invoices/<id>" -H "Authorization: Bearer $FIFI_API_KEY"
curl "$FIFI/api/v1/payments?invoice_id=<id>" -H "Authorization: Bearer $FIFI_API_KEY"
  • An invoice: id, number (null for a draft), status (draft, sent, partially_paid, paid, void), overdue (worked out: sent or partially paid and past its due date in the workspace owner's time zone), contact_id, company_id (null once those are deleted: the invoice keeps its own copy of whom it's billed to), bill_to_name, bill_to_email, bill_to_address, customer_reference, issue_date, due_date, terms, currency (USD), the money in cents (subtotal_cents, tax_cents, total_cents, paid_cents, balance_cents: the total less what is paid, and 0 for a void invoice), tax_label, tax_rate (a percent with up to 3 decimals), notes, issued_at, paid_at, voided_at, void_reason, created_at, updated_at;
  • on GET /invoices/{id}, its lines in order: position, description, quantity (up to 3 decimals), unit_price_cents, line_total_cents, taxable, and the record it bills (source_type: booking, stay, service_record, deal or load, and source_id).
  • Filters: status, contact_id, company_id, issued_after, issued_before (YYYY-MM-DD), overdue (true or false), plus the usual paging and updated_since. Invoices in the Trash aren't listed.
  • A payment (GET /payments, and payments on GET /invoices/{id}): id, invoice_id, kind (payment or refund; amount_cents is always positive), amount_cents, received_on (YYYY-MM-DD), method (cash, check, bank_transfer, card taken outside Fifi, other), reference, notes, voided_at, void_reason (a voided payment was entered in error and counts for nothing), created_at, updated_at. Filters: invoice_id, received_after (on or after), received_before (before), method, kind, include_voided (true: voided entries too; left out by default), plus the usual paging.
  • While Invoicing is off, all three answer 403 module_disabled (nothing is deleted).

Retrying safely: Idempotency-Key

Add an Idempotency-Key header (any printable text up to 255 characters, such as a UUID) to a POST. If you send the same request again with the same key within 24 hours, Fifi returns the first response with Idempotent-Replayed: true instead of creating a duplicate.

  • Same key, different body: 422 idempotency_key_reused.
  • Same key, while the first is still running: 409 idempotency_key_in_use. Retry a moment later.

Rate limits

  • 120 requests a minute per key and 600 per workspace (on Pro, the workspace limit is 300).
  • Each response carries RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset and RateLimit-Policy.
  • Over the limit you get 429 with a Retry-After header (seconds).
  • Too many bad keys from one address (60 a minute) are blocked for a while, even with a good key.

Errors

Errors look like this:

{ "error": { "code": "invalid_request", "message": "Some fields aren't valid.", "details": { "fields": { "email": "..." } } } }
StatusCommon codesWhat to do
400invalid_request, invalid_json, invalid_cursor, cookie_not_allowedFix the request; details.fields says which field
401unauthorizedCheck the key; it may be revoked or expired
403insufficient_scope, forbidden, module_disabled, sample_workspaceGive the key the scope in details.required, or turn the module on. sample_workspace: the key belongs to a sample workspace, where the API never works
404not_foundThe record doesn't exist, is deleted, or belongs to another workspace
409conflict (duplicate_email, duplicate_domain, stale), idempotency_key_in_useUse the existing record (details.existing_id) or retry; stale: the record changed after the expected_updated_at you sent, so fetch it again first
413, 415payload_too_large, unsupported_media_typeKeep bodies under 256 KB, send JSON
422rule_violation, idempotency_key_reusedA business rule refused it; read the message
429rate_limitedWait for Retry-After
500internalRetry; quote the X-Request-Id header if you contact support
503api_disabledThe API isn't available on this Fifi

Every response has an X-Request-Id header. Fifi never sends CORS headers, so call the API from a server, not a browser.

Troubleshooting

  • 401 straight after creating a key: make sure you copied the whole key, including fifi_.
  • A key shows "Paused": its creator's account isn't active. Create a new key as an active owner or admin.
  • 403 insufficient_scope: edit isn't possible; create a new key with the right access and revoke the old one.
  • Changes don't show in a sync by updated_since: pass the time with a time zone, for example 2026-10-05T00:00:00Z.