API reference

The Virtual Closer API.

A small, deliberately narrow HTTP API for getting leads into Virtual Closer and getting events back out of it in real time. It is what our Zapier integration runs on, and it is documented here in full so you can build against it directly if you would rather not use Zapier at all.

Base URL and versioning

Every endpoint lives under:

https://app.virtualcloser.com

All requests must use HTTPS. Plain HTTP is redirected, and you should never send a key over a redirect you did not initiate.

There is no version prefix in the path. We add fields to payloads but do not remove or rename them, and we do not change the meaning of an existing field — so parse defensively, ignore what you do not recognise, and an integration written today keeps working.

Authentication

Authentication is a single API key. Generate one inside the app: IntegrationsZapierCreate API key. The key is shown once at creation and never again — we store only a SHA-256 hash of it, so we cannot redisplay it and neither can anyone who reads our database. If you lose it, revoke it and create another.

Send it on every request, in either of these headers:

X-API-Key: vck_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Authorization: Bearer vck_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Both are accepted and equivalent. Keys are prefixed vck_ so that one leaked into a log file is recognisable at a glance as a credential worth revoking.

Two scopes of key

ScopeSees
AgencyEvery lead, appointment and client in the agency.
PersonalOnly records where you are the owner, setter or closer.

The scope is fixed at creation and is a property of the key, not of the request. A personal key cannot be widened by asking nicely, and an agency key cannot be narrowed — create the key you actually want. Only agency administrators can create agency-scoped keys.

Every key belongs to exactly one agency, and every response is filtered to that agency before it leaves our servers. There is no parameter anywhere in this API that names an agency, because there is no request you could construct that reaches another tenant's data.

Revoking

Revoke a key from the same Integrations page. Revocation takes effect immediately on the next request — there is no cache and no grace period. Any REST hook subscriptions created with that key stop being delivered.

Errors

StatusMeaning
200Success.
400A parameter is missing or not one we recognise. The body names it.
401No key, an unknown key, or a revoked one.
405Wrong HTTP method for that path.
500Our fault. Safe to retry.

Errors are JSON and always carry a human-readable error:

{ "error": "Invalid or revoked API key." }

A revoked key and an unknown key both return 401 with the same message, on purpose — telling a caller which one it was tells an attacker whether a guessed key ever existed.

Endpoints

GET /api/zapier/auth

Verifies a key and describes what it can reach. Use it as a connection test; it has no side effects and is safe to call as often as you like.

curl https://app.virtualcloser.com/api/zapier/auth \
  -H "X-API-Key: $VC_API_KEY"
{
  "ok": true,
  "agency_id": "9f1c5a2e-7b3d-4c8a-9e11-2f6d8b4a0c73",
  "scope": "agency",
  "label": "Pinnacle Insurance (all leads)",
  "available_events": [
    { "key": "lead.created", "description": "A new lead enters the CRM, from any source" }
  ]
}

label is a display string naming the agency and the key's scope. It exists so a person holding several keys can tell two connections apart in a UI.

GET /api/zapier/triggers

Returns a small sample of recent records for one event type, so a form builder can offer real field names before any event has fired.

ParameterRequiredNotes
eventyesOne of the event names below.
curl "https://app.virtualcloser.com/api/zapier/triggers?event=lead.created" \
  -H "X-API-Key: $VC_API_KEY"

Returns a bare JSON array, newest first — not an object wrapping one. When the agency has no matching records yet, a single representative example is returned instead, so the shape is never empty and a field picker never comes up blank.

POST /api/zapier/subscribe

Registers a URL to receive one event type. This is the REST-hook subscribe call.

curl -X POST https://app.virtualcloser.com/api/zapier/subscribe \
  -H "X-API-Key: $VC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event":"lead.created","target_url":"https://example.com/hook"}'
{ "id": "3b7e9d14-6c22-4f05-8a9b-1d4e7c2a5f80" }

Keep the id. It is the only way to unsubscribe. Subscribe once per event type you want; subscribing to the same event twice with the same URL delivers twice.

target_url must be HTTPS.

DELETE /api/zapier/subscribe

Stops delivery.

curl -X DELETE "https://app.virtualcloser.com/api/zapier/subscribe?id=3b7e9d14-6c22-4f05-8a9b-1d4e7c2a5f80" \
  -H "X-API-Key: $VC_API_KEY"
{ "success": true }

Deleting a subscription that is not yours, or does not exist, reports success rather than confirming an id is real for someone else's agency.

Events

