Skip to main content
← Back to docs

Docs · REST API

REST API reference.

Push leads from your website, sync sales from your e-commerce stack, receive payment events from Stripe and Razorpay. Stable, versioned, JSON.

Base URL

Two valid base URLs, with one important difference:

  • https://app.praxcrm.com/api/leads/intake — recommended. This proxy host captures the visitor's real IP from your request's x-forwarded-for header (set by Vercel) and forwards to Convex with that IP injected. Use this for any integration where the call is made by your server (Next.js route, Express endpoint, Lambda, etc.) on behalf of a browser visitor — otherwise the visitor's IP is lost and every lead gets geocoded to your hosting region.
  • https://<your-deployment>.convex.site/api/v1/leads/intake — direct backend. Use this only when the call originates directly from the visitor's browser (CORS-friendly), or when you manage IP capture yourself and pass ipAddress in the request body.

Find your deployment URL under Admin → Settings → API Keys.

Authentication

Every request authenticates with a workspace API key. Generate one at Admin → Settings → API Keys → New key. Pick a descriptive name ("Website intake", "Marketing automation") and optionally a default source tag that gets stamped onto every record the key creates.

Authorization: Bearer wsk_<your-key>
Content-Type: application/json

Keys start with wsk_. They're stored as SHA-256 hashes at rest — even our DB doesn't have the plaintext. Lose a key, revoke it; we can't recover it.

Rate limits

The public endpoints are rate-limited per key and per IP to protect both you and us:

  • Lead intake: 60 requests / minute / key
  • Lead intake: 600 requests / hour / IP (across all keys)
  • Burst tolerance: 10 in any 1-second window

On rate-limit hit, the endpoint returns 429 Too Many Requests with a Retry-After header in seconds. Back off; don't retry tightly.

POST /api/v1/leads/intake

Push a lead into the workspace. CORS-permissive — POST directly from a browser without a same-origin shim.

Request

POST /api/v1/leads/intake
Authorization: Bearer wsk_<your-key>
Content-Type: application/json

{
  "name":            "Jane Doe",          // required-ish; we use email if missing
  "email":           "jane@acme.com",     // recommended, used for dedupe
  "phone":           "+1-555-0100",
  "alternatePhone":  "+1-555-0101",
  "company":         "Acme Co",
  "source":          "Website",           // free-form; defaults to the key's tag
  "sourcePage":      "/pricing",
  "notes":           "Asked about Growth plan",
  "leadDate":        "05/10/2026",        // MM/DD/YYYY
  // UTM / paid-ads enrichment (all optional)
  "utmSource":       "google",
  "utmMedium":       "cpc",
  "utmCampaign":     "spring-2026",
  "gclid":           "...",
  "fbclid":          "...",
  "msclkid":         "...",
  // Vehicle context (autoparts industry)
  "vehicleYear":     "2018",
  "vehicleMake":     "Honda",
  "vehicleModel":    "Civic",
  "vin":             "1HGCM82633A004352",
  "partType":        "Engine",
  // Submission metadata (we'll fill these in if you don't)
  "ipAddress":       "203.0.113.10",
  "referrer":        "https://yourwebsite.com/contact",
  "landingPage":     "https://yourwebsite.com/services"
}

Response — success

200 OK
{
  "ok":         true,
  "leadId":    "j97abc...",
  "customerId": "CUST-00000123",
  "source":    "Website"
}

Response — errors

  • 401 — missing, malformed, or revoked key
  • 400 — invalid JSON body or wrong content-type
  • 429 — rate limit; honour Retry-After
  • 503 — workspace suspended; contact us

