דלג לתוכן הראשי
מתוכנית Business ומעלה

Public REST API (v1)

A focused API for syncing lead status and managing appointments from an external system. Lead intake goes through webhooks, not this API.

התיעוד למפתחים כתוב באנגלית.

The public API is available on the Business plan and above. It is intentionally small: you can read and update a lead's status, and create, list, read and update appointments. It does not create, list or delete leads. To send new leads into CloseUp, use lead webhooks or website forms.

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.

MethodPathWhat it does
GET/api/v1/leads/{leadId}/statusRead a lead and its current status
PATCH/api/v1/leads/{leadId}/statusChange a lead's status (POST is an alias)
POST/api/v1/appointmentsBook 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:

http
X-API-Key: YOUR_API_KEY

# or

Authorization: Bearer YOUR_API_KEY

An 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.

Never put an API key in browser code, a mobile app or a public repository. Call the API from your server only. For browser forms, use website forms, which use a public site key instead.

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:

  1. Serialize the JSON body to a string once. This exact string is what you send.
  2. Compute HMAC-SHA256 over the raw UTF-8 bytes of that string, keyed with the signing secret.
  3. Hex-encode the digest (lowercase) and send it as X-CLL-Signature: sha256=<hex digest>.
Sign the exact bytes you send. Re-serializing the body after signing (for example, letting an HTTP library re-encode an object) changes whitespace or key order and breaks the signature. A request with an empty body is signed over the empty string.
curl + openssl
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"
Node.js (18+)
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);
Python (requests)
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:

json
{ "ok": true, "data": { "...": "..." } }

On failure:

json
{
  "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

codeHTTPMeaning
unauthorized401API key missing, unknown or turned off
invalid_signature401The key requires a signature, and none was sent or it did not match the body
invalid_body400The body is not valid JSON
validation_failed422The JSON parsed, but a field or parameter is wrong. See details
not_found404No such lead or appointment in this company
conflict409The appointment is no longer in a state that allows this change
rate_limited429More than 120 requests in a minute for this key. See Retry-After
internal_error500Unexpected 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.

If you omit 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

http
GET /api/v1/leads/{leadId}/status
bash
curl "https://YOUR-CLOSEUP-APP/api/v1/leads/3f6c2a1e-8b7d-4c3a-9e21-5d4f0b7a6c11/status" \
  -H "X-API-Key: $CLOSEUP_API_KEY"
200 OK
{
  "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

FieldTypeNotes
iduuidLead ID
namestringDisplay name
emailstring or null
phonestring or null
statusstring or nullCurrent status key. A status set by a user takes precedence over an automatic one
campaignstring or nullCampaign name
createdAttimestamp string or nullWhen the lead was registered
updatedAttimestamp string or nullLast change

Update a lead's status

http
PATCH /api/v1/leads/{leadId}/status
POST  /api/v1/leads/{leadId}/status   (alias, for clients that cannot send PATCH)
FieldTypeNotes
statusstring, requiredOne of the company's status keys (max 80 characters)
reasonstring, optionalFree-text note stored on the status history entry (max 500 characters)
closedValueBasis"proposal" or "retainer", optionalWhich 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.

bash
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"}'
200 OK
{
  "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"
  }
}
422 Unknown status
{
  "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

http
POST /api/v1/appointments
FieldTypeNotes
leadIduuid, requiredMust belong to this company
titlestring, required1 to 200 characters
startAtISO 8601 with offset, requiredFor example 2026-03-04T11:00:00+02:00
endAtISO 8601 with offsetSend either endAt or durationMinutes
durationMinutesinteger, 5 to 1440Used when endAt is absent
timezoneIANA name, optionalFor example Asia/Jerusalem
typephone, in_person or videoDefaults to phone
locationstring, optionalAddress, room or meeting link (max 500 characters)
notesstring, optionalMax 5000 characters
assignedUserIduuid, optionalAn active user in this company. Defaults to the user behind the lead's assigned sales agent
idempotencyKeystring, 8 to 120 characters, optionalSame key returns the same appointment. See Idempotency
attendeesarray, optionalUp 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.

bash
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" }
    ]
  }'
201 Created
{
  "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

FieldTypeNotes
iduuidAppointment ID
leadIduuid
assignedUserIduuidThe rep who owns the appointment
titlestring
startAt, endAttimestampReturned in UTC
timezonestring or nullIANA time zone
typestringphone, in_person or video
locationstring or null
statusstringscheduled, completed, cancelled or no_show
notesstring or null
videoJoinUrlstring or nullSet only when a rep adds a video meeting from CloseUp
syncobjectstatus, error, lastSyncedAt of the external calendar copy, if any
rescheduleCountintegerHow many times it was moved
cancelledAt, completedAttimestamp or null
createdAt, updatedAttimestamp
attendeesarray{ kind, userId, email, displayName }. kind is organizer or external

List a lead's appointments

http
GET /api/v1/appointments?leadId={leadId}
Query parameterNotes
leadIdRequired. The lead whose appointments you want
statusOptional. scheduled, completed, cancelled or no_show
fromOptional. Only appointments whose startAt is at or after this timestamp
toOptional. 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.

bash
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"
200 OK
{
  "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

http
GET /api/v1/appointments/{appointmentId}

Returns { "ok": true, "data": { "appointment": { ... } } } with the full appointment object, or not_found.

Update an appointment

http
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.

FieldTypeNotes
startAtISO 8601 with offsetReschedule. New start time
endAtISO 8601 with offset, optionalNew end time
durationMinutesinteger, 5 to 1440, optionalUsed when endAt is absent. With neither, the appointment keeps its current length
timezoneIANA name, optionalDefaults to the current time zone
statuscancelled, no_show or completedClose the appointment
Reschedule
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}'
Mark as no-show
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:

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.