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
- In CompanyFlowHQ, go to Settings → Developers and create an API key. Tick only the permissions it needs.
- Copy the key straight away — it starts with
cfhq_live_and is shown once. - 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):
| Scope | Lets the key… |
|---|---|
leads:read | Read leads |
leads:write | Add and update leads, add notes |
social:read | Read social accounts and posts |
social:write | Create social posts and upload pictures |
bookings:read | Read bookings |
invoices:read | Read invoices |
ads:read | Read Facebook & Instagram ad campaigns and results |
webhooks:manage | Manage 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
| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | /api/v1/leads | leads:read | List leads, newest first. Filters: stage, email, updated_since. |
| POST | /api/v1/leads | leads:write | Add a lead (same duplicate check and auto-replies as your website form). |
| GET | /api/v1/leads/{id} | leads:read | One lead. |
| PATCH | /api/v1/leads/{id} | leads:write | Change details, stage or priority. |
| POST | /api/v1/leads/{id}/notes | leads:write | Add a note to the lead's timeline. |
| GET | /api/v1/social/accounts | social:read | Your connected social accounts. |
| POST | /api/v1/social/posts | social:write | Create 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:read | A post's status and the result on each network. |
| POST | /api/v1/media | social:write | Upload a picture (JPEG, PNG, WebP or GIF, up to 20 MB). Returns its id. |
| GET | /api/v1/bookings | bookings:read | Bookings. Filters: from, to, status. |
| GET | /api/v1/invoices | invoices:read | Sent invoices, quotes and credit notes. Filters: status, kind. |
| GET / POST | /api/v1/webhooks | webhooks:manage | List or add webhooks. |
| PATCH / DELETE | /api/v1/webhooks/{id} | webhooks:manage | Change, 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:
| Event | When |
|---|---|
lead.created | A new lead arrives |
lead.updated | A lead's details or stage change |
booking.created | A booking is made |
invoice.paid | An invoice is paid in full |
social.post.published | A social post is published |
social.post.failed | A social post fails |
social.post.milestone | A social post passes a views milestone (1,000, 2,500, 5,000…) |
chat.lead_created | A 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." } }
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request / invalid_json / invalid_cursor | Something in the request needs fixing — the message says what. |
| 401 | unauthorized / revoked / expired | The key is missing, wrong, revoked or expired. |
| 402 | plan_required | The workspace's plan doesn't include the API. |
| 403 | insufficient_scope / key_owner_inactive | The key lacks the permission, or the person who made it has left. |
| 404 | not_found | Nothing with that id in your workspace. |
| 409 | conflict | It can't be changed right now (for example a post that has already gone out). |
| 413 | payload_too_large | JSON bodies must be under 256 KB; pictures under 20 MB. |
| 415 | unsupported_media_type | Upload a JPEG, PNG, WebP or GIF. |
| 429 | rate_limited | More than 120 requests a minute. Wait for the Retry-After seconds. |
| 500 | server_error | Our fault. Try again; quote the X-Request-Id if it keeps happening. |
Questions? Email companyflowhq@gmail.com.