JPFLD API
Read and create leads, clients and jobs, read quotes and invoices, and get signed webhooks when things happen. For Zapier, your website, or your own software. Included with paid plans; keys are made in Settings, Developer & API.
Base URL and auth
Base URL
https://jpfld.com/api/v1Send your key in the Authorization: Bearer jpfld_live_… header on every request, from your server only. Browser (CORS) calls are blocked so the key never sits in a web page. Keys belong to one business, carry read and/or write, and stop working the moment they are revoked. Create keys in Settings, Developer & API. The API is included with paid plans.
Machine-readable spec (OpenAPI 3.1, includes the webhook payloads): https://jpfld.com/api/v1/openapi.json
Endpoints
| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | /me | any key | The business and scopes this key belongs to. Use it to test a connection. |
| GET | /leads | read | List leads. |
| POST | /leads | write | Create a lead. Accepts Idempotency-Key. |
| GET | /leads/{id} | read | Get one lead. |
| GET | /clients | read | List clients. |
| POST | /clients | write | Create a client. Accepts Idempotency-Key. |
| GET | /clients/{id} | read | Get one client. |
| PATCH | /clients/{id} | write | Update a client's name, email, phone, address, notes or status. |
| GET | /jobs | read | List scheduled jobs. |
| POST | /jobs | write | Schedule a job. Accepts Idempotency-Key. |
| GET | /jobs/{id} | read | Get one job. |
| GET | /schedule | read | Same as GET /jobs. |
| POST | /schedule | write | Same as POST /jobs. Accepts Idempotency-Key. |
| GET | /schedule/{id} | read | Same as GET /jobs/{id}. |
| GET | /proposals | read | List quotes and proposals. |
| GET | /proposals/{id} | read | Get one quote or proposal. |
| GET | /quotes | read | Same as GET /proposals. |
| GET | /quotes/{id} | read | Same as GET /proposals/{id}. |
| GET | /invoices | read | List invoices. |
| GET | /invoices/{id} | read | Get one invoice. |
Lists, paging and syncing
Every list takes limit (1-100, default 25), offset (up to 10,000), status, and updated_since (ISO 8601). Keep requesting with offset = pagination.next_offset until it is null. With updated_since results come oldest change first, so you can save the last updated_at you saw and poll from there. Deleted records (Recently Deleted) are never returned.
curl
curl "https://jpfld.com/api/v1/leads?limit=50&updated_since=2026-10-01T00:00:00Z" \
-H "Authorization: Bearer $JPFLD_API_KEY"JavaScript
// Node 18+ / Deno / Bun: page through every client.
const key = process.env.JPFLD_API_KEY;
let offset = 0;
for (;;) {
const res = await fetch(`https://jpfld.com/api/v1/clients?limit=100&offset=${offset}`, {
headers: { Authorization: `Bearer ${key}` },
});
if (res.status === 429) {
await new Promise((r) => setTimeout(r, Number(res.headers.get("Retry-After") ?? 1) * 1000));
continue;
}
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
for (const client of body.data) console.log(client.id, client.name);
if (body.pagination.next_offset === null) break;
offset = body.pagination.next_offset;
}Response
{
"data": [ { "id": "…", "name": "Jane Doe", "status": "new", "created_at": "…", "updated_at": "…" } ],
"pagination": { "limit": 50, "offset": 0, "has_more": true, "next_offset": 50 }
}Creating and updating
A new lead goes through the same intake as your other lead sources: it merges into an existing lead with the same phone or email, gets a reply draft, and any automatic follow-up waits for your approval. The response says whether it was created, merged or a duplicate (same external_id sent twice).
curl: create a lead
curl -X POST "https://jpfld.com/api/v1/leads" \
-H "Authorization: Bearer $JPFLD_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f9d2c1e-3b8a-4d55-9b1e-1c2d3e4f5a6b" \
-d '{
"name": "Jane Doe",
"phone": "+1 832 555 0100",
"email": "jane@example.com",
"service": "Roof repair",
"address": "123 Main St, Houston TX 77002",
"message": "Leak over the garage",
"source": "Website form",
"external_id": "form-48213"
}'JavaScript: schedule a job
import { randomUUID } from "node:crypto";
// One key per record you mean to create - reuse it on every retry of it.
const idempotencyKey = randomUUID();
const res = await fetch("https://jpfld.com/api/v1/jobs", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.JPFLD_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({
title: "Roof inspection",
client_name: "Jane Doe",
scheduled_date: "2026-11-03",
start_time: "08:00",
end_time: "10:00",
address: "123 Main St, Houston TX",
budget: 450,
}),
});
const body = await res.json(); // 201 { data: { id, title, ... } }
console.log(res.headers.get("Idempotent-Replayed") === "true" ? "replayed" : "created", body.data?.id);curl: update a client
curl -X PATCH "https://jpfld.com/api/v1/clients/6f1c0c1e-..." \
-H "Authorization: Bearer $JPFLD_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "phone": "+18325550199", "notes": "Gate code 4411" }'Safe retries with Idempotency-Key
Every POST create (/leads, /clients, /jobs) accepts an Idempotency-Key header: any unique value up to 255 characters, a UUID works. Use one key per record you mean to create and send the same key on every retry of it.
- Same key and same request (path and JSON body) within 24 hours: you get the first response again, with the header
Idempotent-Replayed: true. Nothing is created twice, except in a rare server failure where the response can't be saved at all. - Errors in the 4xx range are replayed too (for example a
402 plan_limitor409 conflict). Use a new key after fixing the request or your plan. - Same key with a different body or path:
422 idempotency_key_reused. - Same key while the first request is still running:
409 idempotency_in_progress. Retry afterRetry-Afterseconds. - If the first request ran but its response couldn't be saved, the key answers
409 idempotency_in_progressuntil it expires, so the create doesn't run twice. Check whether the record exists, then use a new key if it doesn't. - Server errors (5xx) and 429s are not remembered, so retrying them with the same key runs the request again.
- Keys belong to the API key that sent them and expire after 24 hours. Without the header, creates work exactly as before.
If a response carries Idempotency-Status: unavailable, the request ran but the key could not be stored, so a retry is not protected. Leads also dedupe on external_id, which never expires.
Errors
Every error has the same JSON shape: a stable error.code to branch on, a readable error.message, and error.details with one entry per field when fields are wrong.
Error body
{
"error": {
"code": "validation_failed",
"message": "Some fields aren't valid.",
"details": [ { "field": "scheduled_date", "message": "scheduled_date must be YYYY-MM-DD." } ]
}
}| Status | error.code | Meaning |
|---|---|---|
| 400 | invalid_request | A query parameter, the Idempotency-Key header or the JSON body is malformed. |
| 401 | unauthorized | Missing, malformed, unknown or revoked API key. |
| 402 | plan_required | The business isn't on a plan that includes the API. |
| 402 | plan_limit | A plan limit was hit (for example a record limit on your plan). |
| 403 | insufficient_scope | The key doesn't have the read or write scope this call needs. |
| 403 | forbidden | Not allowed. |
| 404 | not_found | No such record in this business (deleted records are hidden), or no such endpoint. |
| 409 | conflict | A matching record already exists. |
| 409 | idempotency_in_progress | A request with the same Idempotency-Key is still running (retry after Retry-After seconds), or it ran but its response couldn't be saved (check the record, then use a new key). |
| 413 | payload_too_large | The body is over 32 KB. |
| 415 | invalid_request | The body isn't sent as application/json. |
| 422 | validation_failed | Some fields aren't valid. error.details lists each field. |
| 422 | idempotency_key_reused | The Idempotency-Key was already used with a different request. |
| 429 | rate_limited | Over the per-minute, write or daily limit for the plan. Wait Retry-After seconds. |
| 500 | internal_error | Something went wrong on our side. Safe to retry; send an Idempotency-Key on creates. |
| 503 | not_configured | The API isn't set up on this server yet. |
Rate limits
| Plan | Requests / min (per key) | Writes / min (per key) | Requests / day (per business) |
|---|---|---|---|
| Starter | 120 | 30 | 10,000 |
| Pro | 240 | 60 | 50,000 |
| Agency | 600 | 150 | 250,000 |
Responses to a valid key carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix seconds) for the per-minute window, plus X-RateLimit-Daily-Limit, X-RateLimit-Daily-Remaining and X-RateLimit-Daily-Reset. The daily window counts all of the business's keys together. A 429 always includes Retry-After in seconds; wait at least that long.
Webhooks
Add an https endpoint in Settings, Developer & API and pick the events. Each event is POSTed as JSON and signed with that endpoint's secret. A 2xx answer within 10 seconds counts as delivered. Events:
lead.created: a new lead comes in.datais the lead as the API returns it.client.created: a client is added.datais the client as the API returns it.job.created: a job is scheduled.datais the job as the API returns it.job.completed: a job is completed.datais the job as the API returns it.proposal.accepted: a quote/proposal is accepted.datais the proposal as the API returns it.invoice.paid: an invoice is paid.datais the invoice as the API returns it.
Example delivery
POST https://your-app.example.com/jpfld-webhook
Content-Type: application/json
X-JPFLD-Webhook-Event: lead.created
X-JPFLD-Webhook-Delivery: 1b9d6bcd-…
X-JPFLD-Webhook-Timestamp: 1793011200
X-JPFLD-Webhook-Signature: v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
"id": "evt_9f2c…",
"type": "lead.created",
"created_at": "2026-10-26T15:20:00.000Z",
"api_version": "v1",
"data": { "id": "…", "name": "Jane Doe", "phone": "+18325550100", "status": "new", … }
}Retries
The first attempt is made right after the event. After a failure, the next try becomes due 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours, 24 hours later (7 tries in all). A due retry is sent at the next of these: another event from your business, any API request with your key (checked at most once a minute), opening Settings, Developer & API, or a once-a-day sweep. So a retry can arrive later than its due time; with no other activity it waits for the daily sweep. An endpoint that fails 15 times in a row is turned off until you turn it back on.
The delivery log in Settings shows the last 50 deliveries with their result, and a Resend button sends any finished delivery again with the same event id. Use the event id to ignore repeats.
Verify the signature
X-JPFLD-Webhook-Signature is v1= followed by the hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with your signing secret. Check it against the exact bytes you received, before parsing.
Node.js
// Node.js / Express - verify before trusting the body.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.JPFLD_WEBHOOK_SECRET; // whsec_...
app.post("/jpfld-webhook", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-JPFLD-Webhook-Timestamp") ?? "";
const signature = req.get("X-JPFLD-Webhook-Signature") ?? "";
const raw = req.body.toString("utf8"); // the exact bytes received
// 1. Reject old or future timestamps (replay protection, 5 minutes).
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(400);
// 2. HMAC-SHA256 of "<timestamp>.<raw body>" with your signing secret.
const expected = "v1=" + crypto.createHmac("sha256", SECRET).update(timestamp + "." + raw).digest("hex");
const ok = signature.split(",").some((s) => {
const a = Buffer.from(s.trim());
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
if (!ok) return res.sendStatus(401);
const event = JSON.parse(raw);
// 3. Deliveries can repeat - skip event.id values you've already handled.
console.log(event.type, event.data.id);
res.sendStatus(200); // any 2xx within 10 seconds counts as delivered
});Python
# Python / Flask
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["JPFLD_WEBHOOK_SECRET"].encode()
@app.post("/jpfld-webhook")
def jpfld_webhook():
ts = request.headers.get("X-JPFLD-Webhook-Timestamp", "")
sig = request.headers.get("X-JPFLD-Webhook-Signature", "")
raw = request.get_data() # raw bytes
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
abort(400)
expected = "v1=" + hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
if not any(hmac.compare_digest(s.strip(), expected) for s in sig.split(",")):
abort(401)
event = request.get_json()
return "", 200Zapier and no-code tools
In Zapier use Webhooks by Zapier: a Catch Hook URL works as a webhook endpoint (triggers), and a Custom Request with your API key in the Authorization header calls the endpoints above (actions). Make, n8n and Pipedream work the same way.