Back to app

Webhooks & API

Connect PropX to Make, n8n, Zapier, Postman or your own systems. Receive leads into your CRM and send lead data out from workflows — securely, and isolated per organisation.

Built for multiple organisations. Each API key and endpoint belongs to exactly one organisation. PropX always identifies the organisation from your key — never from the data you send — so leads can never cross between organisations.

1. Receiving leads

Send leads to PropX from any tool that can make an HTTP request.

Fast track with Make

Download a ready-made Make scenario, pre-wired to your ingest URL and field mapping. In Make, go to Create a new scenario → ⋯ → Import Blueprint, upload the file, then paste your API key into the Authorization header (Bearer pxk_live_…). The key is never stored in the file.

1

Create an API key

Go to Settings → API & Webhooks → Inbound keys and click New key. Give it a name, choose how duplicates are handled, and fill in the field mapping (step 3). The full key — pxk_live_… — is shown once, so copy it somewhere safe before closing the dialog.
2

Send a POST request

Use this endpoint and pass your key as a Bearer token. The key tells PropX which organisation the lead belongs to.
Endpoint
POST https://www.propx.cloud/api/v1/ingest/leads
Authorization: Bearer pxk_live_xxxxxxxx
Content-Type: application/json
Idempotency-Key: <optional, unique per lead>
3

Map your fields

Your automation can use any field names. In the key’s mapping editor you tell PropX which of your fields maps to which lead field — the source is the JSON key you send, the target is the lead field it fills. Dot paths like contact.Name are allowed. Only full_name and phone are required.
Lead fieldRequiredAccepted values
full_nameRequiredAny text
phoneRequired7–20 chars: digits, +, spaces, - ( ). A bare 10-digit number is stored as +91…
emailOptionalname@example.com
alternate_phoneOptionalStored as given
whatsapp_numberOptionalStored as given
lead_sourceOptionalmanual · website · meta_ads · google_ads · google_sheets · whatsapp · csv_import · webhook · referral · other. Defaults to webhook
interest_typeOptionalready_to_move · under_construction · investment
unit_preferenceOptionalplot · villa · apartment · commercial · other
payment_methodOptionalcash · loan
timeline_to_purchaseOptionalimmediate · 1_3_months · 3_6_months · 6_plus_months
budget_min / budget_maxOptionalNumbers
size_min_sqft / size_max_sqftOptionalNumbers
preferred_locationOptionalAny text
custom_fields.<key>OptionalAnything else you want to store
messageSpecialNot a lead column — fires Inbound Message / Keyword workflows

Values for the fixed-choice fields are matched case-insensitively, so Cash and cash both work.

Anything you don’t map is ignored. The mapping is the whole contract — a key with no rows accepts your request and then fails validation, because nothing reached full_name or phone. If you just want to test quickly, map full_name → full_name, phone → phone and email → email; the examples below then work as-is.

4

Choose duplicate handling

Per key you can pick Update existing — matching on phone or email, which keeps your CRM clean — or Always create new. Updating preserves the original lead source.

Safe retries. Send an Idempotency-Key header (any unique value per lead). If your tool retries after a timeout, PropX returns the original result instead of creating a duplicate. A retry of a failed request is processed fresh, and a retry while the original is still running gets a 409 — so a failure never blocks the key.

Responses

StatusMeaning
201Lead created
200Lead updated, or an idempotent replay of an earlier result
400Body was not a JSON object
401Missing or invalid API key
409Same Idempotency-Key is still in flight — retry after Retry-After seconds
413Payload larger than 1 MB
422Validation failed — details lists every problem
429Rate limited — carries Retry-After and RateLimit-* headers
500Something broke on our side; the attempt is logged as failed and is safe to retry
201 / 200
{ "ok": true, "action": "created", "lead_id": "…" }
200 — idempotent replay
{
  "ok": true,
  "idempotent_replay": true,
  "lead_id": "…",
  "action": "created"
}
422
{
  "ok": false,
  "error": "Validation failed",
  "details": ["Missing full_name", "Invalid phone number: \"12\""]
}
401 / 429
{ "ok": false, "error": "Invalid API key" }

Rate limits

Two limits apply, both over a rolling 60-second window. Hitting either returns 429 with Retry-After, RateLimit-Limit and RateLimit-Reset headers.

ScopeLimit
Per source IP120 requests / minute
Per API key300 requests / minute

Connect your tool

Zapier

Action “Webhooks by Zapier → POST”. URL = the endpoint. Add header Authorization: Bearer <key>.

Make

Module “HTTP → Make a request”, method POST, add the Authorization header.

n8n

“HTTP Request” node, POST, with a Header Auth credential holding the bearer key.

Map a field to the special message target to also trigger Inbound Message / Keyword workflows — handy for web chat, SMS or Facebook Lead Ads messages.

2. Testing with curl / Postman

