Developers

The CompanyFlowHQ API

Send leads in from any website or app, post to social media from your own tools, and get told the moment something happens with webhooks. Included with the Growth, Pro and Social Agency plans.

Getting started

  1. In CompanyFlowHQ, go to Settings → Developers and create an API key. Tick only the permissions it needs.
  2. Copy the key straight away — it starts with cfhq_live_ and is shown once.
  3. Call the API from your server (never from a web page or app a customer can see).

Base address: https://companyflowhq.com/api/v1. Requests and responses are JSON (UTF-8). Times are ISO 8601 in UTC. Money is in the smallest unit (pence for GBP).

Authentication

Send the key in the Authorization header:

curl https://companyflowhq.com/api/v1/leads -H "Authorization: Bearer cfhq_live_YOUR_KEY"

Permissions (scopes):

ScopeLets the key…
leads:readRead leads
leads:writeAdd and update leads, add notes
social:readRead social accounts and posts
social:writeCreate social posts and upload pictures
bookings:readRead bookings
invoices:readRead invoices
ads:readRead Facebook & Instagram ad campaigns and results
webhooks:manageManage webhooks

Keys can be revoked at any time and can be set to expire. We store only a fingerprint (SHA-256) of each key.

Endpoints

MethodPathScopeWhat it does
GET/api/v1/leadsleads:readList leads, newest first. Filters: stage, email, updated_since.
POST/api/v1/leadsleads:writeAdd a lead (same duplicate check and auto-replies as your website form).
GET/api/v1/leads/{id}leads:readOne lead.
PATCH/api/v1/leads/{id}leads:writeChange details, stage or priority.
POST/api/v1/leads/{id}/notesleads:writeAdd a note to the lead's timeline.
GET/api/v1/social/accountssocial:readYour connected social accounts.
POST/api/v1/social/postssocial:writeCreate a post for one or more accounts: now, at a time, next queue slot or as a draft.
GET/api/v1/social/posts/{id}social:readA post's status and the result on each network.
POST/api/v1/mediasocial:writeUpload a picture (JPEG, PNG, WebP or GIF, up to 20 MB). Returns its id.
GET/api/v1/bookingsbookings:readBookings. Filters: from, to, status.
GET/api/v1/invoicesinvoices:readSent invoices, quotes and credit notes. Filters: status, kind.
GET / POST/api/v1/webhookswebhooks:manageList or add webhooks.
PATCH / DELETE/api/v1/webhooks/{id}webhooks:manageChange, switch off or delete a webhook.

Leads example

Add a lead (curl)

curl -X POST https://companyflowhq.com/api/v1/leads \
  -H "Authorization: Bearer cfhq_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: form-2026-09-27-000123" \
  -d '{
    "first_name": "Amara",
    "last_name": "Perera",
    "email": "amara@example.com",
    "phone": "+44 7700 900123",
    "requirement": "MSc Data Science, January intake",
    "consent_marketing": true
  }'

Returns 201 with { "data": { …lead } }. If the person is already in your workspace (same email or phone), you get 200 with "duplicate": true and the existing lead. Sending the same Idempotency-Key twice never creates two leads.

Move a lead on (JavaScript)

const res = await fetch("https://companyflowhq.com/api/v1/leads/LEAD_ID", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.CFHQ_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ stage: "qualified", priority: "hot" }),
});
if (!res.ok) {
  const { error } = await res.json();
  throw new Error(`${error.code}: ${error.message}`);
}
const { data: lead } = await res.json();

Social posts example

Upload a picture first (optional), then create the post. Each account can have its own text.

# 1. Upload a picture
curl -X POST https://companyflowhq.com/api/v1/media \
  -H "Authorization: Bearer cfhq_live_YOUR_KEY" \
  -F "file=@open-day.jpg"
# → { "data": { "id": "MEDIA_ID", ... } }

# 2. Schedule the post
curl -X POST https://companyflowhq.com/api/v1/social/posts \
  -H "Authorization: Bearer cfhq_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "Open day this Saturday, 10am to 2pm. Everyone welcome!",
    "media_ids": ["MEDIA_ID"],
    "accounts": [
      { "account_id": "FACEBOOK_ACCOUNT_ID" },
      { "account_id": "LINKEDIN_ACCOUNT_ID", "text": "Join our open day this Saturday (10:00–14:00)." }
    ],
    "schedule": { "mode": "at", "at": "2026-10-03T08:00:00Z" }
  }'

