BR

Branding Pioneers

Pioneer OS

Integration guide

CRM API

Move leads between your CRM and anything else — a warehouse, a hospital system, a spreadsheet, Make, Zapier, Pabbly Connect, or your own code. JSON in, JSON out, server to server. Your token is scoped to one CRM and can never reach another's leads.

Paste your token and every example below rewrites itself. Nothing you type is stored or sent anywhere — it lives in this tab until you close it.

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

  1. 1Open the CRM and go to Settings → Channels.
  2. 2Find the card headed Automation API (Pabbly / Make / Zapier).
  3. 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.

The token is the credential

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:

  1. 1Settings → Lead sources. Remove the row named Automation API. The URL stops working immediately. Leads already received are kept.
  2. 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

GEThttps://brandingpioneers.in/api/webhooks/crm/<token>

Query parameters

ParameterTypeDefaultNotes
sinceISO 8601noneReturns leads whose updatedAt is at or after this instant. Unparseable values are ignored rather than rejected.
statusstage keynoneExact, case-sensitive match on your own stage keys.
limit1–200100Values 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

FieldTypeMeaning
idstringStable identifier. Use it as your primary key.
namestringNever null. Unnamed leads read "Automation lead".
phone, emailstring · nullAt least one is normally present.
channelstringNormalised channel, from the fixed set below.
channelRawstring · nullWhat the upstream system called it, before normalisation.
statusstringCurrent stage key.
valuenumber · nullDeal value in the CRM's currency.
scorenumber · nullRule-based heat score. Higher is hotter.
city, service, notesstring · nullAs captured.
utmSource, utmMedium, utmCampaignstring · nullAttribution parsed at intake.
assigneestring · nullName of the team member who owns the lead, not an id.
sourcestring · nullName of the lead source that produced it, not an id.
leadDateISO 8601When the enquiry happened.
createdAtISO 8601When the CRM recorded it.
updatedAtISO 8601Last 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 on id; 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.

EventFires when
lead.createdAny 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_changedA 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 "", 200

Delivery 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.created is deduplicated per lead, so a retry never creates a second one. lead.stage_changed can legitimately repeat for the same lead.
  • Do your work asynchronously. Acknowledge first, process after — a slow handler becomes a failed delivery.

05Push leads in

POSThttps://brandingpioneers.in/api/webhooks/crm/<token>

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.

FieldAccepted aliasesNotes
namefull_name, fullName, lead_name, customer_nameDefaults to "Automation lead".
phonemobile, phone_number, phoneNumber, whatsapp, contact
emailemail_address, emailAddressLower-cased.
citylocation
servicetreatment, interest, product
valueamount, budget, deal_valueNon-numeric characters are stripped, so "₹85,000" becomes 85000.
channelsource, lead_source, utm_sourceNormalised. Defaults to AUTOMATION, which lands as OTHER.
notesmessage, comments, description
external_idexternalId, id, lead_idYour own identifier. Makes retries idempotent.
lead_datedate, created_at, timestampAny 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."
  }'
StatusBodyMeaning
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 same leadId back 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.

A push reaches real people

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.

GEThttps://brandingpioneers.in/api/google-ads/offline-conversions/{clientId}?token={feedToken}

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:

NEWCONTACTEDQUALIFIEDCONVERTEDLOST

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:

GOOGLE_ADSMETA_ADSORGANIC_SEARCHGBPWEBSITEWHATSAPPINSTAGRAMFACEBOOKLINKEDINREFERRALWALK_INDIRECT_CALLOTHER

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.

EndpointLimit
GET · fetch leads240 requests / hour
POST · push leads120 requests / hour

Over the limit returns 429 with {"error":"Rate limit exceeded"}. Back off and retry — nothing is queued on your behalf.

Errors

StatusBodyCause
400A JSON body is requiredBody missing or not valid JSON.
400At least one of name / phone / email is requiredEmpty contact.
404Invalid webhook tokenToken malformed, unknown, or the source was deactivated.
429Rate limit exceededSee above.
500Could not create leadServer-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.

What this API does not expose

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.