REST API
Read and write contacts, companies, deals and tickets over HTTPS with JSON, receive webhooks when records change, and post your own website's forms straight into a contact list.
Base URL
https://api.clientpad.com/v1Authentication
Send an API key as a bearer token on every request. Create one under Settings → Developer → API keys; the full token is shown once, at creation, and only a hash is stored, so it cannot be recovered later. Revoking a key takes effect immediately.
curl https://api.clientpad.com/v1/contacts \
-H "Authorization: Bearer cp_live_xxxxxxxxxxxxxxxxxxxx"A missing, malformed, revoked or unknown key returns 401 with an unauthorized error. Keys are long-lived and carry no scopes: a key can read and write everything listed below for its own organization.
Response envelope
Every response is JSON with the same shape: data on success, error on failure. List endpoints add a meta block.
// 200
{ "data": { "id": "...", "email": "ada@example.com" } }
// 200, list
{ "data": [ ... ], "meta": { "total": 184, "limit": 25, "page": 1, "hasMore": true, "nextCursor": "bzoyNQ" } }
// 4xx / 5xx
{ "error": { "code": "validation_failed", "message": "email: Invalid email" } }Error codes
| Field | Notes |
|---|---|
| unauthorized — 401 | Missing or invalid API key. |
| not_found — 404 | No such record in this organization. |
| invalid_request — 400 | Malformed JSON body, or a query parameter that isn't a valid value. |
| validation_failed — 422 | Well-formed request, unacceptable values. The message names the first offending field. |
| internal_error — 500 | Something failed server-side. Never contains a stack trace or SQL. |
Rate limits
100 requests per minute per API key. Every response carries the current state, so you don't have to count:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 93
X-RateLimit-Reset: 1790000460 // epoch secondsOver the limit returns 429 with a rate_limited error and a Retry-After header in seconds. The window is fixed, not sliding: it resets wholesale rather than rolling. The budget is per key, so two integrations in the same organization don't compete — give each one its own key.
Pagination
All list endpoints take limit (default 25, maximum 100) plus either page (1-based) or cursor. Prefer the cursor: read meta.nextCursor and pass it back until meta.hasMore is false.
GET https://api.clientpad.com/v1/contacts?limit=100
GET https://api.clientpad.com/v1/contacts?limit=100&cursor=bzoxMDATreat the cursor as an opaque token. It is currently offset-backed, so it is only valid for the limit it was issued with — do not change limit part-way through an iteration.
Contacts
GET /contacts
search matches name or email.POST /contacts
GET /contacts/{id}
PATCH /contacts/{id}
null to clear a nullable field.| Field | Notes |
|---|---|
| id | UUID, read-only. |
| String or null. | |
| firstName, lastName, title, phone | Strings or null. At least one of email, firstName or lastName is required on create. |
| accountId | UUID of a company, or null. |
| ownerId | UUID of an organization member, or null. |
| lifecycleStageId | UUID of one of the organization's lifecycle stages, or null. |
| source, lawfulBasis | Consent provenance. Defaults to "api" and "legitimate_interest" when you create a contact and don't supply them. |
| acquiredAt, unsubscribedAt | ISO 8601 timestamps or null. Read-only. |
| customFields | Object of the organization's own custom field values. |
| createdAt, updatedAt | ISO 8601 timestamps, read-only. |
curl -X POST https://api.clientpad.com/v1/contacts \
-H "Authorization: Bearer cp_live_..." \
-H "content-type: application/json" \
-d '{"email":"ada@example.com","firstName":"Ada","lastName":"Lovelace"}'
// 201
{ "data": { "id": "9f1c...", "email": "ada@example.com", "source": "api", ... } }Companies
GET /companies
search matches name or domain.POST /companies
GET /companies/{id}
PATCH /companies/{id}
| Field | Notes |
|---|---|
| id | UUID, read-only. |
| name | String. Required on create. |
| domain | String or null. Unique per organization among live records. |
| industryCode, size | Strings or null. size is free text, e.g. "50-200". |
| ownerId | UUID of an organization member, or null. |
| logoUrl | String or null, read-only — set by the signal crawler. |
| customFields | Object of the organization's own custom field values. |
| createdAt, updatedAt | ISO 8601 timestamps, read-only. |
Deals
GET /deals
status (open, won, lost) and pipelineId.| Field | Notes |
|---|---|
| id, name | UUID and the deal's title. |
| accountId, contactId | Linked company and contact, or null. |
| stageId, stageName | The pipeline stage it currently sits in. |
| status | open, won or lost. Derived from the stage, not set independently. |
| expectedClose | Date or null. |
| ownerId | UUID of an organization member, or null. |
| createdAt, updatedAt | ISO 8601 timestamps. |
Tickets
GET /tickets
status, assigneeId and priority.| Field | Notes |
|---|---|
| id, number | UUID and the per-organization ticket number. |
| subject | The ticket's subject line. |
| status, priority | Current state and priority. |
| assigneeId | UUID of an organization member, or null. |
| contactId, contactEmail | The requester. |
| firstResponseAt, resolvedAt | ISO 8601 timestamps or null. |
| firstResponseDueAt, resolutionDueAt | SLA deadlines, ISO 8601 or null. |
| createdAt, updatedAt | ISO 8601 timestamps. |
Lists
Put an address on one of your contact lists, or take it off, from your own server. The contact is created if it is new and reused if not — the same address is never on a list twice. Removing someone from a list leaves the contact itself untouched. For a form on a public web page, which cannot hold an API key, use the unauthenticated /lists/subscribe endpoint described under “Submitting a lead form” instead.
GET /lists
id, name and memberCount — so a job that sweeps your lists never has to be configured with their ids, and picks up a list made later on its own.GET /lists/{listId}/contacts
id and email on every entry. Paginated like the other collections. enrolledOnly=true returns only members a sequence is still sending to — someone who has finished their cadence is no longer enrolled, so they are left out. On a long list that is the difference between a job that costs what the remaining work costs and one that costs the whole list every run.POST /lists/{listId}/contacts
email (required) plus name, or firstName and lastName. Returns 201 with contactId and whether the contact was created.POST /contacts/stop-outreach
email — one address, or several separated by commas, semicolons or spaces, since a stored field often holds more than one. Pauses every sequence still emailing those addresses and takes them off every list. The answer is always an array, one entry per address, so you never have to branch on how many you sent. Meant for a payment or signup hook, called the moment someone converts — a scheduled sweep cannot close the gap between buying and the next sequence step. Keyed by email because that is what such a hook has; an address you have never seen answers found: false rather than 404. Idempotent, so retrying a webhook is free. Not the do-not-email list: that blocks ticket replies and receipts too, and a new customer should stop getting outreach rather than stop being reachable.GET /company-lists
id, name and memberCount. Separate from /lists, which is contacts.GET /company-lists/{listId}/companies
POST /company-lists/{listId}/companies
domain or name (at least one), plus optional industryCode and size. The company is created if it is new and reused if not — matched on domain when you give one, since that is what is unique per organization, and on an exact name otherwise. A company sent with only a domain is named after it.DELETE /company-lists/{listId}/companies/{companyId}
404 if it was not on that list.DELETE /lists/{listId}/contacts/{contactId}
404 if the contact is not on that list, so a wrong id is visible rather than reported as done.curl https://api.clientpad.com/v1/lists \
-H "Authorization: Bearer cp_live_..."
// 200
{ "data": [ { "id": "LIST_ID", "name": "Newsletter", "memberCount": 412, "createdAt": "..." } ], "meta": { ... } }
# Who on it is still being emailed by a sequence
curl "https://api.clientpad.com/v1/lists/LIST_ID/contacts?enrolledOnly=true&limit=100" \
-H "Authorization: Bearer cp_live_..."
// 200
{ "data": [ { "id": "9f1c...", "email": "ada@example.com", "firstName": "Ada", ... } ],
"meta": { "total": 1, "limit": 100, "page": 1, "hasMore": false, "nextCursor": null } }
curl -X POST https://api.clientpad.com/v1/lists/LIST_ID/contacts \
-H "Authorization: Bearer cp_live_..." \
-H "content-type: application/json" \
-d '{"email":"ada@example.com","name":"Ada Lovelace"}'
// 201
{ "data": { "listId": "LIST_ID", "contactId": "9f1c...", "created": true } }
curl -X DELETE https://api.clientpad.com/v1/lists/LIST_ID/contacts/9f1c... \
-H "Authorization: Bearer cp_live_..."
// 200
{ "data": { "listId": "LIST_ID", "contactId": "9f1c...", "removed": true } }Submitting a lead form
One endpoint is deliberately unauthenticated, so you can post a form from a public website without putting a secret in page markup. The form's own slug is the credential, exactly as it already is for the hosted page and the embed — this grants nothing those don't, it just lets you own the markup.
POST /lead-forms/{slug}/submissions
Authorization header. CORS is open, so it can be called from a browser.curl -X POST https://api.clientpad.com/v1/lead-forms/contact-us-50ed187d/submissions -H "content-type: application/json" -d '{
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": "+1 555 0100",
"company": "Analytical Engines",
"message": "Interested in a demo",
"customFields": { "<custom-field-uuid>": "Referral" }
}'
// 201
{ "data": { "contactId": "9f1c..." } }| Field | Notes |
|---|---|
| name | Required. Split into first and last name on the first space. |
| Required. Lowercased, then matched against existing contacts — a repeat submission updates that contact rather than duplicating it. | |
| phone, company, message | Optional. |
| customFields | Optional object keyed by custom field id, for the fields the form is configured to ask for. |
| companyUrl | Leave this absent. It is a honeypot: a filled value returns a normal-looking 201 and stores nothing. |
Find the slug under Contacts → Lead forms — it is the last segment of the form's public link. The form must be published; an unpublished slug returns 404. Submissions are rate limited per IP, and a tripped limit returns 429.
Putting a form on WordPress
The slug is the last part of the form's public link. Open Contacts → Lead forms, look at a form's link — e.g. https://app.clientpad.com/lead/contact-us-50ed187d — and the slug is contact-us-50ed187d.
There is no list id to put in your form. Which contact list a submission joins is set on the form itself in ClientPad, so you can change the destination later without touching your website. Tick the lists you want when you create the form.
Paste this into a Custom HTML block, replacing the slug on the first line. It is a plain form — style it however you like, or swap in your own markup and keep the script.
<form id="cp-form">
<input name="name" placeholder="Your name" required />
<input name="email" type="email" placeholder="Your email" required />
<textarea name="message" placeholder="How can we help?"></textarea>
<button type="submit">Send</button>
<p id="cp-done" hidden>Thanks — we'll be in touch.</p>
</form>
<script>
const CP_SLUG = "contact-us-50ed187d"; // <- your form's slug
document.getElementById("cp-form").addEventListener("submit", async (e) => {
e.preventDefault();
const form = e.target;
const res = await fetch(
"https://api.clientpad.com/v1/lead-forms/" + CP_SLUG + "/submissions",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(Object.fromEntries(new FormData(form))),
}
);
if (res.ok) {
form.reset();
document.getElementById("cp-done").hidden = false;
} else {
const { error } = await res.json();
alert(error.message);
}
});
</script>No API key in either version — the slug is the credential, and a public page can't keep a secret. If you'd rather write no code at all, the form's Share dialog has an embed snippet that drops the ClientPad-hosted form into your page instead; the trade-off is that its design is ours rather than yours.
Webhooks
Rather than polling, subscribe an HTTPS endpoint under Settings → Developer → Webhooks. Each subscription picks which events it wants.
| Field | Notes |
|---|---|
| contact.created | A contact was created. |
| contact.updated | A contact's fields changed. |
| company.created | A company was created. |
| company.updated | A company's fields changed. |
| deal.created | A deal was created. |
| deal.stage_changed | A deal moved to a different pipeline stage. |
Deliveries are a JSON POST:
POST https://your-app.example.com/hooks/clientpad
content-type: application/json
X-ClientPad-Event: contact.created
X-ClientPad-Delivery: 7b3f... // unique per attempt
X-ClientPad-Signature: 9c1e... // HMAC-SHA256, hex
{ "event": "contact.created", "createdAt": "2026-10-01T12:00:00.000Z", "data": { ... } }Verifying a delivery
X-ClientPad-Signature is an HMAC-SHA256 of the raw request body, hex-encoded, keyed with your endpoint's signing secret (shown when you create the endpoint). Compute it over the bytes exactly as received — parsing and re-serializing the JSON will produce different bytes and a signature that never matches.
import { createHmac, timingSafeEqual } from "node:crypto";
const expected = createHmac("sha256", signingSecret).update(rawBody).digest("hex");
const received = req.headers["x-clientpad-signature"];
const valid =
expected.length === received.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(received));Delivery behaviour
- HTTPS only, and the endpoint's hostname must resolve to a public address — private, loopback, link-local and carrier-NAT ranges are rejected at save time and again at delivery.
- One attempt per event, with a short timeout. There are no automatic retries yet, so treat a webhook as a prompt to reconcile rather than a guaranteed log. Every attempt and its response status is visible under Settings → Developer.
- Respond
2xxquickly and do your work afterwards. A slow endpoint records a timeout.
Questions
If something here is wrong, missing, or you need an endpoint that doesn't exist yet, tell us what you're building — it's the fastest way to get a capability added. Use the contact form.