Six events. Each is delivered as an HTTP POST to your subscribed URL, as it happens — this is a push API, not a polling one, so there is no interval to tune and no delay to explain to a customer.

EventFires when
lead.createdA new lead enters the CRM.
lead.disposition_changedA lead's disposition changes.
appointment.bookedAn appointment is booked.
appointment.cancelledA booked appointment is cancelled.
appointment.no_showAn appointment is marked a no-show.
client.createdA lead converts to a client.

Envelope

Every delivery has the same outer shape:

{
  "event": "lead.created",
  "occurred_at": "2026-08-06T14:32:00.000Z",
  "data": { }
}

Lead payload

Sent for lead.created and lead.disposition_changed. The latter also carries previous_disposition.

{
  "id": "9f1c5a2e-7b3d-4c8a-9e11-2f6d8b4a0c73",
  "first_name": "Jane",
  "last_name": "Doe",
  "phone": "+15551234567",
  "email": "jane@example.com",
  "state": "TX",
  "zip": "77002",
  "source": "Facebook Lead Form",
  "disposition": "interested",
  "created_at": "2026-08-06T14:32:00.000Z"
}

Phone numbers are E.164. disposition and source are free text the agency controls, so match them loosely rather than against a fixed list.

Appointment payload

Sent for the three appointment.* events, with the lead nested inside.

{
  "id": "3b7e9d14-6c22-4f05-8a9b-1d4e7c2a5f80",
  "starts_at": "2026-08-07T19:00:00.000Z",
  "duration_minutes": 30,
  "meeting_type": "zoom",
  "join_url": "https://zoom.us/j/98765432100",
  "title": "Mortgage Protection Review",
  "status": "scheduled",
  "lead": { }
}

starts_at is always UTC. join_url is the video link when there is one and the meeting's location otherwise, so it is not always a URL and may be null.

Delivery, retries and failure

We POST to your URL and wait up to 10 seconds for a response. Return a 2xx as soon as you have accepted the payload and do your own work afterwards — a slow endpoint reads to us as a broken one.

  • Any non-2xx counts as a failure and increments a counter on that subscription.
  • Five consecutive failures disables the subscription. Deliveries stop until you subscribe again. A single success resets the counter to zero.
  • 410 Gone disables it immediately, no counter involved. Return 410 when the receiving Zap or workflow has been deleted — it is the polite way to say "stop, permanently".

We do not retry a failed delivery. An event is emitted once, and delivery is best-effort by design: a customer's misconfigured endpoint must never be able to fail the booking, the import or the disposition change that produced the event. If you need guaranteed processing, treat these as notifications and reconcile against your own state.

Events are not queued while a subscription is disabled. Re-subscribing starts delivery from that moment forward; it does not replay what you missed.

Ownership and personal subscriptions

A subscription made with a personal key receives an event only when its owner is the lead's assigned rep, setter or closer. All three are checked, not just the primary assignment — a setter who booked an appointment still hears about it after the lead moves to a closer.

Sending leads in

The complement to the events above: an endpoint that creates a lead. Use it to post leads from a vendor, a form, or your own system.

POST /api/webhooks/leads

curl -X POST https://app.virtualcloser.com/api/webhooks/leads \
  -H "Content-Type: application/json" \
  -d '{
    "first_name": "Jane",
    "last_name": "Doe",
    "phone": "5551234567",
    "email": "jane@example.com",
    "state": "TX",
    "zip": "77002",
    "source": "My Landing Page"
  }'

Only a name and a phone number are strictly required; everything else improves routing. The state is inferred from the ZIP, and the ZIP from the area code, when they are missing.

A lead created this way emits lead.created, so a Zap can react to your own intake. Bulk CSV imports deliberately do not emit it — importing forty thousand rows from a previous CRM is a migration, not forty thousand new leads, and firing on each one would exhaust a customer's automation quota on their first afternoon and message people who are not new.

Rate limits

There is no published request quota. We would rather talk to you than throttle you: if you are planning something high-volume, email support@virtualcloser.com and we will tell you honestly whether it is a problem. Sustained abusive traffic is handled by revoking the key, which is why keys are cheap to create and to replace.

Using Zapier instead

Everything above is what our Zapier integration is built on. If Zapier fits your workflow, you do not need to write any of this code — connect Virtual Closer in Zapier, paste an API key, and the six events become triggers you can wire to eight thousand other applications.

Support

Questions, a payload that does not look like this page says it should, or an endpoint you wish existed: support@virtualcloser.com. We read it, and a report that this documentation is wrong is treated as a bug in the documentation.