Public REST API (v1)
Overview
All endpoints live under the base path /api/v1 on your CloseUp address. In the examples below, https://YOUR-CLOSEUP-APP stands for the address you sign in to CloseUp at.
| Method | Path | What it does |
|---|---|---|
GET | /api/v1/leads/{leadId}/status | Read a lead and its current status |
PATCH | /api/v1/leads/{leadId}/status | Change a lead's status (POST is an alias) |
POST | /api/v1/appointments | Book an appointment for a lead |
GET | /api/v1/appointments?leadId={leadId} | List a lead's appointments |
GET | /api/v1/appointments/{appointmentId} | Read one appointment |
PATCH | /api/v1/appointments/{appointmentId} | Reschedule or close an appointment (POST is an alias) |
Every request is scoped to the company that owns the API key. A key can only read or change rows that belong to its own company. A row from another company returns not_found, exactly like a row that does not exist.
Request and response bodies are JSON. Send Content-Type: application/json on every request that has a body. IDs are UUIDs. Timestamps are ISO 8601.
Authentication
Authenticate with a company API key. A company admin creates keys in CloseUp under Lead Sources → API keys & REST API. The key is shown only once, so store it in a secrets manager right away. Turning a key off or deleting it cuts off access immediately.
Send the key in either header:
X-API-Key: YOUR_API_KEY
# or
Authorization: Bearer YOUR_API_KEYAn API key has no user and no role. It can perform the operations on this page for its own company, and nothing else. The same company API keys are used by the WordPress plugin.
Request signing (HMAC-SHA256)
When you create a key you can also generate a signing secret. If a key has a signing secret, every POST and PATCH request made with that key must carry an X-CLL-Signature header. Requests without a valid signature are rejected with invalid_signature. GET requests are not signed.
A key without a signing secret is bearer-only: the key alone is the credential. Use a signing secret for production keys.
To compute the signature:
- Serialize the JSON body to a string once. This exact string is what you send.
- Compute HMAC-SHA256 over the raw UTF-8 bytes of that string, keyed with the signing secret.
- Hex-encode the digest (lowercase) and send it as
X-CLL-Signature: sha256=<hex digest>.
BASE="https://YOUR-CLOSEUP-APP/api/v1"
LEAD_ID="3f6c2a1e-8b7d-4c3a-9e21-5d4f0b7a6c11"
BODY='{"status":"closed","reason":"Signed the annual plan"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$CLOSEUP_SIGNING_SECRET" -hex | awk '{print $NF}')
curl -X PATCH "$BASE/leads/$LEAD_ID/status" \
-H "X-API-Key: $CLOSEUP_API_KEY" \
-H "X-CLL-Signature: sha256=$SIG" \
-H "Content-Type: application/json" \
-d "$BODY"import crypto from "node:crypto";
const BASE = "https://YOUR-CLOSEUP-APP/api/v1";
async function closeupRequest(method, path, payload) {
const headers = { "X-API-Key": process.env.CLOSEUP_API_KEY };
let body;
if (payload !== undefined) {
body = JSON.stringify(payload); // serialize once, sign and send this string
const digest = crypto
.createHmac("sha256", process.env.CLOSEUP_SIGNING_SECRET)
.update(body, "utf8")
.digest("hex");
headers["Content-Type"] = "application/json";
headers["X-CLL-Signature"] = "sha256=" + digest;
}
const res = await fetch(BASE + path, { method, headers, body });
const json = await res.json();
if (!json.ok) {
throw new Error(json.error.code + ": " + json.error.message);
}
return json.data;
}
const data = await closeupRequest(
"PATCH",
"/leads/3f6c2a1e-8b7d-4c3a-9e21-5d4f0b7a6c11/status",
{ status: "near_closure" }
);
console.log(data.previousStatus, "->", data.lead.status);import hashlib
import hmac
import json
import os
import requests
BASE = "https://YOUR-CLOSEUP-APP/api/v1"
API_KEY = os.environ["CLOSEUP_API_KEY"]
SECRET = os.environ["CLOSEUP_SIGNING_SECRET"].encode()
payload = {"status": "near_closure"}
body = json.dumps(payload, separators=(",", ":")) # serialize once
signature = hmac.new(SECRET, body.encode("utf-8"), hashlib.sha256).hexdigest()
resp = requests.patch(
f"{BASE}/leads/3f6c2a1e-8b7d-4c3a-9e21-5d4f0b7a6c11/status",
data=body, # send the exact signed string, not json=payload
headers={
"X-API-Key": API_KEY,
"X-CLL-Signature": f"sha256={signature}",
"Content-Type": "application/json",
},
timeout=15,
)
print(resp.status_code, resp.json())Response format
Every response uses the same envelope. On success:
{ "ok": true, "data": { "...": "..." } }On failure:
{
"ok": false,
"error": {
"code": "validation_failed",
"message": "Invalid request body.",
"details": [
{ "field": "startAt", "message": "Invalid datetime" }
]
}
}Branch on error.code, not on message. The message is for humans and can change. details is present only when there is something to add: a list of { field, message } objects for body validation errors, or an object such as { "allowedStatuses": [...] } for an unknown status.
Error codes
code | HTTP | Meaning |
|---|---|---|
unauthorized | 401 | API key missing, unknown or turned off |
invalid_signature | 401 | The key requires a signature, and none was sent or it did not match the body |
invalid_body | 400 | The body is not valid JSON |
validation_failed | 422 | The JSON parsed, but a field or parameter is wrong. See details |
not_found | 404 | No such lead or appointment in this company |
conflict | 409 | The appointment is no longer in a state that allows this change |
rate_limited | 429 | More than 120 requests in a minute for this key. See Retry-After |
internal_error | 500 | Unexpected server error. The message includes a request ID |
Rate limits
Each API key may make 120 requests per minute. Over the limit, the API returns 429 rate_limited with a Retry-After header that gives the number of seconds to wait. Successful responses carry x-ratelimit-remaining, the number of requests left in the current window.
Back off and retry after Retry-After seconds. Do not retry 4xx errors other than 429 without changing the request.
Request IDs
Successful responses carry an x-request-id header. An internal_error response includes the request ID in its message, for example Unexpected error. Request id: 7b1d.... Log it and quote it when you contact CloseUp support. It lets us find the exact request on our side.
Idempotency
Creating an appointment accepts an optional idempotencyKey (8 to 120 characters). If you send the same key again, the API returns the appointment that key already created instead of booking a second one. Generate one key per booking (a UUID works) and reuse it on every retry of that booking.
idempotencyKey, CloseUp generates a fresh one for each request, so a retried request after a timeout can create a duplicate appointment. Always send your own key when you might retry.Read a lead's status
GET /api/v1/leads/{leadId}/statuscurl "https://YOUR-CLOSEUP-APP/api/v1/leads/3f6c2a1e-8b7d-4c3a-9e21-5d4f0b7a6c11/status" \
-H "X-API-Key: $CLOSEUP_API_KEY"{
"ok": true,
"data": {
"lead": {
"id": "3f6c2a1e-8b7d-4c3a-9e21-5d4f0b7a6c11",
"name": "Dana Levi",
"email": "dana@example.com",
"phone": "0501234567",
"status": "proposal_sent",
"campaign": "Summer sale",
"createdAt": "2026-01-04 09:12:00+00",
"updatedAt": "2026-02-11 14:03:00+00"
}
}
}Lead object
| Field | Type | Notes |
|---|---|---|
id | uuid | Lead ID |
name | string | Display name |
email | string or null | |
phone | string or null | |
status | string or null | Current status key. A status set by a user takes precedence over an automatic one |
campaign | string or null | Campaign name |
createdAt | timestamp string or null | When the lead was registered |
updatedAt | timestamp string or null | Last change |
Update a lead's status
PATCH /api/v1/leads/{leadId}/status
POST /api/v1/leads/{leadId}/status (alias, for clients that cannot send PATCH)| Field | Type | Notes |
|---|---|---|
status | string, required | One of the company's status keys (max 80 characters) |
reason | string, optional | Free-text note stored on the status history entry (max 500 characters) |
closedValueBasis | "proposal" or "retainer", optional | Which amount counts as the deal value when closing. Ignored for other statuses |
Built-in status keys: new, awaiting_proposal, proposal_sent, near_closure, closed, not_interested, irrelevant, follow_up_later, paused. A company can add its own statuses. If you send a key the company does not use, the API returns validation_failed and lists the accepted keys in details.allowedStatuses, so your integration can discover them.
curl -X PATCH "https://YOUR-CLOSEUP-APP/api/v1/leads/3f6c2a1e-8b7d-4c3a-9e21-5d4f0b7a6c11/status" \
-H "X-API-Key: $CLOSEUP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"status":"closed","reason":"Signed the annual plan","closedValueBasis":"proposal"}'{
"ok": true,
"data": {
"lead": {
"id": "3f6c2a1e-8b7d-4c3a-9e21-5d4f0b7a6c11",
"name": "Dana Levi",
"email": "dana@example.com",
"phone": "0501234567",
"status": "closed",
"campaign": "Summer sale",
"createdAt": "2026-01-04 09:12:00+00",
"updatedAt": "2026-03-02 10:41:07+00"
},
"previousStatus": "proposal_sent"
}
}{
"ok": false,
"error": {
"code": "validation_failed",
"message": "Unknown status \"won\".",
"details": {
"allowedStatuses": [
"new", "awaiting_proposal", "proposal_sent", "near_closure", "closed",
"not_interested", "irrelevant", "follow_up_later", "paused"
]
}
}
}A status change through the API runs the same logic as a change in the CloseUp dashboard. It records status history, logs a status-change activity on the lead, closes open tasks when the status is final, notifies managers on a win, and reports the conversion to connected ad platforms.
Create an appointment
POST /api/v1/appointments| Field | Type | Notes |
|---|---|---|
leadId | uuid, required | Must belong to this company |
title | string, required | 1 to 200 characters |
startAt | ISO 8601 with offset, required | For example 2026-03-04T11:00:00+02:00 |
endAt | ISO 8601 with offset | Send either endAt or durationMinutes |
durationMinutes | integer, 5 to 1440 | Used when endAt is absent |
timezone | IANA name, optional | For example Asia/Jerusalem |
type | phone, in_person or video | Defaults to phone |
location | string, optional | Address, room or meeting link (max 500 characters) |
notes | string, optional | Max 5000 characters |
assignedUserId | uuid, optional | An active user in this company. Defaults to the user behind the lead's assigned sales agent |
idempotencyKey | string, 8 to 120 characters, optional | Same key returns the same appointment. See Idempotency |
attendees | array, optional | Up to 25 external guests: { email, displayName?, isOptional? } |
Every appointment must belong to a real person. If the lead has no assigned agent with a user account and you do not send assignedUserId, the API returns validation_failed.
Appointments created through the API live in CloseUp only. They are not pushed to an external calendar and no video link is created, because an API caller cannot pick a calendar connection. The assigned rep can push the appointment to their calendar from CloseUp. No invitation email is sent.
curl -X POST "https://YOUR-CLOSEUP-APP/api/v1/appointments" \
-H "X-API-Key: $CLOSEUP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"leadId": "3f6c2a1e-8b7d-4c3a-9e21-5d4f0b7a6c11",
"title": "Site survey",
"startAt": "2026-03-04T11:00:00+02:00",
"durationMinutes": 30,
"timezone": "Asia/Jerusalem",
"type": "in_person",
"location": "Rothschild 12, Tel Aviv",
"idempotencyKey": "booking-9c1f6e2a-55d4-4b0e-a1c7-2f8e3b6d9a40",
"attendees": [
{ "email": "dana@example.com", "displayName": "Dana Levi" }
]
}'{
"ok": true,
"data": {
"appointment": {
"id": "b2e4d7a9-1c3f-4e8b-9a6d-0f5c2e7b1a34",
"leadId": "3f6c2a1e-8b7d-4c3a-9e21-5d4f0b7a6c11",
"assignedUserId": "8a1d3c5e-7f9b-4d2a-b6c8-e0f2a4c6d8e1",
"title": "Site survey",
"startAt": "2026-03-04T09:00:00.000Z",
"endAt": "2026-03-04T09:30:00.000Z",
"timezone": "Asia/Jerusalem",
"type": "in_person",
"location": "Rothschild 12, Tel Aviv",
"status": "scheduled",
"notes": null,
"videoJoinUrl": null,
"sync": { "status": "not_applicable", "error": null, "lastSyncedAt": null },
"rescheduleCount": 0,
"cancelledAt": null,
"completedAt": null,
"createdAt": "2026-02-11T14:03:00.000Z",
"updatedAt": "2026-02-11T14:03:00.000Z",
"attendees": [
{
"kind": "organizer",
"userId": "8a1d3c5e-7f9b-4d2a-b6c8-e0f2a4c6d8e1",
"email": "rep@yourcompany.com",
"displayName": "Yossi Cohen"
},
{
"kind": "external",
"userId": null,
"email": "dana@example.com",
"displayName": "Dana Levi"
}
]
}
}
}Appointment object
| Field | Type | Notes |
|---|---|---|
id | uuid | Appointment ID |
leadId | uuid | |
assignedUserId | uuid | The rep who owns the appointment |
title | string | |
startAt, endAt | timestamp | Returned in UTC |
timezone | string or null | IANA time zone |
type | string | phone, in_person or video |
location | string or null | |
status | string | scheduled, completed, cancelled or no_show |
notes | string or null | |
videoJoinUrl | string or null | Set only when a rep adds a video meeting from CloseUp |
sync | object | status, error, lastSyncedAt of the external calendar copy, if any |
rescheduleCount | integer | How many times it was moved |
cancelledAt, completedAt | timestamp or null | |
createdAt, updatedAt | timestamp | |
attendees | array | { kind, userId, email, displayName }. kind is organizer or external |
List a lead's appointments
GET /api/v1/appointments?leadId={leadId}| Query parameter | Notes |
|---|---|
leadId | Required. The lead whose appointments you want |
status | Optional. scheduled, completed, cancelled or no_show |
from | Optional. Only appointments whose startAt is at or after this timestamp |
to | Optional. Only appointments whose startAt is at or before this timestamp |
Send from and to in UTC, in the same format the API returns (for example 2026-03-01T00:00:00.000Z). The list is always for one lead. There is no endpoint that lists all appointments across the company.
curl "https://YOUR-CLOSEUP-APP/api/v1/appointments?leadId=3f6c2a1e-8b7d-4c3a-9e21-5d4f0b7a6c11&status=scheduled&from=2026-03-01T00:00:00.000Z" \
-H "X-API-Key: $CLOSEUP_API_KEY"{
"ok": true,
"data": {
"appointments": [
{
"id": "b2e4d7a9-1c3f-4e8b-9a6d-0f5c2e7b1a34",
"leadId": "3f6c2a1e-8b7d-4c3a-9e21-5d4f0b7a6c11",
"title": "Site survey",
"startAt": "2026-03-04T09:00:00.000Z",
"endAt": "2026-03-04T09:30:00.000Z",
"status": "scheduled",
"...": "same shape as the appointment object"
}
]
}
}Read an appointment
GET /api/v1/appointments/{appointmentId}Returns { "ok": true, "data": { "appointment": { ... } } } with the full appointment object, or not_found.
Update an appointment
PATCH /api/v1/appointments/{appointmentId}
POST /api/v1/appointments/{appointmentId} (alias)One request does one of two things: it reschedules the appointment, or it closes it. Sending both startAt and status in the same request returns validation_failed.
| Field | Type | Notes |
|---|---|---|
startAt | ISO 8601 with offset | Reschedule. New start time |
endAt | ISO 8601 with offset, optional | New end time |
durationMinutes | integer, 5 to 1440, optional | Used when endAt is absent. With neither, the appointment keeps its current length |
timezone | IANA name, optional | Defaults to the current time zone |
status | cancelled, no_show or completed | Close the appointment |
curl -X PATCH "https://YOUR-CLOSEUP-APP/api/v1/appointments/b2e4d7a9-1c3f-4e8b-9a6d-0f5c2e7b1a34" \
-H "X-API-Key: $CLOSEUP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"startAt":"2026-03-05T14:00:00+02:00","durationMinutes":45}'curl -X PATCH "https://YOUR-CLOSEUP-APP/api/v1/appointments/b2e4d7a9-1c3f-4e8b-9a6d-0f5c2e7b1a34" \
-H "X-API-Key: $CLOSEUP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"status":"no_show"}'Both return 200 with the updated appointment object. Only a scheduled appointment can change. An appointment that is already cancelled, completed or no_show returns 409 conflict:
{
"ok": false,
"error": { "code": "conflict", "message": "Appointment is already cancelled." }
}There is no delete endpoint. To call off an appointment, set its status to cancelled.