Build on the same API the app uses.
A JSON REST API, lead-intake webhooks for your ad platforms and website, and signed webhooks for everything that happens in a workspace.
Introduction
The base URL is https://crm.anibrosdesign.com/api/v1. Requests and responses are JSON. Successful responses wrap the result as { "data": … }; lists add a page object for the next page. Times are ISO 8601 in UTC. Everything is over HTTPS.
https://crm.anibrosdesign.com/api/v1The API is the one the web app itself uses, so anything you can do in the CRM you can do from code, within the permissions of the key you use.
Authentication
Create an API key in the CRM under Settings → API keys. Send it as a bearer token on every request:
curl https://crm.anibrosdesign.com/api/v1/leads?limit=5 \
-H "Authorization: Bearer crm_live_xxxxxxxx"- A key is shown once, when you create it. Store it like a password. Only a hash is kept on our side.
- A key belongs to one workspace (one client) and can do only what its scopes allow. Two ready-made templates cover the usual cases: send leads in (create leads and contacts) and read data out (read leads, contacts, activities and reports).
- Keys can expire, and can be revoked at any time. Failed attempts are rate-limited per address.
- How many keys you can have depends on your plan.
Send your first lead
Create a lead with a contact. If the phone number or e-mail matches a person you already have, the lead is linked to them rather than creating a duplicate.
curl https://crm.anibrosdesign.com/api/v1/leads \
-H "Authorization: Bearer crm_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f2c1d9e-form-1042" \
-d '{
"contact": { "firstName": "Priya", "lastName": "Sharma", "phone": "+919800000000", "email": "priya@example.com" },
"source": "website",
"campaign": "diwali-offer",
"estimatedValue": 8500000
}'const res = await fetch('https://crm.anibrosdesign.com/api/v1/leads', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.CRM_KEY,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
contact: { firstName: 'Priya', lastName: 'Sharma', phone: '+919800000000', email: 'priya@example.com' },
source: 'website',
campaign: 'diwali-offer',
}),
});
if (!res.ok) throw new Error((await res.json()).detail);
const { data: lead } = await res.json();
console.log(lead.id);$ch = curl_init('https://crm.anibrosdesign.com/api/v1/leads');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('CRM_KEY'), 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'contact' => ['firstName' => 'Priya', 'lastName' => 'Sharma', 'phone' => '+919800000000'],
'source' => 'website',
'campaign' => 'diwali-offer',
]),
]);
$lead = json_decode(curl_exec($ch), true)['data'];import os, requests
r = requests.post(
'https://crm.anibrosdesign.com/api/v1/leads',
headers={'Authorization': 'Bearer ' + os.environ['CRM_KEY']},
json={
'contact': {'firstName': 'Priya', 'lastName': 'Sharma', 'phone': '+919800000000'},
'source': 'website',
'campaign': 'diwali-offer',
},
timeout=15,
)
r.raise_for_status()
lead = r.json()['data']The response is the created lead, with its id, stage, owner (assigned by your routing rules) and score.
Leads
| Endpoint | What it does |
|---|---|
GET/leads | List leads. Filters: q, stageId, ownerId, source, state (open or closed), status, createdFrom, createdTo, tag, sort, order. |
POST/leads | Create a lead (see above). source is one of website, referral, portal, direct, meta_ads, google_ads, walk_in, other. |
GET/leads/{id} | One lead with its contact, stage history and details. |
PATCH/leads/{id} | Change fields. Send If-Match: "<version>" with the version you read; a stale edit gets 412 instead of overwriting someone else's change. |
POST/leads/{id}/stage | Move to a stage: { "stageId": "…" }. Lost or won stages may need reason or a deal value, depending on your pipeline rules. |
POST/leads/{id}/assign | Give the lead to a person: { "ownerId": "…" }, or null to unassign. |
POST/leads/{id}/activities | Log a call, WhatsApp, SMS, e-mail, meeting or note: { "type": "call", "description": "…", "direction": "outbound", "durationMinutes": 6, "nextFollowupAt": "…" }. |
GET/leads/{id}/activities | The lead's activity history. |
Other resources
| Resource | Endpoints |
|---|---|
| Contacts | GET/POST /contacts, GET/PATCH/DELETE /contacts/{id}, GET /contacts/lookup |
| Tasks | GET/POST /tasks, GET/PATCH /tasks/{id}, POST /tasks/{id}/complete |
| Pipelines | GET /pipelines, GET /pipelines/{id}, GET /pipelines/{id}/board |
| Properties | GET/POST /properties, GET/PATCH /properties/{id} |
| Lead sources | GET/POST /lead-sources, POST /lead-sources/{id}/test |
| Webhooks | GET/POST /webhooks, POST /webhooks/{id}/test, GET /webhooks/deliveries |
What a key can reach is limited by its scopes, for example leads:read, leads:create, contacts:create. GET /api-keys/scopes lists them.
This table is a summary. The API reference lists every endpoint with its parameters and request body, and the OpenAPI file can be imported into Postman or used to generate a client.
Pagination
List endpoints use cursors. Pass limit (1 to 200, default 50) and, for the next page, the cursor from the previous response's page.next. When page.next is empty you have everything.
curl "https://crm.anibrosdesign.com/api/v1/leads?limit=100&state=open" -H "Authorization: Bearer crm_live_xxxxxxxx"
# { "data": [ … ], "page": { "next": "eyJ…" } }
curl "https://crm.anibrosdesign.com/api/v1/leads?limit=100&state=open&cursor=eyJ…" -H "Authorization: Bearer crm_live_xxxxxxxx"Errors
Errors follow RFC 9457 (application/problem+json). Every error carries a stable code and a requestId; quote the request id when you contact us.
{
"type": "https://crm.anibrosdesign.com/developers#errors",
"title": "Validation failed",
"status": 400,
"detail": "One or more fields are invalid",
"code": "VALIDATION_FAILED",
"requestId": "01a15d23-…",
"errors": [ { "path": "body.contact.phone", "message": "Enter a valid phone number" } ]
}| Status | Meaning |
|---|---|
400 | The request is invalid. errors says which fields. |
401 | Missing, wrong, expired or revoked key. |
402 | The plan blocks this: a limit is reached (PLAN_LIMIT) or the subscription has ended and the workspace is read-only (SUBSCRIPTION_REQUIRED). Reading still works. |
403 | The key's scopes do not allow this. |
404 | Not found, or not in this key's workspace. |
409 / 412 | Conflict, or the record changed since you read it (If-Match). |
429 | Too many requests. Wait for the seconds in Retry-After. |
5xx | Something went wrong on our side. Safe to retry with the same Idempotency-Key. |
Limits and idempotency
Rate limits. Requests are limited per key and per address. When you exceed a limit you get 429 and a Retry-After header; RateLimit-Limit and RateLimit-Remaining are sent along. Back off and retry.
Idempotency. Send an Idempotency-Key header (any unique string up to 128 characters) on POST requests that create things (leads, contacts, tasks, messages, webhooks). Repeating a request with the same key and the same body returns the original response instead of creating a second record, so retrying after a timeout is always safe. The same key with a different body is rejected.
Plan limits. Your plan caps client workspaces, team members, leads and API keys. Hitting a cap returns 402 with the limit, what you use and the plan.
Lead intake
Each lead source you create in Settings → Lead sources gets its own address, https://crm.anibrosdesign.com/hooks/{source-id}. Use it for ad platforms, your website and your own systems. It is separate from the REST API and does not use API keys or cookies.
| Source type | How it is authenticated |
|---|---|
| Meta lead ads | Meta's own X-Hub-Signature-256, checked with your app secret. Each Facebook Page has its own access token (see below). |
| The same Meta signature. Every message a customer sends to your WhatsApp Business number lands on their lead. | |
| Google ads | The key you set on the lead form asset (Google Ads → Assets → Lead form → Webhook integration). |
| Website | No secret (it runs in a browser); only the origins you list are accepted. Use the capture script below. |
| Generic (your own system) | Authorization: Bearer <key> or X-Anibros-Key, or an HMAC signature: X-Anibros-Signature: t=<unix>,v1=<hex> where v1 is HMAC-SHA256 of "<t>.<raw body>" with the source secret. |
# a lead from your own system, authenticated with the source's key
curl https://crm.anibrosdesign.com/hooks/YOUR-SOURCE-ID \
-H "Authorization: Bearer YOUR-SOURCE-KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Priya Sharma", "phone": "+919800000000", "email": "priya@example.com", "utm_campaign": "diwali-offer" }'<!-- website source: paste the script from Settings → Lead sources once, in the page head or before </body> -->
<script src="https://crm.anibrosdesign.com/hooks/YOUR-SOURCE-ID/capture.js" async></script>
<!-- every form on the page is captured. Fields named name, email and phone (and common variations) are recognised -->
<form action="/thank-you" method="post">
<input name="name"> <input name="phone"> <input name="email">
<button>Enquire</button>
</form>Field names are matched generously (name, full_name, phone, mobile, email and common variations). The source and campaign are recorded, a repeat enquiry is linked to the existing person, and every delivery is kept so a failed one can be retried from the CRM. The same delivery sent twice creates one lead.
Meta and Google in detail
Meta lead ads: one address, one token per Facebook Page
A Meta app has one webhook address for every Facebook Page it serves, but a lead can only be read with the access token of the Page it came from. So a Meta lead source holds a list of Pages, each with its own token. In the CRM: Settings → Lead sources → your Meta source → Pages. From code:
# add a Page (the token is checked with Meta, kept encrypted and never returned)
curl -X POST https://crm.anibrosdesign.com/api/v1/lead-sources/SOURCE-ID/pages \
-H "Authorization: Bearer crm_live_xxxxxxxx" -H "Content-Type: application/json" \
-d '{ "token": "EAAB…page-access-token", "subscribe": true }'
# how each Page is doing: token state, notifications, last lead, leads waiting
curl https://crm.anibrosdesign.com/api/v1/lead-sources/SOURCE-ID/pages -H "Authorization: Bearer crm_live_xxxxxxxx"
# a new token for a Page whose token stopped (waiting leads are read at once)
curl -X PATCH https://crm.anibrosdesign.com/api/v1/lead-sources/SOURCE-ID/pages/188174958051218 \
-H "Authorization: Bearer crm_live_xxxxxxxx" -H "Content-Type: application/json" -d '{ "token": "EAAB…" }'- Set up once: in Meta for Developers open your app → Webhooks → Page, paste the source's address as Callback URL and its verify token, subscribe the
leadgenfield. The app must be Published: Meta delivers nothing to an app in development mode. Leads need the permissionsleads_retrieval,pages_show_list,pages_read_engagement,pages_manage_metadataandads_management. - Per Page: add the Page with its token. We ask Meta which Page it is for, refuse a personal token, check the token works, and (unless you say otherwise) switch on lead notifications for the Page. A Page belongs to one lead source on the whole platform.
- Reading a lead: we use that Page's own token, and fill in the campaign, ad set and ad names from the ad when Meta's lead record does not carry them.
- Nothing is lost: a lead of a Page without a working token waits in the delivery log, saying which Page, and is read automatically the moment you save a token. Every few hours each token is checked with Meta; the owners are notified when one stops, or ends within a week (give the source your Meta App ID under Settings so Meta can tell us when it ends).
- Tip: a Page token made from a long-lived user token does not expire. Make it in the Graph API Explorer (
me/accounts) after extending the login in the Access Token Debugger.
Google ads: a key per lead form
Google keeps the webhook address and key inside each lead form asset, so one source can serve any number of forms and campaigns. Every lead carries its campaign number, form number and a click id; Google does not send the campaign name, so name the campaign once in Campaigns. Google sends the same test person every time you press “Send test data”, so repeated tests join the first lead instead of creating new ones.
Agencies: whose lead is it
The ad platform says which campaign a lead came from, never whose it is. A source in an agency's own workspace can be switched to sort leads between clients. Each lead then goes to a client by the first rule that proves it: a person's choice for that lead, the campaign's own rule, a test lead (stays with the agency), a word you gave the client that appears in the campaign, ad set, ad or form name, then a default for the form, Page, ad account or source. If nothing proves it, the lead waits in the Waiting inbox, masked, with the reason and a suggestion, and is never guessed. Deciding once for a campaign files every waiting lead of it and every future one.
| Endpoint | What it does |
|---|---|
GET/ad-routing/overview | What waits, which campaigns are owned, defaults. |
GET/ad-routing/unrouted | Waiting leads, grouped by campaign. |
POST/ad-routing/assign | Decide which client a campaign (or form, Page, ad account) belongs to. |
GET/campaigns | Every campaign leads came from, with counts and owner. |
GET/leads?channel=meta | Leads by where they came from. GET /leads/channels gives the counts per channel for the same filters. |
Webhooks
Add an endpoint under Settings → Send data out (it must be a public HTTPS address). Choose the events you want:
lead.created · lead.assigned · lead.stage_changed · lead.updated · lead.deleted · activity.created · task.created · task.completed · contact.created
POST /your/endpoint HTTP/1.1
Content-Type: application/json
X-Anibros-Event: lead.created
X-Anibros-Delivery: 01a15d23-…
X-Anibros-Signature: t=1766300000,v1=9f2b…
{
"id": "01a15d23-…",
"event": "lead.created",
"occurredAt": "2026-10-21T10:15:00.000Z",
"workspace": { "id": "01a15c…" },
"change": { … },
"data": { … }
}Every delivery is signed with your endpoint's secret (shown once, rotatable). Verify the signature before you trust the payload, using the raw body:
import crypto from 'node:crypto';
// raw = the request body exactly as received (a Buffer or string), never re-serialised JSON
export function verify(raw, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!parts.t || !parts.v1 || age > toleranceSeconds) return false;
const expected = crypto.createHmac('sha256', secret).update(parts.t + '.' + raw).digest('hex');
return expected.length === parts.v1.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}function verify(string $raw, string $header, string $secret, int $tolerance = 300): bool
{
parse_str(str_replace(',', '&', $header), $p);
if (empty($p['t']) || empty($p['v1']) || abs(time() - (int) $p['t']) > $tolerance) return false;
return hash_equals(hash_hmac('sha256', $p['t'] . '.' . $raw, $secret), $p['v1']);
}
$ok = verify(file_get_contents('php://input'), $_SERVER['HTTP_X_ANIBROS_SIGNATURE'] ?? '', getenv('WEBHOOK_SECRET'));import hmac, hashlib, time
def verify(raw: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split('=', 1) for p in header.split(','))
if 't' not in parts or 'v1' not in parts or abs(time.time() - int(parts['t'])) > tolerance:
return False
expected = hmac.new(secret.encode(), parts['t'].encode() + b'.' + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts['v1'])- Reply with any
2xxwithin 8 seconds. Anything else is retried automatically with growing delays, up to 8 attempts. Deliveries and their results are listed in the CRM, and a failed one can be retried by hand. - An event can be delivered more than once. Use
X-Anibros-Deliveryor the eventidto ignore repeats. - Endpoints that point at private or internal addresses are refused.
- Outgoing webhooks are part of the Growth and Scale plans.
API reference
Every endpoint, with its parameters, request body, the permission it needs and an example call, generated from the running API so it is always current: open the reference. The machine-readable version is openapi.json (OpenAPI 3.1).
What changed
- October 2026. Downloads can be aimed:
GET /leads/exportandGET /agency/leads/exporttake the campaign, ad set, ad, form and channel filters and a period as days or exact timestamps, andGET /leads/export/countandGET /agency/leads/export/countsay how many leads a download would hold before it is built.POST /messaging/email/previewreturns the lead e-mail exactly as it would be sent. The agency routes accept the agency’s own workspace asclientId. - October 2026. A Meta lead source holds one access token per Facebook Page (
/lead-sources/{id}/pages), checked with Meta and renewed without losing a lead. Campaign, ad set and ad names are filled in from the ad. The API reference and the OpenAPI file are generated from the live API. - October 2026. Leads know where they came from:
channelfilter andGET /leads/channels. Agencies decide whose campaign is whose (/ad-routing,/campaigns); leads that cannot be placed wait instead of being guessed. Tagged links (UTM and click ids), Click-to-WhatsApp ads and Instagram placements are recognised. - October 2026. Personal details are removed from finished lead deliveries after 45 days; the lead itself is untouched.
Something missing or unclear? Tell us. The API grows with what people build on it.