API & webhooks

Last updated 16 September 2026

Authentication

Every request carries an API key as a bearer token. Keys are created in Flowtiq under Settings → API & webhooks by an owner or admin, and belong to the workspace rather than to the person who made them — the key decides which workspace the request reads and writes, and nothing in the request can change that.

curl https://flowtiq.ai/api/v1/me \
  -H "Authorization: Bearer ft_live_…"

{"data":{"org":{"id":"…","name":"Acme"},"scopes":["read","write"],"key_prefix":"ft_live_…a1b2"}}

GET /api/v1/me is the whole of that endpoint: it names the workspace the key belongs to, the scopes it carries and the prefix shown in settings, and it touches no customer data — which is what makes it the right thing behind a "test connection" button.

A key has one or both of two scopes. read allows GET; write allows POST, PATCH and DELETE and implies read. A key without the scope the endpoint needs is 403 insufficient_scope; a key that is unknown or has been revoked is 401 invalid_api_key.

The key is shown once

We store only a SHA-256 of the token, so we cannot show it to you again and cannot recover it for you. If a key is lost, revoke it and create another. Treat it as a password: it grants the same access to your workspace data that you have.

Rate limit

120 requests per minute, per key. The window is a fixed minute that restarts on the first request after it lapses. Over the limit you get 429 rate_limited with a Retry-After: 60 header; wait that long and retry rather than spinning.

Pagination

List endpoints return { "data": [...], "next_cursor": "…" }, newest first. Pass the next_cursor back as ?cursor= to get the next page; a null cursor means you have reached the end.

  • limit — 1 to 100, default 50. A larger value is clamped, not rejected.
  • cursor — opaque. It encodes the last row's position, so inserts during your walk cannot make you skip or repeat a row the way an offset would. Do not parse it, and send it back verbatim. A cursor we did not issue is 400 validation_error, not page one: one truncated in transit used to re-serve page one silently, so a paging client looped on it and reprocessed the same rows forever.
  • updated_since — an ISO-8601 timestamp, e.g. 2026-01-31T09:00:00Z. Returns only rows modified at or after it, which is what you want for an incremental sync. A value we cannot parse — epoch seconds, say — is rejected, not ignored: ignoring it returned the entire table, which a delta sync reads as "every row changed". Contacts, deals and tasks only; invoices have no updated_at and reject it.

Contacts

  • GET /api/v1/contacts — list. Supports q (matches name, email or company), updated_since, limit and cursor.
  • POST /api/v1/contacts — create. Returns 201 and the stored row.
  • GET /api/v1/contacts/{id} — fetch one.
  • PATCH /api/v1/contacts/{id} — update the fields you send; omitted fields are left alone.
  • DELETE /api/v1/contacts/{id} — delete. Returns 204.

full_name is required on create. The optional fields are email, phone, company, title, industry, country, source, notes, lifecycle_stage (one of lead, prospect, customer, churned, mql, sql, opportunity), tags (up to 50 strings of 80 characters), linkedin_url, linkedin_company_url and custom_fields (an object of scalar values).

Those custom fields are bounded: at most 100 keys, each key up to 80 characters, and string values up to 2,000. A payload over those limits is 400 validation_error — every contact write is echoed into every matching webhook delivery, so an unbounded blob is paid for again per subscriber.

On PATCH, sending null clears a field. tags and custom_fields are stored empty rather than null — they are collections, and {"tags": null} and {"tags": []} do the same thing. Omitting a field leaves it alone; there is no way to say "leave this one" and "clear that one" other than by which keys you send.

Deals

  • GET /api/v1/deals — list. Supports status, pipeline_id, updated_since, limit and cursor.
  • POST /api/v1/deals — create.
  • GET /api/v1/deals/{id} — fetch one.
  • PATCH /api/v1/deals/{id} — update.
  • DELETE /api/v1/deals/{id} — delete. Returns 204.

title, pipeline_id and stage_id are required on create, and the stage must belong to that pipeline. The optional fields are value, currency (a three-letter code), contact_id, expected_close_date and notes.

status is read-only. It is derived from the stage you put the deal in — won, lost or open — so moving a deal into your "Closed won" stage is what marks it won, over the API exactly as on the board. Sending a status yourself would let the column and the board disagree, so we ignore it.