Every command below is copy-paste runnable. Postman, Insomnia and Bruno all import raw curl directly — in Postman it’s Import → Raw text — so these double as a ready-made request collection.

Set this up first

bash
# Paste once per terminal session.
# Your PropX URL — swap for http://localhost:3000 to test against a local dev server.
export BASE_URL="https://www.propx.cloud"
# Settings → API & Webhooks → Inbound keys (shown once, at creation).
export API_KEY="pxk_live_xxxxxxxx"

Use the www host exactly as shown. propx.cloud without it answers a 308 redirect, and most HTTP clients — curl included — don’t follow redirects unless you tell them to, so a request to the bare domain looks like a failure rather than a lead.

The smallest request that works

Assumes the key maps full_name and phone to themselves.

curl — expect 201
curl -i -X POST "$BASE_URL/api/v1/ingest/leads" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "full_name": "Asha Rao",
    "phone": "+919876543210"
  }'

Your own field names

The same lead, sent the way a typical form posts it. This one needs mapping rows contact.Name → full_name, Mobile → phone and Email → email.

curl — nested source paths
curl -i -X POST "$BASE_URL/api/v1/ingest/leads" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contact": { "Name": "Asha Rao" },
    "Mobile": "9876543210",
    "Email": "asha@example.com"
  }'

Every field at once

Useful for checking your mapping covers everything. Map each key to the matching lead field, the extras to custom_fields.<key>, and enquiry → message to also fire keyword workflows.

curl — full payload
curl -i -X POST "$BASE_URL/api/v1/ingest/leads" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "full_name": "Asha Rao",
    "phone": "+919876543210",
    "email": "asha@example.com",
    "alternate_phone": "+919812345678",
    "whatsapp_number": "+919876543210",
    "lead_source": "website",
    "interest_type": "ready_to_move",
    "unit_preference": "villa",
    "payment_method": "loan",
    "timeline_to_purchase": "1_3_months",
    "budget_min": 4500000,
    "budget_max": 6500000,
    "size_min_sqft": 1200,
    "size_max_sqft": 1800,
    "preferred_location": "Whitefield, Bengaluru",
    "utm_source": "google",
    "campaign": "spring-launch",
    "enquiry": "Please send the brochure and price list"
  }'

Prove retries are safe

Run this twice. The first call creates the lead (201); the second returns the same lead_id with idempotent_replay: true (200) instead of creating a duplicate.

curl — run me twice
curl -i -X POST "$BASE_URL/api/v1/ingest/leads" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: demo-lead-0001" \
  -d '{
    "full_name": "Retry Test",
    "phone": "+919000000001"
  }'

Reproduce each error

One command per failure mode, so you can confirm your error handling before going live.

401 — bad key
curl -i -X POST "$BASE_URL/api/v1/ingest/leads" \
  -H "Authorization: Bearer pxk_live_not_a_real_key" \
  -H "Content-Type: application/json" \
  -d '{"full_name":"Asha Rao","phone":"+919876543210"}'
400 — body is not a JSON object
curl -i -X POST "$BASE_URL/api/v1/ingest/leads" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '[{"full_name":"Asha Rao"}]'
422 — validation failed (no phone; unknown payment_method)
curl -i -X POST "$BASE_URL/api/v1/ingest/leads" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"full_name":"Asha Rao","payment_method":"crypto"}'
429 — trip the rate limit (150 rapid calls)
for i in $(seq 1 150); do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST "$BASE_URL/api/v1/ingest/leads" \
    -H "Authorization: Bearer $API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"full_name":"Load Test","phone":"+919000000002"}'
done | sort | uniq -c

The rate-limit loop really does ingest leads — every call before the 429 is a live request. Run it against a test organisation, or on a key set to “Update existing (match phone)” so the repeats land on one lead you can delete afterwards.

Every one of these attempts — successes and failures alike — shows up under Settings → API & Webhooks → Activity with the payload you sent, which is usually the fastest way to see what your mapping actually received.

3. Sending data out

Push lead data to another system from inside a workflow.

1

Register an endpoint

Go to Settings → API & Webhooks → Outbound and click New endpoint. Enter an https URL (e.g. a Make/n8n/Zapier catch hook) and, if the destination needs its own auth, add custom headers. PropX generates a signing secretwhsec_…, shown once — which you use to verify requests.
2

Add a “Send to Webhook” node

Open a workflow, drag in Send to Webhook, pick your endpoint, and connect the success / failure outputs to branch on the result. Send the default lead envelope, or a custom JSON body using {{lead.field}} placeholders. You can also set your own event type in place of lead.workflow.

Default payload

POST body
{
  "id": "evt_<workflow_run_id>_<node_id>",
  "type": "lead.workflow",
  "created": "2026-06-17T10:00:00.000Z",
  "org_id": "…",
  "data": { "lead": { /* the full lead */ } }
}

Headers we send

