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.
Receive leads (Inbound)
Create an API key and POST leads from any automation. New leads can auto-start workflows.
Send data out (Outbound)
Register a destination URL and add a “Send to Webhook” node to any workflow. Payloads are signed.
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.
Create an API key
pxk_live_… — is shown once, so copy it somewhere safe before closing the dialog.Send a POST request
Bearer token. The key tells PropX which organisation the lead belongs to.POST https://www.propx.cloud/api/v1/ingest/leads
Authorization: Bearer pxk_live_xxxxxxxx
Content-Type: application/json
Idempotency-Key: <optional, unique per lead>Map your fields
contact.Name are allowed. Only full_name and phone are required.| Lead field | Required | Accepted values |
|---|---|---|
| full_name | Required | Any text |
| phone | Required | 7–20 chars: digits, +, spaces, - ( ). A bare 10-digit number is stored as +91… |
| Optional | name@example.com | |
| alternate_phone | Optional | Stored as given |
| whatsapp_number | Optional | Stored as given |
| lead_source | Optional | manual · website · meta_ads · google_ads · google_sheets · whatsapp · csv_import · webhook · referral · other. Defaults to webhook |
| interest_type | Optional | ready_to_move · under_construction · investment |
| unit_preference | Optional | plot · villa · apartment · commercial · other |
| payment_method | Optional | cash · loan |
| timeline_to_purchase | Optional | immediate · 1_3_months · 3_6_months · 6_plus_months |
| budget_min / budget_max | Optional | Numbers |
| size_min_sqft / size_max_sqft | Optional | Numbers |
| preferred_location | Optional | Any text |
| custom_fields.<key> | Optional | Anything else you want to store |
| message | Special | Not 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.
Choose duplicate handling
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
| Status | Meaning |
|---|---|
| 201 | Lead created |
| 200 | Lead updated, or an idempotent replay of an earlier result |
| 400 | Body was not a JSON object |
| 401 | Missing or invalid API key |
| 409 | Same Idempotency-Key is still in flight — retry after Retry-After seconds |
| 413 | Payload larger than 1 MB |
| 422 | Validation failed — details lists every problem |
| 429 | Rate limited — carries Retry-After and RateLimit-* headers |
| 500 | Something broke on our side; the attempt is logged as failed and is safe to retry |
{ "ok": true, "action": "created", "lead_id": "…" }{
"ok": true,
"idempotent_replay": true,
"lead_id": "…",
"action": "created"
}{
"ok": false,
"error": "Validation failed",
"details": ["Missing full_name", "Invalid phone number: \"12\""]
}{ "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.
| Scope | Limit |
|---|---|
| Per source IP | 120 requests / minute |
| Per API key | 300 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
# 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 -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 -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 -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 -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.
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"}'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"}]'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"}'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 -cThe 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.
Register an endpoint
whsec_…, shown once — which you use to verify requests.Add a “Send to Webhook” node
{{lead.field}} placeholders. You can also set your own event type in place of lead.workflow.Default payload
{
"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
| Header | Value |
|---|---|
| Content-Type | application/json |
| User-Agent | PropX-Webhooks/1.0 |
| X-Propx-Signature | t=<unix>,v1=<hmac-sha256-hex> — one v1 normally, two during a rotation |
| X-Propx-Timestamp | The same <unix> value, for convenience |
| Your own headers | Anything 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.
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.
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.