Tasks

  • GET /api/v1/tasks — list. Supports status, q (matches the title), updated_since, limit and cursor.
  • POST /api/v1/tasks — create. Returns 201 and the stored row.
  • GET /api/v1/tasks/{id} — fetch one.
  • PATCH /api/v1/tasks/{id} — update the fields you send; omitted fields are left alone.
  • DELETE /api/v1/tasks/{id} — delete. Returns 204.

title is required on create. The optional fields are status (one of backlog, todo, in_progress, review, done), priority (low, medium, high, urgent), due_date, description, and the relations assignee_id, project_id, contact_id and deal_id.

due_date is a calendar day, YYYY-MM-DD. A full timestamp is 400 validation_error rather than being truncated, because truncation moves the date by a day for anyone east or west of UTC and nothing would say so.

Invoices

  • GET /api/v1/invoices — list. Supports status, q (matches the number or the client name), limit and cursor.
  • POST /api/v1/invoices — create a draft, with its line items. Returns 201.
  • GET /api/v1/invoices/{id} — fetch one, with its items.
  • PATCH /api/v1/invoices/{id} — set status (sent, paid or void), due_date or notes.

Create takes items — one to two hundred rows of description, quantity and unit_price — plus either client_name or a contact_id to take the name and email from. The optional header fields are client_email, due_date, currency (a three-letter ISO code, lower case accepted and stored upper), tax_rate (a percentage) and notes. The invoice number is allocated by us and is consecutive per workspace.

Invoices are the one list that does not take updated_since: they have no updated_at column, so sending it is 400 validation_error rather than a filter that quietly matches the whole table and reads, to a delta sync, as "every invoice changed".

subtotal and total are read-only: they are computed in the database from the line items and the tax rate, so an invoice cannot be stored whose total disagrees with its own rows. Send money only on the items.

There is no DELETE. An invoice is a financial record, so the destructive operation is status: "void" — it keeps the row and its number, which stays spent. A number that could be reused is a number that means nothing.

Idempotency

Send an Idempotency-Key header on any POST or PATCH — any string up to 255 characters, one per logical operation. A request that times out can then be retried safely: we replay the original response instead of performing the write again.

  • A repeat of the same key and the same body returns the stored status and body verbatim, with Idempotent-Replayed: true. Nothing is written the second time.
  • The same key with a different body is 422 idempotency_key_reused. That is a bug in the caller, and answering it with the previous success would hide the bug and silently drop the write.
  • Keys are scoped to the API key that used them and kept for 24 hours. Server faults (5xx) are not stored, so a retry after one is a real attempt.
  • Omitting the header is allowed and unchanged: the request is performed every time.

Errors