schedule.mode is now, at, queue (next free slot) or draft. Add "require_approval": true to send it for approval in CompanyFlowHQ instead. Posts follow the same rules as in the app, including client approval for agencies. Check progress with GET /api/v1/social/posts/{id} — results lists each network's status, link and any error.

Pagination

Lists return up to limit items (default 25, most 100), newest first:

{ "data": [ … ], "has_more": true, "next_cursor": "WyIyMDI2LTA5…" }

Pass ?cursor= with next_cursor to get the next page. Stop when has_more is false.

Webhooks

Add a webhook in Settings → Developers (or with the API). We POST JSON to your HTTPS address when something happens:

EventWhen
lead.createdA new lead arrives
lead.updatedA lead's details or stage change
booking.createdA booking is made
invoice.paidAn invoice is paid in full
social.post.publishedA social post is published
social.post.failedA social post fails
social.post.milestoneA social post passes a views milestone (1,000, 2,500, 5,000…)
chat.lead_createdA website chat becomes a lead
POST /your-webhook
Content-Type: application/json
X-CompanyFlow-Event: lead.created
X-CompanyFlow-Delivery: 5f0c…
X-CompanyFlow-Signature: t=1790000000,v1=6b1f…

{ "id": "evt_…", "type": "lead.created", "created": "2026-09-27T10:00:00.000Z",
  "data": { "lead": { "id": "…", "first_name": "Amara", … }, "source": "Website" } }

Answer with any 2xx status within 10 seconds. Anything else counts as a failure and we try again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. After 20 failures in a row the webhook is switched off and the owner is emailed. Redirects are not followed, and addresses must be public (no private or local networks). The same event can occasionally arrive twice — use id to ignore repeats.

Checking signatures

Every message is signed with your webhook's secret (starts with whsec_). The signature is an HMAC-SHA256 of <t>.<raw body>. Always check it, and reject messages older than 5 minutes.

Node.js (Express)

import crypto from "node:crypto";
import express from "express";

const app = express();
app.post("/companyflow-webhook", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("X-CompanyFlow-Signature") ?? "";
  const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto
    .createHmac("sha256", process.env.CFHQ_WEBHOOK_SECRET)
    .update(`${t}.${req.body}`)
    .digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
  const ok =
    fresh && v1 && v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
  if (!ok) return res.status(400).send("Bad signature");
  const event = JSON.parse(req.body);
  // … handle event.type / event.data …
  res.sendStatus(200);
});

Cloudflare Workers / Deno / browsers (Web Crypto)

async function verify(secret, header, rawBody) {
  const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const key = await crypto.subtle.importKey(
    "raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
  const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${t}.${rawBody}`));
  const hex = [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
  return hex === v1; // use a constant-time compare in production
}

Rate limits

Each key can make 120 requests a minute. Every response has X-RateLimit-Limit and X-Request-Id. Over the limit you get 429 with a Retry-After header — wait that many seconds and try again.

Errors

Errors always look the same:

{ "error": { "code": "insufficient_scope", "message": "This API key doesn't have the “leads:write” permission." } }
StatusCodeMeaning
400invalid_request / invalid_json / invalid_cursorSomething in the request needs fixing — the message says what.
401unauthorized / revoked / expiredThe key is missing, wrong, revoked or expired.
402plan_requiredThe workspace's plan doesn't include the API.
403insufficient_scope / key_owner_inactiveThe key lacks the permission, or the person who made it has left.
404not_foundNothing with that id in your workspace.
409conflictIt can't be changed right now (for example a post that has already gone out).
413payload_too_largeJSON bodies must be under 256 KB; pictures under 20 MB.
415unsupported_media_typeUpload a JPEG, PNG, WebP or GIF.
429rate_limitedMore than 120 requests a minute. Wait for the Retry-After seconds.
500server_errorOur fault. Try again; quote the X-Request-Id if it keeps happening.

Questions? Email companyflowhq@gmail.com.