HeaderValue
Content-Typeapplication/json
User-AgentPropX-Webhooks/1.0
X-Propx-Signaturet=<unix>,v1=<hmac-sha256-hex> — one v1 normally, two during a rotation
X-Propx-TimestampThe same <unix> value, for convenience
Your own headersAnything set on the endpoint (e.g. a destination API key) is sent first

Your custom headers are applied first, so they can never overwrite the signature or content type.

Delivery & retries

Any 2xx counts as delivered. Anything else is retried up to 3 attempts, waiting 5 seconds then 10 seconds between them; each attempt times out after 10 seconds and is logged separately under Activity. After the third failure the node takes its failure branch, so the workflow can react rather than stall.

Secure by default. Only public https URLs are allowed — localhost and private/internal addresses are blocked, on the original URL and on every redirect.

Verify the signature

Recompute the HMAC on your side using the endpoint’s signing secret over `${t}.${rawBody}` — sign the raw request body, before any JSON parsing.

During a secret rotation the header carries two signatures — t=…,v1=<new>,v1=<old>. Your receiver must accept the request if any v1 matches, so a single verifier keeps working while you migrate to the new secret (24-hour grace window). The snippet below handles this.

Node.js
import crypto from "crypto";

function verify(secret, rawBody, header, toleranceSec = 300) {
  // Parse "t=...,v1=...[,v1=...]" — collect EVERY v1 (rotation sends two).
  let t = NaN;
  const v1s = [];
  for (const seg of header.split(",")) {
    const i = seg.indexOf("=");
    if (i === -1) continue;
    const k = seg.slice(0, i).trim();
    const v = seg.slice(i + 1).trim();
    if (k === "t") t = Number(v);
    else if (k === "v1" && v) v1s.push(v);
  }
  if (!Number.isFinite(t) || v1s.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // replay guard

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  const b = Buffer.from(expected);
  // Accept if ANY signature matches; length-check guards timingSafeEqual.
  return v1s.some((v1) => {
    const a = Buffer.from(v1);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
}

Test your receiver before wiring a workflow

This sends your own endpoint a correctly signed request, identical in shape to a real delivery — so you can confirm your verifier accepts it without touching a workflow. Change one character of BODY after signing and it should reject.

bash — sign and POST
export TARGET_URL="https://your-receiver.example.com/hooks/propx"
export SIGNING_SECRET="whsec_xxxxxxxx"   # shown once when the endpoint was created

BODY='{"id":"evt_test_1","type":"lead.workflow","created":"2026-07-31T10:00:00.000Z","org_id":"test-org","data":{"lead":{"full_name":"Asha Rao","phone":"+919876543210"}}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SIGNING_SECRET" -r | cut -d' ' -f1)

curl -i -X POST "$TARGET_URL" \
  -H "Content-Type: application/json" \
  -H "User-Agent: PropX-Webhooks/1.0" \
  -H "X-Propx-Timestamp: $T" \
  -H "X-Propx-Signature: t=$T,v1=$SIG" \
  -d "$BODY"

Or skip the shell entirely. Settings → API & Webhooks → Outbound has Send test event on every endpoint, which posts a signed sample through the real delivery path and records the result in Activity.

4. Activity & replay

Settings → API & Webhooks → Activity shows recent inbound events and outbound deliveries with their status, the exact payload, and the response we got back. If something failed — or you just want to re-run it — use Replay on either side. Replaying an inbound event re-runs it through the key’s current mapping, which makes it the quickest way to check a mapping fix against real data.

5. Tips & FAQ

My request returns 422 “Missing full_name” but I sent a name.

The key’s mapping decides what is read. If your JSON says Name but no mapping row has Name as its source, the value is ignored. Open the key’s mapping, check the source spelling (it is case-sensitive), and use dot paths for nested values.

Who can manage keys and endpoints?

Only organisation owners and admins. Managers who build workflows can select existing endpoints but never see their secrets.

I lost my API key / signing secret.

For security, secrets are shown only once. For an inbound key, create a new one and revoke the old. For an outbound endpoint, use Rotate secret — the new secret is shown once and the old one keeps working for 24 hours.

Can I change a key's field mapping later?

Yes — use the pencil icon on the key in Settings → API & Webhooks → Inbound keys to edit its name, mapping and duplicate handling. The key itself never changes, so your automation keeps working.

How do I rotate an outbound signing secret without downtime?

Use Rotate secret on the endpoint. The old secret keeps signing alongside the new one for a 24-hour grace window, so every payload carries both signatures (v1=<new>,v1=<old>). Update your receiver to the new secret any time inside that window — as long as it accepts a request when any v1 matches (see the verifier above), no events are dropped.

A lead came in twice.

Set duplicate handling to “Update existing (match phone)” on the key, and send an Idempotency-Key so retries don’t create copies.

My outbound endpoint was rejected.

URLs must be https and publicly reachable. Localhost and private/internal addresses are blocked to protect your data — which also means a local tunnel (ngrok, Cloudflare Tunnel) is the way to test a receiver running on your machine.