Four ways data moves
Each direction has its own mechanism. Two of them share a single URL, distinguished only by the verb.
01Get your token
- 1Open the CRM and go to Settings → Channels.
- 2Find the card headed Automation API (Pabbly / Make / Zapier).
- 3Press Enable automation API. A URL appears.
https://brandingpioneers.in/api/webhooks/crm/<token>
The last path segment is the token. Only an Owner or Sales head sees that button; a Coordinator or Viewer does not, so ask whoever administers the account if it is missing. There is one token per CRM — pressing the button again returns the same URL rather than minting a second one.
There is no separate key or header. Anyone holding that URL can read every lead in the CRM and create new ones. Keep it in a secrets manager, never in front-end code, a public repository, or a shared document.
Rotating it
Rotation is self-service and needs nobody's help, but it is two steps in two places:
- 1Settings → Lead sources. Remove the row named Automation API. The URL stops working immediately. Leads already received are kept.
- 2Settings → Channels. Press Enable automation API again for a fresh URL.
Anything still pushing to the old URL gets 404 until you repoint it, so rotate when you can update the callers, not in the middle of a campaign.
02Fetch leads out
Query parameters
| Parameter | Type | Default | Notes |
|---|---|---|---|
| since | ISO 8601 | none | Returns leads whose updatedAt is at or after this instant. Unparseable values are ignored rather than rejected. |
| status | stage key | none | Exact, case-sensitive match on your own stage keys. |
| limit | 1–200 | 100 | Values above 200 clamp to 200. |
Results come back ordered by updatedAt, newest first. Leads merged into another record as duplicates are never returned.
Request
curl -s "https://brandingpioneers.in/api/webhooks/crm/<token>?since=2026-09-01T00:00:00Z&limit=200"
Response · 200
{
"count": 1,
"leads": [
{
"id": "clx9a1b2c0003qw3r8v6k1n7d",
"name": "Asha Rao",
"phone": "9876543210",
"email": "asha@example.com",
"channel": "GOOGLE_ADS",
"channelRaw": "google_ads_search",
"status": "QUALIFIED",
"value": 85000,
"score": 72,
"city": "Gurgaon",
"service": "Hair transplant",
"notes": "Asked about EMI options.",
"utmSource": "google",
"utmMedium": "cpc",
"utmCampaign": "hair-transplant-ncr",
"assignee": "Priya Sharma",
"source": "Google Ads lead form",
"leadDate": "2026-09-09T06:12:00.000Z",
"createdAt": "2026-09-09T06:12:03.441Z",
"updatedAt": "2026-09-10T11:48:19.207Z"
}
]
}What each field means
| Field | Type | Meaning |
|---|---|---|
| id | string | Stable identifier. Use it as your primary key. |
| name | string | Never null. Unnamed leads read "Automation lead". |
| phone, email | string · null | At least one is normally present. |
| channel | string | Normalised channel, from the fixed set below. |
| channelRaw | string · null | What the upstream system called it, before normalisation. |
| status | string | Current stage key. |
| value | number · null | Deal value in the CRM's currency. |
| score | number · null | Rule-based heat score. Higher is hotter. |
| city, service, notes | string · null | As captured. |
| utmSource, utmMedium, utmCampaign | string · null | Attribution parsed at intake. |
| assignee | string · null | Name of the team member who owns the lead, not an id. |
| source | string · null | Name of the lead source that produced it, not an id. |
| leadDate | ISO 8601 | When the enquiry happened. |
| createdAt | ISO 8601 | When the CRM recorded it. |
| updatedAt | ISO 8601 | Last change of any kind. This is what since filters on. |
03Keeping a copy in sync
The endpoint returns the most recently touched leads, capped at 200, and there is no pagination cursor. Poll on a cadence short enough that fewer than 200 leads change in a window. For a single-location clinic, hourly is generous.
Incremental pull with a watermark
#!/usr/bin/env bash set -euo pipefail TOKEN="<token>" HOST="https://brandingpioneers.in" CURSOR=./crm-cursor SINCE=$(cat "$CURSOR" 2>/dev/null || echo "1970-01-01T00:00:00Z") RESP=$(curl -sS --fail "$HOST/api/webhooks/crm/$TOKEN?since=$SINCE&limit=200") echo "$RESP" | jq -c '.leads[]' > new-leads.ndjson # Advance the watermark to the newest updatedAt we actually saw. NEWEST=$(echo "$RESP" | jq -r '[.leads[].updatedAt] | max // empty') [ -n "$NEWEST" ] && echo "$NEWEST" > "$CURSOR" # A full page means more may have changed than one page can carry. [ "$(echo "$RESP" | jq '.count')" -eq 200 ] && echo "WARN: poll more often" >&2
Two things to build for:
- A lead reappears every time it changes. Stage moves, edits and notes all bump
updatedAt. Upsert onid; do not insert blindly. - Deletions are invisible. A lead merged into a duplicate simply stops being returned. If that matters, reconcile periodically against a full pull.
04Real-time webhooks
Instead of polling, have the CRM POST to you the moment a lead arrives or moves. Set it up under Settings → Channels → Outbound webhook: an HTTPS URL, the events you want, and a signing secret.
| Event | Fires when |
|---|---|
| lead.created | Any new lead, from any source — web form, Google Sheet sync, WhatsApp, a phone call, the Automation API, or typed in by hand. Delivered once per lead. |
| lead.stage_changed | A lead moves stage, singly or in a bulk move. Carries fromStatus and toStatus. |
Delivered payload
POST /your/endpoint
Content-Type: application/json
User-Agent: PioneerOS-Webhook/1.0
X-Pioneer-Signature: 6f1c… (hex HMAC-SHA256 of the raw body)
{
"event": "lead.stage_changed",
"timestamp": "2026-09-10T11:48:19.207Z",
"client": "Nova Skin Clinic",
"lead": {
"id": "clx9a1b2c0003qw3r8v6k1n7d",
"name": "Asha Rao",
"phone": "9876543210",
"email": "asha@example.com",
"channel": "GOOGLE_ADS",
"status": "QUALIFIED",
"value": 85000,
"city": "Gurgaon",
"service": "Hair transplant",
"score": 72,
"source": "Google Ads lead form",
"utmSource": "google",
"utmMedium": "cpc",
"utmCampaign": "hair-transplant-ncr",
"leadDate": "2026-09-09T06:12:00.000Z",
"createdAt": "2026-09-09T06:12:03.441Z"
},
"fromStatus": "NEW",
"toStatus": "QUALIFIED"
}Verify the signature
When a secret is set, every delivery carries X-Pioneer-Signature: the hex-encoded HMAC-SHA256 of the exact raw request body, keyed by your secret. Compute it over the bytes you received, before any JSON parsing or re-serialisation.
Node · Express
import crypto from 'crypto'
app.post(
'/hooks/pioneer',
express.raw({ type: 'application/json' }),
(req, res) => {
const expected = crypto
.createHmac('sha256', process.env.PIONEER_SECRET)
.update(req.body)
.digest('hex')
const got = req.get('X-Pioneer-Signature') || ''
const ok =
got.length === expected.length &&
crypto.timingSafeEqual(
Buffer.from(got),
Buffer.from(expected)
)
if (!ok) return res.sendStatus(401)
const payload = JSON.parse(req.body.toString('utf8'))
res.sendStatus(200) // acknowledge, then work
queue.add(payload)
}
)Python · Flask
import hmac, hashlib
from flask import request, abort
@app.post("/hooks/pioneer")
def pioneer_hook():
expected = hmac.new(
SECRET.encode(),
request.get_data(),
hashlib.sha256,
).hexdigest()
sent = request.headers.get("X-Pioneer-Signature", "")
if not hmac.compare_digest(expected, sent):
abort(401)
payload = request.get_json()
queue.enqueue(handle, payload)
return "", 200Delivery guarantees
- Answer with any 2xx within 10 seconds. A non-2xx status, a timeout or a connection error all count as failure.
- Failures retry five times total, after 1, 5, 15, 60 and 240 minutes. After that the delivery is abandoned.
lead.createdis deduplicated per lead, so a retry never creates a second one.lead.stage_changedcan legitimately repeat for the same lead.- Do your work asynchronously. Acknowledge first, process after — a slow handler becomes a failed delivery.
05Push leads in
Same URL, different verb. Use it when another system owns the enquiry first — a landing page, an IVR, a hospital system, a Make scenario. At least one of name, phone or email must be present; everything else is optional.
| Field | Accepted aliases | Notes |
|---|---|---|
| name | full_name, fullName, lead_name, customer_name | Defaults to "Automation lead". |
| phone | mobile, phone_number, phoneNumber, whatsapp, contact | |
| email_address, emailAddress | Lower-cased. | |
| city | location | |
| service | treatment, interest, product | |
| value | amount, budget, deal_value | Non-numeric characters are stripped, so "₹85,000" becomes 85000. |
| channel | source, lead_source, utm_source | Normalised. Defaults to AUTOMATION, which lands as OTHER. |
| notes | message, comments, description | |
| external_id | externalId, id, lead_id | Your own identifier. Makes retries idempotent. |
| lead_date | date, created_at, timestamp | Any parseable date. Defaults to now. |
Each value is trimmed and capped at 500 characters. Unrecognised fields are not dropped — the whole payload is kept on the lead's raw record.
Request
curl -X POST "https://brandingpioneers.in/api/webhooks/crm/<token>" \
-H 'Content-Type: application/json' \
-d '{
"external_id": "hms-88213",
"name": "Asha Rao",
"phone": "9876543210",
"email": "asha@example.com",
"service": "Hair transplant",
"source": "google_ads",
"value": "85,000",
"city": "Gurgaon",
"message": "Asked about EMI options."
}'| Status | Body | Meaning |
|---|---|---|
| 201 | {"ok":true,"leadId":"clx…","deduped":false} | A new lead was created. |
| 200 | {"ok":true,"leadId":"clx…","deduped":true} | Matched an existing lead; nothing new was created. |
| 400 | {"error":"…"} | No JSON body, or none of name / phone / email. |
Deduplication
- With
external_id: it maps one-to-one to a lead for the lifetime of the source. Retry the same payload as often as you like — you get the sameleadIdback and nothing is duplicated. - Without one: the same phone or email seen within the last 30 days attaches a timeline note to the existing lead instead of creating a new one.
Send an external_id whenever your system has a stable identifier. It is the difference between a safe retry and a duplicate enquiry on someone's desk.
An accepted lead runs the full pipeline, exactly like one from a web form: automation sequences enrol, round-robin assignment picks an owner, the arrival alert goes out over WhatsApp and email, the AI qualifier reads it, the score is computed, and any lead.created webhook fires.
So test data messages a live team. Mark test leads clearly and tell the coordinators before you start.
06Google Ads offline conversions
A token-guarded CSV of won leads that carry a gclid, formatted for Google Ads Tools → Conversions → Uploads → Schedule. Point the schedule at the URL and Google pulls it on its own, so bidding optimises toward leads that actually converted rather than raw form fills.
We generate this URL for you. It returns text/csv, covers a 90-day conversion window, caps at 5,000 rows, and stamps times in IST. The conversion action name in the Google Ads account must match the one configured on the CRM, or Google accepts the file and silently attributes nothing.
07Reference
Stages
status is a stage key, and stage keys are configurable per CRM under Settings → Pipeline. The default set:
A CRM that renamed or added stages has its own keys. Read them off your board rather than assuming, and match exactly — the filter is case-sensitive and does no normalisation.
Channels
Inbound channel values are pattern-matched into one of:
Anything unrecognised becomes OTHER, with your original string preserved in channelRaw. So "fb lead form" arrives as META_ADS, "adwords" as GOOGLE_ADS, and "IVR" as DIRECT_CALL.
Rate limits
Counted per token per calling IP address, on a rolling hour.
| Endpoint | Limit |
|---|---|
| GET · fetch leads | 240 requests / hour |
| POST · push leads | 120 requests / hour |
Over the limit returns 429 with {"error":"Rate limit exceeded"}. Back off and retry — nothing is queued on your behalf.
Errors
| Status | Body | Cause |
|---|---|---|
| 400 | A JSON body is required | Body missing or not valid JSON. |
| 400 | At least one of name / phone / email is required | Empty contact. |
| 404 | Invalid webhook token | Token malformed, unknown, or the source was deactivated. |
| 429 | Rate limit exceeded | See above. |
| 500 | Could not create lead | Server-side failure. Safe to retry, especially with an external_id. |
A 404 on a token that worked yesterday means the source was switched off. That is an account question, not a code one.
Leads only. Contacts and patient lists, call logs and recordings, WhatsApp and social conversations, appointments and bookings, tasks, testimonials, custom field values, tags and AI qualification notes are all reachable in the portal and its CSV exports, but not through this API. Ask us if you need one of them wired up.