CRM Custom API
Any CRM, through Zapier, Make, n8n or your own endpoint
The CRM Integration connects Salesforce, HubSpot, Zoho, Pipedrive and Zendesk natively. The Custom API provider opens the same integration to everything else: you give it one HTTPS address, and every event the integration produces (a logged call, a note, a new contact, a request for your contact list) is delivered there as a signed JSON POST. Point that address at a Zapier, Make or n8n webhook and the workflow you build in their editor writes to whatever CRM, help desk or spreadsheet they connect to; point it at an API you already run and your own code is the CRM. Contacts flow the other way through a push endpoint on our side, so your CRM's contacts reach desk phones, the customer portal, caller ID and the AI agents exactly as a native provider's do.
Setup is on the CRM Integration page: choose Custom API as the provider, enter the endpoint URL and click Connect. The connected card shows the secret the platform uses on every request, the contact push address, and a Send Test Event button that delivers a sample event and shows exactly what your endpoint answered. This page is the contract your developers, or your workflow, build against.
How It Works
- Every operation is one
POSTto your endpoint URL with a JSON envelope. Theeventfield says which operation it is, so one endpoint (or one workflow with a router step) serves them all. - Each request carries the secret as a bearer token and a signature of the body, so your endpoint can reject anything that is not from Cloudspire.
- Your endpoint answers with JSON, or with nothing at all. Any
2xxstatus is an acknowledgement. If the reply carries an id (an activity id, a contact id) the platform records it; if it does not, the event still counts as delivered. A Zapier catch hook answers a fixed success before the Zap runs, and that is fine. - A failure (a timeout, a
4xxor5xx, no response, or a2xxreply that itself says"success": false) is retried by the call logging queue, up to five times, a few minutes apart. - Contacts come to Cloudspire through the push endpoint below. The Sync Now button on the card asks your endpoint for its contact list instead (the
contacts.listevent); use it when your endpoint is an API that can answer with data, not a catch hook.
Request Envelope
Every request is POST with Content-Type: application/json and this shape:
{
"event": "call.logged",
"sent_at": "2026-09-19T14:30:05-04:00",
"data": { ... }
}
| Header | Description |
|---|---|
Authorization | Bearer {secret}: the secret shown on the connected CRM card. Compare it with the value you copied and refuse the request when it differs. |
X-Cloudspire-Event | The same value as the body's event field, so a router can branch without parsing the body. |
X-Cloudspire-Signature | Lowercase hex HMAC-SHA256 of the raw request body, keyed with the secret. Verify it to prove the body was not altered in transit (see Verifying the Signature). The bearer token alone is enough for a workflow platform; verify the signature when your own code is the endpoint. |
sent_at is ISO 8601 with the platform's timezone offset. There is no tenant field: the endpoint is configured per phone system, so everything that arrives at it belongs to that one account.
Events
call.logged
Sent a few minutes after every answered external call, inbound or outbound. Unlike the native providers, which log only calls that match a synced contact, the Custom API provider receives every answered external call and leaves the matching to you; when a synced contact did match, its id rides along.
{
"event": "call.logged",
"sent_at": "2026-09-19T14:30:05-04:00",
"data": {
"call_id": "sip1-atl.example-1789830000.1234",
"direction": "inbound",
"from_number": "+14045551234",
"from_name": "Jane Smith",
"to_number": "+14045550100",
"started_at": "2026-09-19 14:26:11",
"duration_seconds": 214,
"disposition": "ANSWERED",
"summary": "Summary: Caller asked about ...",
"contact_id": "crm-12345"
}
}
| Field | Description |
|---|---|
call_id | The call's identifier, the same value that appears in your call records. Treat it as an opaque string and use it to deduplicate: a retried delivery carries the same id. |
direction | inbound or outbound. Internal extension-to-extension calls are never sent. |
from_number / to_number | The parties in E.164 where known. On an inbound call the caller and the number they dialed; on an outbound call the extension's caller ID and the number dialed. |
from_name | The caller's name as the phone system knew it: the caller ID name, or the matched contact's name when caller ID carried none. May be empty. |
started_at | When the call started, as YYYY-MM-DD HH:MM:SS in the phone system's timezone (whole seconds, no offset). |
duration_seconds | Talk time in seconds. |
disposition | The call outcome as recorded by the phone system, for example ANSWERED. |
summary | The AI call summary when call transcription with CRM notes is on for an extension that handled the call, else empty. |
contact_id | The external_id of the synced contact whose phone number matched, or null when none matched. |
Reply: optionally {"activity_id": "..."}; the id is shown on the card's call activity log. Any other 2xx reply is an acknowledgement.
contact.note
Sent when an AI agent saves a note to the customer's record with its CRM Note tool, and when the post-call analysis of an AI conversation is logged to the CRM. The note is free text; the contact id is the one the agent looked up or the one that matched the caller.
{
"event": "contact.note",
"sent_at": "2026-09-19T14:31:40-04:00",
"data": {
"contact_id": "crm-12345",
"note": "Caller confirmed the appointment for Tuesday at 10."
}
}
Reply: optionally {"activity_id": "..."}.
contact.create
Sent when an AI agent creates a record for a caller who had none (its CRM Create Contact tool). Fields the agent did not collect are empty strings.
{
"event": "contact.create",
"sent_at": "2026-09-19T14:32:02-04:00",
"data": {
"first_name": "Jane",
"last_name": "Smith",
"phone": "+14045551234",
"email": "[email protected]",
"company": "Acme"
}
}
Reply: {"contact_id": "..."} when your system assigned one. With an id, the agent tells the caller the record was created and the contact is added to the synced list at once; without one, the agent says the record was sent, and the contact appears once your workflow pushes it back (below).
contacts.list
Sent only when an administrator clicks Sync Now on the card. Your endpoint may answer with a page of contacts; a catch hook cannot, and the card says so.
{
"event": "contacts.list",
"sent_at": "2026-09-19T14:35:00-04:00",
"data": { "cursor": null, "limit": 100 }
}
Reply: {"contacts": [ ... ], "cursor": "next-page", "has_more": true}, each contact in the shape the push endpoint accepts. Return the same cursor you received, or omit it, to end the walk. An acknowledgement with no contacts array is read as zero contacts.
Pushing Contacts to Cloudspire
This is how your CRM's contacts reach desk phones, the customer portal, caller ID and the AI agents' CRM lookup. Your workflow calls it whenever a contact is created or changed in your CRM (a Zapier New or Updated Contact trigger, a Make or n8n schedule, or your own code), with the same secret as the bearer token.
POST https://api.cloudspirevoice.com/crm/contacts.php
Authorization: Bearer {secret}
Content-Type: application/json
{
"contacts": [
{
"external_id": "crm-12345",
"first_name": "Jane",
"last_name": "Smith",
"company": "Acme",
"phone_work": "+14045551234",
"phone_mobile": "+14045559876",
"email": "[email protected]",
"external_url": "https://crm.example.com/contacts/12345"
}
]
}
| Field | Description |
|---|---|
external_id | Required. Your CRM's stable id for the contact (up to 255 characters). It is the key: a second push with the same id updates the contact, and it is the contact_id you receive on call.logged when the contact's number matches. |
first_name, last_name | Up to 100 characters each. |
company, email | Up to 150 characters each. |
phone_work, phone_mobile | Any common format; numbers are normalized to E.164. A number that cannot be normalized is stored empty. |
external_url | An http or https link to the record in your CRM; it becomes the screen-pop link on the contact. Up to 500 characters. |
Send the full record each time: a field left out is stored as empty, so your CRM remains the source of truth. Up to 500 contacts per request and 1 MB per body. The reply reports what happened:
{"ok": true, "inserted": 1, "updated": 0, "capped": 0, "rejected": 0, "total_contacts": 318, "contact_limit": 500}
capped counts new contacts not added because the account's contact limit was reached (existing contacts are always updated); rejected counts records with no external_id. A 401 means the secret is wrong or the Custom API CRM is not connected; a 403 means the CRM integration is no longer on the plan; a 409 means another push for this connection is still being stored, so retry after a few seconds (pushes for one connection are stored one at a time). Contacts pushed this way are removed when the CRM is disconnected, exactly like synced contacts.
Verifying the Signature
Compute HMAC-SHA256 over the raw request body (the exact bytes, before any JSON parsing) with the secret as the key, encode it as lowercase hex, and compare it with X-Cloudspire-Signature using a constant-time comparison.
// Node.js
const crypto = require('crypto');
const expected = crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.headers['x-cloudspire-signature'] || ''));
# Python
import hmac, hashlib
expected = hmac.new(SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
ok = hmac.compare_digest(expected, request.headers.get('X-Cloudspire-Signature', ''))
Worked Example: Any CRM via Zapier
A Zap with a Webhooks by Zapier: Catch Hook trigger, a Paths step that branches on event, and your CRM's own actions on each path:
| Endpoint URL | The catch hook URL Zapier shows when you add the trigger. Paste it into the Custom API provider's Endpoint URL box and click Connect. |
| Path: call.logged | Your CRM's Find Contact by from_number (or to_number on an outbound call), then Create Call or Create Activity with the duration, direction and summary. |
| Path: contact.note | Your CRM's Create Note on the contact whose id is contact_id. |
| Path: contact.create | Your CRM's Create Contact from the name, phone, email and company. |
| A second Zap | Trigger: your CRM's New or Updated Contact. Action: Webhooks by Zapier: Custom Request, POST to the contact push address with Authorization: Bearer {secret} and a body carrying the contact in the shape above. This is what puts your contacts on the phones and in front of the AI. |
Make and n8n follow the same pattern with a Webhooks or Webhook trigger, a Router or Switch on event, and an HTTP module for the push. Because a catch hook acknowledges before the workflow runs, activity_id and contact_id replies are not available from Zapier; Make (a Webhook response module) and n8n (a Respond to Webhook node) can return them.
Click Send Test Event on the connected card at any point: it delivers a call.logged with sample data and "test": true, and the card shows the HTTP status and reply your endpoint gave, so a workflow can be built and tested before a real call ever arrives.