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/v1

Authentication

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

FieldNotes
unauthorized — 401Missing or invalid API key.
not_found — 404No such record in this organization.
invalid_request — 400Malformed JSON body, or a query parameter that isn't a valid value.
validation_failed — 422Well-formed request, unacceptable values. The message names the first offending field.
internal_error — 500Something 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 seconds

Over 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=bzoxMDA

Treat 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

Optional search matches name or email.

POST /contacts

GET /contacts/{id}

PATCH /contacts/{id}

Only the fields you send are changed. Send null to clear a nullable field.
FieldNotes
idUUID, read-only.
emailString or null.
firstName, lastName, title, phoneStrings or null. At least one of email, firstName or lastName is required on create.
accountIdUUID of a company, or null.
ownerIdUUID of an organization member, or null.
lifecycleStageIdUUID of one of the organization's lifecycle stages, or null.
source, lawfulBasisConsent provenance. Defaults to "api" and "legitimate_interest" when you create a contact and don't supply them.
acquiredAt, unsubscribedAtISO 8601 timestamps or null. Read-only.
customFieldsObject of the organization's own custom field values.
createdAt, updatedAtISO 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

Optional search matches name or domain.

POST /companies

GET /companies/{id}

PATCH /companies/{id}

FieldNotes
idUUID, read-only.
nameString. Required on create.
domainString or null. Unique per organization among live records.
industryCode, sizeStrings or null. size is free text, e.g. "50-200".
ownerIdUUID of an organization member, or null.
logoUrlString or null, read-only — set by the signal crawler.
customFieldsObject of the organization's own custom field values.
createdAt, updatedAtISO 8601 timestamps, read-only.

Deals

GET /deals

Optional status (open, won, lost) and pipelineId.
FieldNotes
id, nameUUID and the deal's title.
accountId, contactIdLinked company and contact, or null.
stageId, stageNameThe pipeline stage it currently sits in.
statusopen, won or lost. Derived from the stage, not set independently.
expectedCloseDate or null.
ownerIdUUID of an organization member, or null.
createdAt, updatedAtISO 8601 timestamps.

Tickets

GET /tickets

Optional status, assigneeId and priority.
FieldNotes
id, numberUUID and the per-organization ticket number.
subjectThe ticket's subject line.
status, priorityCurrent state and priority.
assigneeIdUUID of an organization member, or null.
contactId, contactEmailThe requester.
firstResponseAt, resolvedAtISO 8601 timestamps or null.
firstResponseDueAt, resolutionDueAtSLA deadlines, ISO 8601 or null.
createdAt, updatedAtISO 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

Every list in your organization, each with 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

Who is on the list, newest join first, with 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

Body: email (required) plus name, or firstName and lastName. Returns 201 with contactId and whether the contact was created.

POST /contacts/stop-outreach

Body: 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

Your company lists, each with id, name and memberCount. Separate from /lists, which is contacts.

GET /company-lists/{listId}/companies

Which companies are on one, newest first, paginated.

POST /company-lists/{listId}/companies

Body: 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}

Takes the company off the list and leaves the company itself alone. 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

No 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..." } }
FieldNotes
nameRequired. Split into first and last name on the first space.
emailRequired. Lowercased, then matched against existing contacts — a repeat submission updates that contact rather than duplicating it.
phone, company, messageOptional.
customFieldsOptional object keyed by custom field id, for the fields the form is configured to ask for.
companyUrlLeave 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.

FieldNotes
contact.createdA contact was created.
contact.updatedA contact's fields changed.
company.createdA company was created.
company.updatedA company's fields changed.
deal.createdA deal was created.
deal.stage_changedA 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 2xx quickly 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.