Failures answer with { "error": "<code>", "message": "…" } and the matching status: 400 validation_error (with a details array naming each bad field), 401 invalid_api_key, 402 trial_ended (the workspace's free trial ended with no plan; the owner can choose one in Settings → Billing), 403 insufficient_scope, 404 not_found, 405 method_not_allowed (with an Allow header), 409 conflict, 422 idempotency_key_reused, 429 rate_limited, 500 internal.

409 conflict means the record collides with one that already exists — a duplicate of something the database holds unique. Retrying it unchanged will collide again. 500 internal is the only status worth a blind retry.

Each entry in details carries a path locating the field, and a message saying what was wrong with it. A rejected query parameter — a cursor we did not issue, an unparseable updated_since — is the same status with the explanation in message and no details.

A record belonging to another workspace is 404, never 403. A 403 would confirm the id exists, which is exactly what someone guessing ids wants to learn.

Webhooks

Add an endpoint under Settings → API & webhooks, choose the events you care about, and we POST JSON to it when they happen. The body is { id, event, org_id, created_at, data }, where data is the whole row as it stands after the change.

  • contact.created — contact created
  • contact.updated — contact updated
  • contact.deleted — contact deleted
  • deal.created — deal created
  • deal.updated — deal updated
  • deal.stage_changed — deal moved to another stage
  • deal.won — deal won
  • deal.lost — deal lost
  • task.created — task created
  • task.updated — task updated
  • task.completed — task completed
  • invoice.created — invoice created
  • invoice.updated — invoice updated
  • invoice.sent — invoice sent
  • invoice.paid — invoice paid

Every event derived from an update carries a sixth key, previous: the row as it was before. That is contact.updated, deal.updated, task.updated, invoice.updated, deal.stage_changed, deal.won, deal.lost, task.completed, invoice.sent and invoice.paid. Diff it against data to see what actually moved — a deal.updated fires for a note as readily as for a value.

Send test in settings posts a ping event — { "message": "Hello from Flowtiq" } as its data — to that one endpoint. It is not subscribable and does not appear in the list above, so a handler that rejects unknown event names will fail the test it was meant to pass; accept ping and answer 2xx.

A new endpoint receives nothing until it has proved it wants to. On save — and again whenever you change the URL — we POST one webhook.verify event, signed exactly like a real delivery, whose data is { "challenge": "<random>" }. Answer 2xx within ten seconds with that challenge value somewhere in the body — bare text, {"challenge":"…"} or an echo of the whole request all count. Until then the endpoint is inactive and nothing is queued for it: events that happen while it is unverified are not held and are not delivered later.

The challenge sent when you first save is signed with a secret you have not seen yet — it is shown to you right after. So if your handler verifies signatures before it echoes (it should), expect that first challenge to fail. Deploy the secret, then press Verify in settings; that one will pass. Changing the URL later clears the verification and repeats this, because it belongs to the address.

The URL must be a public https:// address on port 443 or 8443. Private, loopback, link-local and internal-only hosts are refused. Redirects are not followed — a 3xx is recorded as a failed delivery — so point us at the final address.

Every delivery carries three headers:

  • X-Flowtiq-Event — the event name, e.g. contact.created, and the same value as event in the body. Route on it without parsing.
  • X-Flowtiq-Delivery — the id of this delivery attempt's queue row: unique per endpoint, and stable across the retries of that delivery. It is not the body's id.
  • X-Flowtiq-Signature — t=<unix seconds>,v1=<hex>.

The two ids answer different questions, and one event fans out to every endpoint subscribed to it. The body's id is the event: shared by all those endpoints, stable across retries. The header is the delivery: one per endpoint per event, also stable across retries. So deduplicate on the body's id if you want the event processed once no matter how many of your endpoints receive it, and on the header if each endpoint keeps its own ledger.

The signature is an HMAC-SHA256 over `${t}.${rawBody}` keyed with your webhook's signing secret. Verify it against the raw body, before any JSON parsing — a re-serialised body will not match.

const [t = "", v1 = ""] = (req.headers["x-flowtiq-signature"] ?? "")
  .split(",")
  .map((part) => part.split("=")[1] ?? "");

const expected = crypto
  .createHmac("sha256", secret)
  .update(`${t}.${rawBody}`)
  .digest("hex");

// timingSafeEqual throws unless both buffers are the same length, so reject a
// missing or truncated signature before the comparison rather than with it.
const valid =
  v1.length === expected.length &&
  crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1)) &&
  Math.abs(Date.now() / 1000 - Number(t)) < 300;

// The ownership challenge. Echo it back or the endpoint never goes active.
const event = req.headers["x-flowtiq-event"];
if (valid && event === "webhook.verify") {
  return res.status(200).send(JSON.parse(rawBody).data.challenge);
}

Check the timestamp as well as the digest, and reject anything more than five minutes old. Signing the timestamp alongside the body is what makes a replay detectable: a captured request cannot be re-sent later, because moving t invalidates the signature.

Answer 2xx to acknowledge. Anything else — or no answer within ten seconds — is a failure, and we try again after 1 minute, then 5 minutes, 30 minutes, 2 hours and 12 hours. That is six attempts in all, the last of them about 14 hours and 36 minutes after the first; only once that one fails is the delivery marked failed and dropped. An endpoint that is down for an afternoon still gets the event. The queue is drained every five minutes, so each attempt can start up to five minutes after its slot. Every attempt and its response code is visible next to the endpoint in settings.

A queued event whose endpoint you have paused or deleted in the meantime is dropped rather than held: it is marked failed on the next run, not kept until you re-enable it.

Answer fast, work later

Return 200 as soon as you have stored the event, and do the work afterwards. A handler that finishes its processing before responding will time out on a slow day and receive the same event again.