Behaviour notes

  • Idempotency by email — re-posting the same email within 60 seconds returns the existing leadId rather than duplicating.
  • Source tag auto-fill — if you tagged the API key with "Web", every lead inherits source=Web unless your payload sets it.
  • IP enrichment — if you don't supply ipAddress, we pick up the X-Forwarded-For from the request edge. When the request comes through https://app.praxcrm.com/api/leads/intake (proxy), the visitor's real IP is captured automatically. When you call the direct Convex URL from your own server, we'll see your server's IP unless you pass ipAddress yourself.
  • Datacenter detection — every lead is tagged with ipAddressOrg (network owner) and ipIsDatacenter (boolean). If your captured IP belongs to a hosting provider (Vercel, AWS, GCP, Cloudflare, etc.) the geo fields are still written but the lead row shows a "⚠ Datacenter IP" warning so you can debug your integration.

Incoming webhooks (Stripe & Razorpay)

Payment events from Stripe and Razorpay land at:

POST /api/webhooks/stripe
POST /api/webhooks/razorpay

Both endpoints verify the provider's signature against the secret you set in Convex env vars (STRIPE_WEBHOOK_SECRET, RAZORPAY_WEBHOOK_SECRET) before doing anything.

  • Stripe — verifies the Stripe-Signature header (HMAC-SHA256 over <unix>.<body>) with a 5-minute replay-protection window.
  • Razorpay — verifies the X-Razorpay-Signature (HMAC-SHA256 hex of the raw body), constant-time compared.

Without the secret env var set, the endpoint returns 503 Webhook not configured — we never run with signature verification disabled.

Outgoing webhooks (HMAC-signed)

On Growth and Enterprise plans, configure outgoing webhooks under Admin → Settings → Webhooks. We POST a JSON envelope with an HMAC signature in the request headers:

POST <your endpoint>
Content-Type: application/json
X-Praxcrm-Signature: t=<unix>,v1=<hmac-sha256>
X-Praxcrm-Event: lead.created
X-Praxcrm-Delivery: <uuid>

{
  "event": "lead.created",
  "createdAt": 1746883200000,
  "data": { "leadId": "...", ... }
}

Verify the signature exactly like Stripe's:

const expected = hmacSha256Hex(`${ts}.${body}`, secret);
if (!constantTimeEqual(expected, sigV1)) throw new Error("Bad signature");
if (Math.abs(now - ts) > 300) throw new Error("Stale event");

We retry failed deliveries with exponential backoff up to 24 hours, then give up and surface the failure on the webhook log page. Idempotency-key on every POST: the X-Praxcrm-Delivery UUID is unique per attempt of the same event.

Error envelope

Every error response shares the same shape:

{
  "ok": false,
  "error": "Unknown or revoked key"
}

We never echo the request body in errors and never include internal stack traces. If you see a vague error and need help debugging, include the response timestamp when you email support@praxcrm.com — we can correlate it to our logs without you sending us your payload.

Versioning

Endpoints under /api/v1/... are stable for the v1 lifetime. We may add fields to responses; we won't remove or rename them. Breaking changes ship under /api/v2/... with at least 6 months of overlap.

Quick example: HTML form on your website

<form id="lead-form">
  <input name="name"    placeholder="Name"    required />
  <input name="email"   placeholder="Email"   type="email" required />
  <input name="phone"   placeholder="Phone" />
  <button type="submit">Get a quote</button>
</form>

<script>
document.getElementById("lead-form").addEventListener("submit", async (e) => {
  e.preventDefault();
  const fd = new FormData(e.target);
  const res = await fetch("https://<your>.convex.site/api/v1/leads/intake", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": "Bearer wsk_PUBLIC_KEY_HERE"
    },
    body: JSON.stringify(Object.fromEntries(fd))
  });
  const json = await res.json();
  if (json.ok) location.href = "/thank-you";
  else alert("Couldn't send: " + json.error);
});
</script>

Use a key dedicated to website intake (so you can rotate it without taking down other integrations) and serve over HTTPS. Even though the key is in the page source, it can only create leads — it can't read or modify other data. Treat it like a publishable key.

Need an endpoint we don't have?

Email support@praxcrm.com — most v1 endpoints shipped because a customer asked.