CRM Integration
Dialspace is a dialler, not a CRM. Your CRM stays the source of truth: you push leads in, we stream live context onto the agent's screen at call time (never stored), and every call outcome flows back to you. Three integration tiers — each one optional beyond the first.
1. Lead intake webhook (Tier 1)
Send each new lead to Dialspace as it's created in your CRM. Authenticate with the company API key (Settings → CRM Integration). Include your own external_id — it links context requests and write-back events to your record. Re-sending the same external_id updates the lead rather than duplicating it.
Already posting to the old URL? The previous app.conversionly.co.uk endpoint is permanent infrastructure, not a deprecation: it serves this same API directly, indefinitely. You never need to update a working integration. The same applies to the X-Conversionly-Signature / X-Conversionly-Timestamp header names — they are stable identifiers and will not be renamed under you.
campaign is optional — and usually better left out. Campaign assignment is normally configured inside Dialspace under Settings → Lead Routing, where managers write rules against the lead's attributes. Send rich custom_fields (source, product, budget, region…) precisely so those rules have something to route on — everything you send is also shown to the agent on the lead card. If you do send a campaign, it must already exist: an unrecognised campaign name no longer creates a campaign. The lead is still accepted, routed by rules (then the company default), and the response carries a warning so your logs show the mismatch.
POST https://app.dialspace.io/api/dialer/leads/intake
Authorization: Bearer <your API key>
Content-Type: application/json
{
"name": "Jane Smith",
"phone": "07700 900123", // normalised to E.164, UK default
"email": "jane@example.com", // optional
"external_id": "crm-lead-42", // your CRM's id — strongly recommended
"priority": 80, // 0-100, optional (default 50)
"campaign": "July Web Leads", // OPTIONAL — must already exist; prefer routing rules
"custom_fields": { // shown to the agent AND used by routing rules
"source": "Facebook Ads",
"product": "Conservatory",
"budget": "£8,000–£12,000"
}
}
// 201 → { "success": true, "leadId": "<uuid>",
// "campaign": { "id": "...", "name": "July Web Leads" },
// "routed_by": "explicit" } // or "rule" | "default" | "unassigned"
// 200 → same shape with "updated": true when external_id matched
// Unknown campaign name → lead still accepted, plus:
// "warning": "Campaign 'X' not found — routed by rules instead"
// If nothing routes it, "campaign" is null and "routed_by" is
// "unassigned" — the lead is visible to managers, never dropped.
// A number on the company's Do Not Call list is still accepted but
// created suppressed: { ..., "suppressed": true }2. Live context endpoint (Tier 2)
When an agent is handed a lead, Dialspace POSTs to your configured context URL and renders whatever you return directly on the agent's lead card. Nothing you return is stored — it's fetched fresh per call and discarded. Your endpoint has a hard 3-second timeout; if it doesn't answer in time the agent simply dials without context, so aim for well under one second.
Request we send you:
POST <your context URL>
Content-Type: application/json
X-Conversionly-Timestamp: 1782476400
X-Conversionly-Signature: <hex HMAC-SHA256, see §5>
{
"event": "lead.context",
"external_id": "crm-lead-42", // the id you sent at intake (may be null)
"phone": "+447700900123",
"campaign": { "id": "<uuid>", "name": "July Web Leads" },
"agent": { "id": "<uuid>", "name": "Sarah Jones" }
}Response you return — every field is optional; return {} if you have nothing:
{
// A single headline the agent reads first
"banner": {
"label": "Appointment already booked — confirm only",
"detail": "Thursday 14 May, 2:00pm",
"tone": "warning" // "info" | "warning" | "success" | "danger"
},
// Grouped label/value pairs for the Details tab
"fields": [
{
"group": "Enquiry",
"items": [
{ "label": "Product", "value": "Conservatory" },
{ "label": "Source", "value": "Facebook Ads" }
]
}
],
// Conversation history for the Timeline tab (merged with our call log)
"timeline": [
{
"type": "sms", // "sms" | "email" | "call" | "note" | "system"
"direction": "inbound", // "inbound" | "outbound" | null
"sender": "AI Assistant",
"subject": null, // emails only
"body": "Hi, are you free Thursday?",
"timestamp": "2026-07-28T14:22:00Z"
}
],
// Pick the agent's script: EITHER reference one by key...
"script_key": "confirm-appointment",
// ...or send a full script inline (takes precedence over script_key)
"script_inline": {
"name": "Confirmation Call",
"sections": [
{ "type": "opening", "title": "Opening", "body": "Hi {name}...",
"trigger_label": null },
{ "type": "objection", "title": "Wrong time",
"trigger_label": "Can't make that time", "body": "No problem..." }
]
},
// Manager-facing views only (see note below)
"links": [
{ "label": "Open in CRM", "url": "https://your-crm.com/leads/42" }
]
}- Unrecognised
timeline.typevalues render asnote. - Unknown top-level keys are ignored — you can safely add your own.
- Malformed entries are dropped individually; the rest of the payload still renders.
- Script precedence:
script_inline, thenscript_key, then the campaign's default script. linksrender in manager-facing views only (the lead drawer on Leads and the call detail panel) — never in the agent dialler, which is deliberately kept free of ways out of the call.
3. Embedded panel (Tier 3)
Configure an https embed URL and Dialspace adds a tab to the agent's lead card rendering it in a sandboxed iframe (allow-scripts allow-same-origin allow-forms). The lead's external_id is appended:
https://your-crm.com/dialspace/panel?external_id=crm-lead-42
Your page must not send X-Frame-Options: DENY or a blocking frame-ancestors CSP.
4. Write-back events
Configure a webhook URL and Dialspace POSTs these events, each signed with your webhook secret in X-Conversionly-Signature with X-Conversionly-Timestamp — the same scheme as context requests, see §5. Failed deliveries retry once after 30 seconds.
// After every dispositioned call
{
"event": "call.dispositioned",
"lead": { "external_id": "crm-lead-42", "name": "Jane Smith", "phone": "+447700900123" },
"campaign": { "id": "<uuid>", "name": "July Web Leads" },
"call": {
"disposition": "Appointment Booked",
"disposition_action": "converted", // retry|complete|dead|callback|converted|dnc
"notes": "Confirmed for Thursday",
"connected": true,
"talk_seconds": 184,
"attempt_number": 2,
// A time-limited (7 days) link to the call recording — fetch and
// store it your side if you need it longer. Null when recording is
// disabled for the company or the recording isn't ready yet.
"recording_url": "https://.../call-recordings/RE123...mp3?token=...",
"recording_sid": "RE1234567890abcdef",
// True when the called party asked for recording to stop mid-call
"recording_stopped_by_request": false,
"agent_name": "Sarah Jones",
"timestamp": "2026-07-30T10:15:00Z"
},
"callback_at": null,
"do_not_call": false,
// Present ONLY when the agent changed lead details during this call —
// corrected phone numbers, spellings, or fields collected on the call
// (addresses, dates of birth, whatever your company defines). Treat
// these as authoritative corrections and apply them to your record.
// Keys are 'name', 'phone', 'email' or your custom field keys. Omitted
// entirely when nothing changed.
"lead_updates": {
"phone": "+447700900456",
"house_no": "28",
"road_name": "Wren Crescent",
"postcode": "BH12 1DP"
},
// Present ONLY when the disposition captured a conversion. Branch on
// conversion.type — the three shapes below are the complete set.
"conversion": {
"type": "appointment",
"appointment_at": "2026-08-04T14:00:00Z",
"duration_minutes": 60,
"assigned_to": "Dave Sullivan",
"location": "28 Wren Crescent, Poole, BH12 1DP",
"notes": "..."
}
}
// The other two conversion shapes:
"conversion": {
"type": "sale",
"value": 4200,
"currency": "GBP",
"product": "Windows & Doors",
"quantity": 8,
"notes": "..."
}
"conversion": {
"type": "custom",
"fields": { "survey_size": "4kW" },
"notes": "..."
}
// When a callback is scheduled
{
"event": "lead.callback_scheduled",
"lead": { "external_id": "crm-lead-42", "name": "Jane Smith", "phone": "+447700900123" },
"campaign": { "id": "<uuid>", "name": "July Web Leads" },
"callback_at": "2026-07-31T10:00:00Z",
"agent": { "id": "<uuid>", "name": "Sarah Jones" }, // null if unassigned
"timestamp": "2026-07-30T10:15:00Z"
}
// When a number is added to the Do Not Call list — honour this in your CRM
{
"event": "lead.dnc",
"phone": "+447700900123",
"lead": { "external_id": "crm-lead-42", ... }, // when known
"reason": "Asked not to be contacted",
"timestamp": "2026-07-30T10:15:00Z"
}5. Verifying signatures
One scheme covers both directions. Every request Dialspace sends you — inbound context fetches and outbound write-back webhooks alike — carries both X-Conversionly-Signature and X-Conversionly-Timestamp, where the signature is HMAC-SHA256 over `${timestamp}.${rawBody}`. The only difference is which secret signs it: context requests use your context secret, write-back events your webhook secret. Verify the signature and reject stale timestamps (±5 minutes) to prevent replay — the same verifier works for both.
Node.js
const crypto = require("crypto");
// Works for context requests AND write-back webhooks — pass the
// matching secret (context secret or webhook secret).
function verifyConversionlyRequest(req, rawBody, secret) {
const signature = req.headers["x-conversionly-signature"];
const timestamp = req.headers["x-conversionly-timestamp"];
// Reject replays
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature || "", "hex"),
Buffer.from(expected, "hex")
);
}Python
import hashlib, hmac, time
# Works for context requests AND write-back webhooks — pass the
# matching secret (context secret or webhook secret).
def verify_conversionly_request(headers, raw_body: bytes, secret: str) -> bool:
signature = headers.get("X-Conversionly-Signature", "")
timestamp = headers.get("X-Conversionly-Timestamp", "0")
# Reject replays
if abs(time.time() - int(timestamp)) > 300:
return False
signed_payload = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)