API Reference
SurelyCrm exposes a set of JSON endpoints for programmatic integration. Use these APIs to create customers, search records, manage workflows, and interact with the telephony system from your own applications.
Authentication Required: Most endpoints require an active session cookie (standard browser login). Endpoints under /Api/ use API key authentication. Administrative endpoints require the Administrator role.
Command Line Tool: You can also interact with these endpoints using the SurelyCrm CLI. Install it with npm install -g @surelycrm/cli.
Authentication
API Key Authentication
Endpoints under /Api/ accept a GUID API key passed in the request header:
apiKey: {your-guid-api-key}
Administrators can view or regenerate the API key in Settings > Application Settings. Keep this key secure — treat it like a password. If compromised, regenerate it immediately.
Session Authentication
All other endpoints use standard ASP.NET Core cookie authentication. Log in via the web interface and use the resulting session cookie for subsequent API requests.
Customers API
Create Customer
Creates a new customer record in the system.
| Property | Value |
|---|---|
| Endpoint | POST /Api/Create |
| Authentication | API Key (apiKey header) |
| Content-Type | application/json |
Required fields: Only title, firstname, surname and referenceNumber are required. All other fields are optional.
Request Body
{
"title": "Mr",
"firstname": "John",
"surname": "Davies",
"referenceNumber": "optional-override",
"address1": "123 High Street",
"address2": "",
"address3": "",
"address4": "",
"town": "Manchester",
"city": "Greater Manchester",
"postcode": "M1 1AA",
"homePhone": "01611234567",
"mobilePhone": "07700900123",
"emailAddress": "john.davies@example.com",
"notificationsEnabled": true,
"importantDate": "2025-06-15T00:00:00",
"dateOfBirth": "1985-03-20T00:00:00",
"website": "https://example.com",
"statusId": "00000000-0000-0000-0000-000000000000",
"ownerId": "00000000-0000-0000-0000-000000000000",
"referredBy": "Google Ads",
"customFields": {
"ContractType": "Premium",
"Priority": "High"
}
}
Field Reference
| Field | Type | Required | Description |
|---|---|---|---|
title | string | Yes | Mr, Mrs, Miss, or Ms |
firstname | string | Yes | First name |
surname | string | Yes | Surname |
referenceNumber | string | Yes | Customer reference number |
address1 | string | No | First line of address |
address2 | string | No | Second line of address |
address3 | string | No | Third line of address |
address4 | string | No | Fourth line of address |
town | string | No | Town |
city | string | No | City |
postcode | string | No | Postcode |
homePhone | string | No | Home telephone number |
mobilePhone | string | No | Mobile telephone number |
emailAddress | string | No | Email address |
notificationsEnabled | boolean | No | Consent to marketing communications |
importantDate | datetime | No | Key date for the customer |
dateOfBirth | datetime | No | Date of birth |
website | string | No | Website URL |
statusId | guid | No | Status GUID. Omit for default. |
ownerId | guid | No | Assigned user GUID. Omit for unassigned. |
referredBy | string | No | Referral source |
customFields | object | No | Key-value pairs of custom field data |
Response
200 OK — Customer created successfully. Returns the new customer's GUID:
"550e8400-e29b-41d4-a716-446655440000"
400 Bad Request — Validation failed or customer could not be created. Returns error details:
{
"Title": ["The Title field is required."],
"Firstname": ["The Firstname field is required."],
"Surname": ["The Surname field is required."],
"ReferenceNumber": ["The ReferenceNumber field is required."]
}
401 Unauthorized — Missing or invalid API key.
Get Customer by GUID
Retrieves a single customer record by its unique identifier.
| Property | Value |
|---|---|
| Endpoint | GET /Api/customer/{id} |
| Authentication | API Key (apiKey header) |
Response
200 OK — Returns the customer record:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"referenceNumber": "1234567890",
"title": "Mr",
"firstname": "John",
"surname": "Davies",
"address1": "123 High Street",
"address2": "",
"address3": "",
"address4": "",
"town": "Manchester",
"city": "Greater Manchester",
"postcode": "M1 1AA",
"homePhone": "01611234567",
"mobilePhone": "07700900123",
"emailAddress": "john.davies@example.com",
"notificationsEnabled": true,
"importantDate": "2025-06-15T00:00:00",
"dateOfBirth": "1985-03-20T00:00:00",
"website": "https://example.com",
"age": 40,
"statusId": "00000000-0000-0000-0000-000000000000",
"ownerId": "00000000-0000-0000-0000-000000000000",
"referredBy": "Google Ads",
"customFields": {
"ContractType": "Premium",
"Priority": "High"
}
}
401 Unauthorized — Missing or invalid API key.
404 Not Found — Customer with the specified GUID does not exist.
Get Customer by Reference Number
Retrieves a single customer record by its reference number.
| Property | Value |
|---|---|
| Endpoint | GET /Api/customer/reference/{referenceNumber} |
| Authentication | API Key (apiKey header) |
Response
200 OK — Returns the customer record (same shape as Get Customer by GUID).
400 Bad Request — Reference number is missing.
401 Unauthorized — Missing or invalid API key.
404 Not Found — Customer with the specified reference number does not exist.
Get Customer by Email Address
Retrieves a single customer record by email address.
| Property | Value |
|---|---|
| Endpoint | GET /Api/customer/byEmail/{emailAddress} |
| Authentication | API Key (apiKey header) |
Response
200 OK — Returns the customer record (same shape as Get Customer by GUID).
400 Bad Request — Email address is missing.
401 Unauthorized — Missing or invalid API key.
404 Not Found — Customer with the specified email address does not exist.
Update Customer
Updates an existing customer record. The customer ID in the request body must match the ID in the route.
| Property | Value |
|---|---|
| Endpoint | PUT /Api/customer/{id} |
| Authentication | API Key (apiKey header) |
| Content-Type | application/json |
Required fields: Only title, firstname, surname and referenceNumber are required. All other fields are optional.
Request Body
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Mr",
"firstname": "John",
"surname": "Davies",
"referenceNumber": "1234567890",
"address1": "123 High Street",
"address2": "",
"address3": "",
"address4": "",
"town": "Manchester",
"city": "Greater Manchester",
"postcode": "M1 1AA",
"homePhone": "01611234567",
"mobilePhone": "07700900123",
"emailAddress": "john.davies@example.com",
"notificationsEnabled": true,
"importantDate": "2025-06-15T00:00:00",
"dateOfBirth": "1985-03-20T00:00:00",
"website": "https://example.com",
"statusId": "00000000-0000-0000-0000-000000000000",
"ownerId": "00000000-0000-0000-0000-000000000000",
"referredBy": "Google Ads",
"customFields": {
"ContractType": "Premium",
"Priority": "High"
}
}
Response
200 OK — Customer updated successfully.
400 Bad Request — Validation failed, IDs do not match, or update could not be completed.
401 Unauthorized — Missing or invalid API key.
404 Not Found — Customer with the specified GUID does not exist.
Change Account Status
Changes the status of a customer account.
| Property | Value |
|---|---|
| Endpoint | POST /Api/customer/{customerId}/status |
| Authentication | API Key (apiKey header) |
| Content-Type | application/json |
Request Body
{
"statusId": "550e8400-e29b-41d4-a716-446655440000"
}
Response
200 OK — Returns true when the status was changed successfully.
400 Bad Request — Invalid request or status could not be changed.
401 Unauthorized — Missing or invalid API key.
404 Not Found — Customer or status record not found.
Start Workflow for Customer
Starts a workflow instance for a customer. Provide either the workflow GUID or the workflow name; one is required.
| Property | Value |
|---|---|
| Endpoint | POST /Api/customer/{customerId}/workflow |
| Authentication | API Key (apiKey header) |
| Content-Type | application/json |
Request Body
{
"workflowId": "550e8400-e29b-41d4-a716-446655440000",
"workflowName": "Welcome Sequence",
"contextData": "{}"
}
Field Reference
| Field | Type | Required | Description |
|---|---|---|---|
workflowId | guid | One of workflowId or workflowName | GUID of the active workflow to start |
workflowName | string | One of workflowId or workflowName | Name of the active workflow to start |
contextData | string | No | JSON context data for the workflow instance. Defaults to "{}". |
Response
200 OK — Workflow started successfully:
{
"success": true,
"instanceId": "550e8400-e29b-41d4-a716-446655440001"
}
400 Bad Request — Neither workflow ID nor name was provided, the workflow is not active, or the workflow could not be started.
Get Support Ticket by Id
Retrieves a single support ticket, including its messages.
| Property | Value |
|---|---|
| Endpoint | GET /Api/support/tickets/{id} |
| Authentication | API Key (apiKey header) |
Response
200 OK — Returns the support ticket record with messages:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"customerId": "550e8400-e29b-41d4-a716-446655440001",
"customerName": "John Davies",
"customerEmail": "john@example.com",
"subject": "Login issue",
"referenceNumber": "SR-001",
"status": "Open",
"priority": "Normal",
"categoryName": "Technical Support",
"assignedToName": "Jane Smith",
"messageCount": 3,
"unreadMessageCount": 1,
"lastMessageDate": "2025-06-15T14:30:00",
"createDate": "2025-06-15T10:00:00",
"messages": [
{
"id": "550e8400-e29b-41d4-a716-446655440002",
"supportRequestId": "550e8400-e29b-41d4-a716-446655440000",
"message": "I cannot log in.",
"isFromCustomer": true,
"isInternal": false,
"sentByName": "John Davies",
"readAt": null,
"createDate": "2025-06-15T10:00:00"
}
]
}
401 Unauthorized — Missing or invalid API key.
404 Not Found — Support ticket not found.
Get Support Tickets
Returns support tickets. Use global list filters (newTickets or recentlyReplied) for open/recently-active tickets, or customer filters (customerId or customerEmailAddress) to list all tickets for a specific customer. Customer filters and list filters are mutually exclusive.
| Property | Value |
|---|---|
| Endpoint | GET /Api/support/tickets |
| Authentication | API Key (apiKey header) |
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
newTickets | boolean | One mode selector only | Return open tickets ordered by creation date |
recentlyReplied | boolean | One mode selector only | Return tickets with messages in the recent period |
customerId | guid | One mode selector only | Return all tickets for the customer with this GUID |
customerEmailAddress | string | One mode selector only | Return all tickets for the customer with this email address |
hours | integer | No | Number of hours to look back when recentlyReplied is true. Defaults to 24. |
page | integer | No | Page number. Defaults to 1. |
pageSize | integer | No | Page size. Defaults to 25. |
Response
200 OK — Returns an array of support ticket records:
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"customerId": "550e8400-e29b-41d4-a716-446655440001",
"customerName": "John Davies",
"customerEmail": "john@example.com",
"subject": "Login issue",
"referenceNumber": "SR-001",
"status": "Open",
"priority": "Normal",
"categoryName": "Technical Support",
"assignedToName": "Jane Smith",
"messageCount": 3,
"unreadMessageCount": 1,
"lastMessageDate": "2025-06-15T14:30:00",
"createDate": "2025-06-15T10:00:00"
}
]
400 Bad Request — Missing selector, conflicting filters, or customer not found.
401 Unauthorized — Missing or invalid API key.
Reply to Support Ticket
Adds a staff reply to an existing support ticket. Optionally closes the ticket after the reply.
| Property | Value |
|---|---|
| Endpoint | POST /Api/support/tickets/{id}/reply |
| Authentication | API Key (apiKey header) |
| Content-Type | application/json |
Request Body
{
"message": "Thanks for getting in touch. We are looking into this now.",
"isInternal": false,
"closeAfterReply": false
}
Field Reference
| Field | Type | Required | Description |
|---|---|---|---|
message | string | Yes | The reply text |
isInternal | boolean | No | Whether the reply is internal-only. Defaults to false. |
closeAfterReply | boolean | No | Close the ticket after posting the reply. Defaults to false. |
Response
200 OK — Reply posted successfully:
{
"success": true
}
400 Bad Request — Missing message or reply could not be posted.
401 Unauthorized — Missing or invalid API key.
404 Not Found — Support ticket not found.
Get Customers by Age
Returns all customers whose age matches the supplied comparison.
| Property | Value |
|---|---|
| Endpoint | GET /Api/customers/byAge?age={age}&comparison={comparison} |
| Authentication | API Key (apiKey header) |
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
age | integer | Yes | The age value to compare against |
comparison | string | No | One of equal, greater, less, greaterthanorequal, or lessthanorequal. Defaults to equal. |
Response
200 OK — Returns an array of matching customer records:
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"referenceNumber": "1234567890",
"title": "Mr",
"firstname": "John",
"surname": "Davies",
"age": 40,
"emailAddress": "john.davies@example.com",
"statusId": "00000000-0000-0000-0000-000000000000"
}
]
400 Bad Request — Invalid comparison value supplied.
401 Unauthorized — Missing or invalid API key.
Search Customer by Phone
Searches for customers by phone number across mobile and home phone fields. Automatically handles UK format variations.
| Property | Value |
|---|---|
| Endpoint | GET /Customer/SearchByPhone?phoneNumber={number} |
| Authentication | Session cookie |
Response
{
"success": true,
"customers": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"referenceNumber": "1234567890",
"title": "Mr",
"firstname": "John",
"surname": "Davies",
"dateOfBirth": "20/03/1985",
"address": "123 High Street, Manchester, M1 1AA",
"mobilePhone": "07700900123",
"homePhone": "01611234567",
"status": "Active"
}
]
}
Telephony API
Generate Twilio Token
Generates a Twilio access token for the authenticated agent to use the embedded dialer.
| Property | Value |
|---|---|
| Endpoint | GET /TwilioToken/Generate or POST /TwilioToken/Generate |
| Authentication | Session cookie |
Response
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
Change Agent Status
Updates the agent's availability status in the call queue.
| Property | Value |
|---|---|
| Endpoint | GET /TwilioAgent/ChangeStatus?status={Ready|Busy} |
| Authentication | Session cookie |
Workflow Definition API (API key)
Administrative endpoints for inspecting, replacing, and changing the lifecycle
of a tenant's workflow definitions. Authenticate with the tenant's
administrative API key in the apiKey header; a browser session
cookie is not accepted. The request host selects the tenant, so a key for a
different tenant is rejected. This API-key surface has no delete operation.
The key is tenant-wide, not a per-user credential or scoped token. Apply
authenticates before reading or validating the body. Use the intended HTTPS
host directly; these routes do not redirect HTTP requests to HTTPS.
Key-authenticated writes do not need a browser identity or an interactive tenant
user. Tenant startup ensures the tenant-local non-login system
actor for audit foreign keys; if attribution setup is unavailable, the write
returns a bounded server error without an empty audit reference.
Complete definitions are returned with Cache-Control: no-store
because they can contain sensitive action configuration. Handle exported
files privately, and avoid pasting them into shell history.
List Workflow Definitions
| Property | Value |
|---|---|
| Endpoint | GET /api/workflows |
| Authentication | apiKey header (tenant API key) |
| Query | page (default 1),
pageSize (default 50, 1-100) |
Returns definitions ordered by name then id, including inactive ones.
hasMore reports whether another page is available. A tenant
without workflows returns items: [] with a 200.
Supplier workflows are reported as the canonical Company record
type. Page numbers start at 1; the offset
(page - 1) * pageSize must not exceed 2,147,483,647. Invalid
numeric ranges return 400. Pages are separate reads, so names
or inserts changing between requests can shift results.
{
"items": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Preparation Wait",
"primaryEntityType": "Customer",
"status": "Active",
"isActive": true
}
],
"page": 1,
"pageSize": 50,
"hasMore": false
}
Get Workflow Definition
| Property | Value |
|---|---|
| Endpoint | GET /api/workflows/{id} |
| Authentication | apiKey header (tenant API key) |
Returns the complete editable definition plus read-only status,
isActive, and version. Graph targets use stage
GUIDs. Enum values are documented string names, including trigger operators.
Nested configuration strings and branch-rule order are preserved exactly.
Created-by users, instance counts, and trigger persistence IDs are omitted.
Legacy linear workflows keep their stored values; no End stage or explicit
next route is added. Metadata, stages, and trigger come from one consistent
database snapshot. Inspection does not repair invalid historical definitions
or include execution records.
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Preparation Wait",
"description": "Wait before continuing",
"primaryEntityType": "Customer",
"status": "Active",
"isActive": true,
"version": 3,
"configuration": "{\"canvas\":{\"zoom\":1}}",
"startStageId": "550e8400-e29b-41d4-a716-446655440010",
"stages": [
{
"id": "550e8400-e29b-41d4-a716-446655440010",
"name": "Wait one minute",
"description": "",
"order": 1,
"stageKind": "Wait",
"actionType": "Wait",
"actionConfiguration": "",
"delayDays": 0,
"delayHours": 0,
"delayMinutes": 1,
"conditionConfiguration": "",
"conditionMode": "Wait",
"elseStageId": null,
"branchConfiguration": "",
"nextStageId": null,
"failureMode": "Stop",
"failureStageId": null,
"positionX": 120,
"positionY": 80
}
],
"trigger": {
"entityName": "Customer",
"entityState": "Added",
"isActive": true,
"conditions": [
{ "propertyName": "Status", "expectedValue": "New", "operator": "Equals" }
]
}
}
Create or Update Workflow Definition
| Property | Value |
|---|---|
| Endpoint | POST /api/workflows/apply |
| Authentication | apiKey header (tenant API key) |
| Content-Type | application/json |
| Body limit | 1 MiB |
Applies a complete definition. An absent, null, or all-zero GUID
id creates a
new Draft, inactive workflow. A non-zero GUID id must
already exist in this tenant and updates that workflow, preserving its
lifecycle state and returning created: false. An unknown or
foreign-tenant id returns 404 and never creates a
workflow or matches by name. Read-only status,
isActive, and version values from a get result are
accepted but ignored, so supplied activation metadata never activates or
deactivates a workflow. Both stages and trigger
must be present; use [] and null to clear them. The
record type defaults to Customer on create or an empty inactive
update; include the exported type when reapplying. Company and the
Supplier alias are accepted; a locked workflow (one with stages
or active) keeps its stored type. Graph targets reference stage GUIDs;
supplied stage IDs are preserved, must not belong to another workflow, and
an unchanged stage keeps its identity. Apply is a full replacement: a
retained trigger keeps its identity and queue rows even when edited; its
conditions are replaced. Unreferenced stages and
a trigger without queued work are removed, a stage with execution history
cannot be removed (409), and a trigger with queued work cannot
be removed (409), including one referenced only by completed or
failed queue rows. Reapplying an unchanged get export preserves
the graph and any queued trigger work. The write is atomic: validation,
ownership, license, or persistence failures leave the previous definition
unchanged. Create requires the Workflow Builder licence; update retains the
existing browser-update behaviour without a new licence check.
The example uses illustrative stage IDs; choose fresh IDs when creating another workflow in the same tenant.
POST /api/workflows/apply
apiKey: 11111111-1111-1111-1111-111111111111
Content-Type: application/json
{
"name": "Preparation Wait",
"description": "Wait before continuing",
"primaryEntityType": "Customer",
"configuration": "{\"canvas\":{\"zoom\":1}}",
"startStageId": "550e8400-e29b-41d4-a716-446655440010",
"stages": [
{
"id": "550e8400-e29b-41d4-a716-446655440010",
"name": "Wait one minute",
"order": 1,
"stageKind": "Wait",
"actionType": "Wait",
"actionConfiguration": "",
"delayMinutes": 1,
"conditionMode": "Wait",
"failureMode": "Stop"
}
],
"trigger": {
"entityName": "Customer",
"entityState": "Added",
"isActive": true,
"conditions": [
{ "propertyName": "Status", "expectedValue": "New", "operator": "Equals" }
]
}
}
Response (201 when created, 200 when updated):
{
"id": "66f1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b",
"created": true,
"status": "Draft",
"isActive": false
}
An update returns created: false with the workflow's preserved
lifecycle state. Converge by editing a workflow get export and
posting it back with its id. Updates and lifecycle changes lock
and reload the same workflow row. The last successful serialized update wins;
version is not a concurrency token. There is no stale-edit
rejection or automatic merge. Reapply preserves definition meaning and stage
IDs, not identical audit timestamps or trigger-condition row IDs.
Unknown definition/stage/trigger DTO properties and undefined enum values are rejected. Defaults retain legacy Action stages, Wait conditions, Stop failure handling, and order-based continuation. Omitted/null/all-zero start selects the first ordered stage on apply. Invalid explicit targets, duplicate stage IDs, missing branch fallbacks, and cycles (including disconnected and failure routes) fail validation. API and MVC share server rules, although the browser can request an explicit End node before saving a legacy graph. Existing invalid configurations remain readable and need explicit correction.
For Lead action conditions and branch rules, field may be a
built-in Lead name or the exact Name of an active Lead custom field in this
tenant. Custom names are case-sensitive; built-ins remain case-insensitive
and take precedence. Missing values and excluded definitions evaluate as
null. The same existing operators apply to both stage conditions and ordered
branch rules. See Lead conditions
for null/empty semantics and a generic missing-domain guard example.
Use tenant-local typed status IDs and active custom-field definitions where required by the action. StartWorkflow must reference an existing tenant workflow; child record-type compatibility is checked during execution. To copy a definition, remove its workflow ID, choose stage IDs not already owned by another workflow, update every graph reference consistently, and reconcile destination templates, statuses, custom fields, and child workflows. Names are not identities; no automatic reference remapping is performed.
Limits: at most 500 stages, 100 rules per branch, 100 conditions per
collection, and JSON depth 32 in the body and in parsed stage action,
condition, and branch configuration strings.
Oversized bodies return 413. Applying never evaluates actions,
contacts configured URLs, or starts execution.
Activate or Deactivate Workflow
| Property | Value |
|---|---|
| Activate | POST /api/workflows/{id}/activate |
| Deactivate | POST /api/workflows/{id}/deactivate |
| Authentication | apiKey header (tenant API key) |
| Body | None required |
| Success | 200 with { id, status,
isActive } |
Activation changes a Draft or Paused definition to Active after
validating the stored definition and enforcing the Workflow Builder license and
capacity. A definition with no stages is invalid for activation and returns
409, leaving the workflow in its current lifecycle state.
Repeating the requested state succeeds before revalidation or
licence checks, without consuming another capacity slot. Deactivation needs
no Workflow Builder licence. Deactivation changes the definition to
Paused and preserves its definition, trigger, queued work,
instances, and stage history. It prevents subsequent starts but does not
cancel running instances or stop a start already in progress. A worker that
attempts a pending item while inactive marks it Failed; reactivation does not
retry failed items. Deactivation is not a hold-and-resume queue operation.
Both operations serialize on the workflow row and
activation capacity decisions also serialize on the tenant settings singleton.
They write only lifecycle state through the normal persistence pipeline and do
not create instances or duplicate trigger work.
POST /api/workflows/550e8400-e29b-41d4-a716-446655440000/activate
apiKey: 11111111-1111-1111-1111-111111111111
Response:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "Active",
"isActive": true
}
Applying an active definition never changes its lifecycle state. Existing instances read the current definition rather than a versioned snapshot, so a later stage edit can affect their next processing. Use deliberate update, activation, and deactivation sequencing when changing live workflows. Configuration management does not require worker toggles to be enabled; execution still follows the existing worker gates.
Definition API Errors
| Status | Meaning |
|---|---|
400 |
Invalid paging or definition input |
401 |
Missing, malformed, wrong, or foreign-tenant key, or an unknown/expired host |
403 |
The Workflow Builder license is unavailable or its capacity is full |
404 |
Unknown or foreign-tenant workflow id |
409 |
Activation found an invalid stored definition (including one with no stages), or a stage ID belongs to another workflow, a stage with execution history would be removed, or a trigger with queued work would be removed |
413 |
The request body exceeds 1 MiB |
500 |
Unexpected failure (generic message only) |
{
"code": "unauthorized",
"message": "Authentication failed."
}
Errors are bounded and never include SQL, stack traces, submitted values, or
another tenant's data. The envelope is { code, message, errors? };
an optional field map contains one fixed field path and message for the first
failure. Current fixed messages are below 200 characters and envelopes below
8 KiB; errors never echo the submitted field value. Invalid GUID route syntax
can produce a routing 404 rather than this controller envelope.
Management outcomes use the WorkflowDefinitionApi ApplicationLog
source and Sure.WorkflowDefinitionApi meter. API evidence omits
keys, bodies, action settings, names, and record data. Shared workflow service
logs retain their existing entity association without definition payloads.
Release and verify the server before publishing the matching CLI. Create is not exactly-once: after a lost response or timeout, inspect list/get before retrying. The CLI never retries writes or falls back to browser actions or a database on an older server. See the CLI limits and recovery guidance.
Workflow Builder API (session cookie)
These browser-session endpoints back the workflow builder. They require an Administrator cookie session and are not part of the API-key contract above. Prefer the Workflow Definition API for automation.
Save Workflow
Creates or updates a workflow definition.
| Property | Value |
|---|---|
| Endpoint | POST /Workflow/SaveWorkflow |
| Authentication | Session cookie (Admin) |
| Content-Type | application/json |
Request Body
{
"id": "00000000-0000-0000-0000-000000000000",
"name": "Welcome Sequence",
"description": "Wait before continuing",
"primaryEntityType": "Customer",
"stages": [
{
"name": "Wait one minute",
"stageKind": "Wait",
"actionType": "Wait",
"actionConfiguration": "",
"delayMinutes": 1,
"order": 1
}
],
"trigger": null
}
Save creates a Draft or preserves the existing lifecycle; activation is a separate action. The server accepts this legacy linear form; the browser can request an explicit End node before its next save.
Response
{
"success": true,
"id": "550e8400-e29b-41d4-a716-446655440000"
}
Activate / Deactivate Workflow
POST /Workflow/ActivateWorkflow?id={workflowId}
POST /Workflow/DeactivateWorkflow?id={workflowId}
Both return { "success": true } on success.
Delete Workflow
DELETE /Workflow/DeleteWorkflow?id={workflowId}
This existing browser-only operation remains separate. No delete operation is exposed through the Workflow Definition API or workflow CLI commands.
Start Workflow Instance
POST /Workflow/StartWorkflow
Body:
{
"workflowId": "guid",
"customerId": "guid"
}
Returns { "success": true, "instanceId": "guid" }.
Control Instance
POST /Workflow/PauseInstance?id={instanceId}
POST /Workflow/ResumeInstance?id={instanceId}
POST /Workflow/CancelInstance?id={instanceId}
POST /Workflow/AdvanceInstance?id={instanceId}
All return { "success": true } on success.
Bulk Action API
Preview Bulk Action
These authenticated Administrator controller endpoints are used by the Bulk
Actions page. They are not part of the external /Api contract. The
existing Customer behaviour remains available, and Lead support uses the same
endpoints whenever the Leads feature is enabled.
For a Lead preview, set recordType to Lead and use a
saved Lead view or a manual search with an optional Campaign filter. Converted
Leads are excluded.
POST /BulkAction/Preview
Body:
{
"recordType": "Customer",
"statusFilterOperator": "In",
"statusIds": ["guid-1", "guid-2"],
"ownerIds": ["guid-3"],
"importantDateFrom": "2025-01-01",
"importantDateTo": "2025-12-31",
"balanceFrom": 0,
"balanceTo": 1000,
"notificationsEnabledOnly": true
}
Response
{
"success": true,
"data": {
"recordType": "Customer",
"totalRecordsMatched": 245,
"sampleCustomers": [ /* first 10 matches */ ]
}
}
A Lead response uses recordType: Lead,
totalRecordsMatched, and sampleLeads. The Lead sample
is bounded to 10 records.
Create Bulk Action Job
POST /BulkAction/CreateJob
Body:
{
"actionRequest": {
"recordType": "Lead",
"actionType": "Email",
"useTemplate": true,
"emailTemplateId": "guid",
"filter": {
"recordType": "Lead",
"sourceType": "LeadView",
"leadViewId": "guid"
}
}
}
Lead jobs support Email, Sms,
SetStatus, and StartWorkflow. Supply
subject/content or the relevant template ID for
communication actions, statusId for SetStatus, and
workflowId for StartWorkflow. Lead status and workflow
IDs must belong to the Lead record type. A manual Lead filter uses
sourceType: ManualFilter, searchTerm, and optional
campaignId.
Manage Jobs
GET /BulkAction/GetJobs
GET /BulkAction/GetJob?id={jobId}
POST /BulkAction/PauseJob?id={jobId}
POST /BulkAction/ResumeJob?id={jobId}
POST /BulkAction/CancelJob?id={jobId}
POST /BulkAction/DeleteJob?id={jobId}
POST /BulkAction/RetryJob?id={jobId}
Support API
List Support Requests
GET /support?status={status}&assignedTo={userId}
Returns HTML view. For JSON data, use the existing authenticated session.
Create Support Request
POST /support/new
Body (form data):
- customerId
- categoryId
- subject
- message
- priority (Low, Normal, High, Urgent)
Reply to Request
POST /support/{id}/reply
Body (form data):
- message
- isInternal (boolean)
- closeAfterReply (boolean)
Update Status / Assign / Close
POST /support/{id}/status (form: status)
POST /support/{id}/assign (form: userId)
POST /support/{id}/close
HTTP Status Codes
| Code | Meaning |
|---|---|
| 200 OK | Request succeeded |
| 400 Bad Request | Validation failed or malformed request |
| 401 Unauthorized | Missing or invalid authentication |
| 403 Forbidden | Authenticated but insufficient permissions |
| 404 Not Found | Resource does not exist |
| 409 Conflict | Request conflicts with existing data |
| 422 Unprocessable Entity | Selection is valid but unavailable |
| 500 Internal Server Error | Unexpected server error |
cURL Examples
Create Customer
curl -X POST https://app.surelycrm.co.uk/Api/Create \
-H "Content-Type: application/json" \
-H "apiKey: 550e8400-e29b-41d4-a716-446655440000" \
-d '{
"title": "Mr",
"firstname": "John",
"surname": "Davies",
"address1": "123 High Street",
"town": "Manchester",
"city": "Greater Manchester",
"postcode": "M1 1AA",
"emailAddress": "john@example.com",
"notificationsEnabled": true,
"mobilePhone": "07700900123"
}'
Search by Phone
curl -X GET "https://app.surelycrm.co.uk/Customer/SearchByPhone?phoneNumber=07700900123" \
-H "Cookie: .AspNetCore.Cookies={your-session-cookie}"
Start Workflow
curl -X POST https://app.surelycrm.co.uk/Workflow/StartWorkflow \
-H "Content-Type: application/json" \
-H "Cookie: .AspNetCore.Cookies={your-session-cookie}" \
-d '{
"workflowId": "550e8400-e29b-41d4-a716-446655440000",
"customerId": "550e8400-e29b-41d4-a716-446655440001"
}'
Get Customer by GUID
curl -X GET "https://app.surelycrm.co.uk/Api/customer/550e8400-e29b-41d4-a716-446655440000" \
-H "apiKey: 550e8400-e29b-41d4-a716-446655440000"
Get Customer by Reference Number
curl -X GET "https://app.surelycrm.co.uk/Api/customer/reference/1234567890" \
-H "apiKey: 550e8400-e29b-41d4-a716-446655440000"
Update Customer
curl -X PUT "https://app.surelycrm.co.uk/Api/customer/550e8400-e29b-41d4-a716-446655440000" \
-H "Content-Type: application/json" \
-H "apiKey: 550e8400-e29b-41d4-a716-446655440000" \
-d '{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Mr",
"firstname": "John",
"surname": "Davies",
"address1": "123 High Street",
"town": "Manchester",
"city": "Greater Manchester",
"postcode": "M1 1AA",
"emailAddress": "john@example.com",
"notificationsEnabled": true,
"mobilePhone": "07700900123"
}'
Start Workflow for Customer
curl -X POST "https://app.surelycrm.co.uk/Api/customer/550e8400-e29b-41d4-a716-446655440000/workflow" \
-H "Content-Type: application/json" \
-H "apiKey: 550e8400-e29b-41d4-a716-446655440000" \
-d '{
"workflowName": "Welcome Sequence",
"contextData": "{}"
}'
Get Customers by Age
curl -X GET "https://app.surelycrm.co.uk/Api/customers/byAge?age=30&comparison=greater" \
-H "apiKey: 550e8400-e29b-41d4-a716-446655440000"
Get Support Ticket by Id
curl -X GET "https://app.surelycrm.co.uk/Api/support/tickets/550e8400-e29b-41d4-a716-446655440000" \
-H "apiKey: 550e8400-e29b-41d4-a716-446655440000"
Get Support Tickets for Customer
# By customer id
curl -X GET "https://app.surelycrm.co.uk/Api/support/tickets?customerId=550e8400-e29b-41d4-a716-446655440001" \
-H "apiKey: 550e8400-e29b-41d4-a716-446655440000"
# By customer email
curl -X GET "https://app.surelycrm.co.uk/Api/support/tickets?customerEmailAddress=john@example.com" \
-H "apiKey: 550e8400-e29b-41d4-a716-446655440000"
Get New Support Tickets
curl -X GET "https://app.surelycrm.co.uk/Api/support/tickets?newTickets=true&page=1&pageSize=25" \
-H "apiKey: 550e8400-e29b-41d4-a716-446655440000"
Opportunities API
Manage the sales pipeline. Opportunities are linked to leads and track deal value, stage, probability, and expected close date.
List Opportunities
curl -X GET "https://app.surelycrm.co.uk/Api/opportunities?search=&leadId=&stage=&status=&page=1" \
-H "apiKey: 550e8400-e29b-41d4-a716-446655440000"
Get Opportunity by Id
curl -X GET "https://app.surelycrm.co.uk/Api/opportunities/550e8400-e29b-41d4-a716-446655440000" \
-H "apiKey: 550e8400-e29b-41d4-a716-446655440000"
Get Opportunities for Lead
curl -X GET "https://app.surelycrm.co.uk/Api/leads/550e8400-e29b-41d4-a716-446655440000/opportunities" \
-H "apiKey: 550e8400-e29b-41d4-a716-446655440000"
Create Opportunity
curl -X POST "https://app.surelycrm.co.uk/Api/opportunities" \
-H "Content-Type: application/json" \
-H "apiKey: 550e8400-e29b-41d4-a716-446655440000" \
-d '{
"leadId": "550e8400-e29b-41d4-a716-446655440000",
"title": "Website redesign project",
"description": "Full redesign of company website",
"value": 5000.00,
"currency": "GBP",
"probability": 50,
"stage": "Prospecting",
"status": "Open",
"expectedCloseDate": "2026-12-31"
}'
Update Opportunity
curl -X PUT "https://app.surelycrm.co.uk/Api/opportunities/550e8400-e29b-41d4-a716-446655440000" \
-H "Content-Type: application/json" \
-H "apiKey: 550e8400-e29b-41d4-a716-446655440000" \
-d '{
"leadId": "550e8400-e29b-41d4-a716-446655440000",
"title": "Website redesign project",
"value": 7500.00,
"probability": 75,
"stage": "Proposal",
"status": "Open"
}'
Change Opportunity Stage
curl -X POST "https://app.surelycrm.co.uk/Api/opportunities/550e8400-e29b-41d4-a716-446655440000/stage" \
-H "Content-Type: application/json" \
-H "apiKey: 550e8400-e29b-41d4-a716-446655440000" \
-d '{
"stage": "ClosedWon",
"notes": "Verbal agreement received"
}'
External Campaign Selection API
The reference contract is available only when the
ReferenceBackedCampaignPromotion feature is enabled for the current tenant.
References are opaque and tenant-scoped; do not infer meaning from them. A campaign's
referenceCode is its CRM display/search code, not its external identity.
List Active Campaign Locations
GET /Api/external/campaign-locations
Returns active locations only. Each item contains externalReference,
name, and description.
Look Up an Active Location
GET /Api/external/campaign-locations/{externalReference}
Returns 404 when the reference is unknown, inactive, belongs to another tenant,
or the feature is disabled.
List Eligible Campaigns
GET /Api/external/campaigns?locationReference={locationExternalReference}
Returns campaigns for the active location that are New or
InProgress. Finished campaigns are not returned.
Create or Reconcile a Campaign
POST /Api/external/campaigns
{
"externalReference": "dark-campaign-100",
"name": "Autumn outreach",
"referenceCode": "AUT-2026-001",
"locationExternalReference": "loc_opaque_reference"
}
A first request returns 201; an identical retry returns 200 and
the same campaign. A reused external reference with a changed name, display reference code,
or location returns 409. An unknown or inactive location returns
422. Do not substitute referenceCode for
externalReference.
External Promotion API
The same feature enables an atomic, reference-only handoff from Dark Leads. It is the only contract Dark Leads uses: it neither receives CRM UUIDs nor uses legacy UUID-based Lead or Opportunity endpoints.
Create or Reconcile a Lead and Initial Opportunity
POST /Api/external/promotions
{
"locationExternalReference": "loc_opaque_reference",
"campaignExternalReference": "dark-campaign-100",
"leadExternalReference": "dark-handoff-100",
"businessName": "Example Business",
"contactPerson": "Business contact",
"contactEmail": "contact@example.test",
"websiteUrl": "https://example.test",
"salesBrief": "Sales-ready snapshot..."
}
Each reference is required, is 1 to 64 characters, starts with an ASCII letter or digit, and
then contains only ASCII letters, digits, underscores, or hyphens.
businessName is required and is at most 200 characters. Contact fields are
optional. The optional salesBrief is plain text or simple Markdown treated as
untrusted text, at most 8,000 characters, trimmed with blank values stored as
null.
Surely locks and verifies the location and campaign, then creates the Lead, its initial
Prospecting/Open Opportunity, and activity history in one database
transaction. The location must be active, the campaign must belong to it, and the campaign
must be New or InProgress. The Opportunity title is the business
name; its value is 0 GBP, with no owner, probability, or date.
Retries and conflicts
The supplied leadExternalReference is the handoff key. Strings are trimmed;
blank optional contact fields become null. References and email are compared
case-insensitively. Other fields are compared after trimming with their exact case.
While the selected location remains active and the campaign remains eligible, an identical
canonical retry returns 200 with both leadCreated and
opportunityCreated set to false. A changed canonical payload
returns 409 with only the differing field names. A location/campaign mismatch or
an incomplete existing pair also returns 409; it exposes no internal IDs or
field values. Do not retry with a different payload or fall back to a UUID endpoint.
The salesBrief is a creation-time snapshot: it is stored only when the Lead is
created, and a canonical replay — with no brief or a different brief — reconciles exactly as
before, never comparing or overwriting the stored brief, and never appearing in
differingFields. The promotion endpoint stores a supplied brief even while the
SalesBrief feature toggle is disabled; only the form and read API are gated.
Unknown locations or campaigns return 404. Inactive locations and finished
campaigns return 422, including a replay after the selection has become
ineligible. Invalid input returns 400. The feature being disabled returns
404.
Response
{
"leadExternalReference": "dark-handoff-100",
"opportunityExternalReference": "opp_opaque_reference",
"leadCreated": true,
"opportunityCreated": true
}
Responses contain external references only, never CRM UUIDs. The same reference string may be used by another tenant, but a tenant can only select and promote records in its own CRM database.
Lead Read API
Bounded read of a Lead by its opaque external reference for
trusted callers. Available only when the
SalesBrief feature is enabled for the current
tenant; disabled access returns 404. The
response contains the external reference, business name,
SalesBrief, optional contact email and phone, the preferred
site, and the Lead's custom-field values keyed by name —
never CRM UUIDs, contact-person names, definition metadata,
or other PII.
Use CLI status discovery followed by
sure-cli lead get --external-reference to select an exact Lead,
including when several Leads share an email. This read requires
SalesBrief independently of the listing's
AutomatedSampleSiteClaim gate; it does not require
ReferenceBackedCampaignPromotion. A previously listed Lead can
become unavailable before detail is read.
Read a Lead by External Reference
GET /Api/external/leads/{externalReference}
Authentication uses the apiKey header. The
request host selects the tenant database. The read is
read-only — it never recomputes or rewrites anything.
Response
{
"externalReference": "lead_opaque_reference",
"businessName": "Example Business",
"salesBrief": "Sales-ready snapshot...",
"contactEmail": "contact@example.test",
"contactPhone": "01234 567890",
"preferredSite": "https://preferred.example.test",
"customFields": {
"Domain": "customer.example.test"
}
}
businessName, salesBrief, contactEmail,
contactPhone, and preferredSite are
null when the Lead has none stored.
customFields is always an object keyed by the
tenant's custom-field name. It contains only stored values for
active, Lead-typed custom fields; a field with
no stored value is omitted, and an inactive field definition is
omitted. It is an empty object ({}) when the Lead
has no such values. Field definitions, types, and identifiers
are never exposed, and the values are untrusted tenant content
that never appears in logs, metrics, or errors.
Errors
401— missing, wrong, or cross-tenant API key404— feature disabled, Lead missing, or foreign-tenant reference
Lead Fields API
Use PATCH /api/lead/{id}/fields to edit only supplied Sales Brief
and existing Lead custom values. Use GET /api/lead/{id}/fields
for readback. The request host selects the tenant; its administrative
apiKey header and enabled Leads feature
are required.
{
"salesBrief": "Prepared sales brief",
"customFields": { "Domain": "example.test", "Approved": "false", "OldValue": null }
}
The object accepts only case-sensitive salesBrief and/or
customFields. Omitted properties and omitted custom keys preserve
stored values. Brief text must be a string or null, at most 8,000 raw characters
before trimming. Null or blank brief text clears it.
Custom keys must be exact active Lead definition Names, rather than Labels or IDs. Values must be strings or null; strings are trimmed and validated using the existing field type. Null, empty or whitespace values delete only that named value. No defaults are filled and omitted required fields are not revalidated. An empty custom object supplies no custom edits and is valid only with a supplied brief. A null custom object does not mean clear all.
Supported types are Text, TextArea, invariant Number, Date
(yyyy-MM-dd), DateTime, Checkbox ("true" or
"false"), Dropdown (an exact configured option), and Hyperlink
(an allowed absolute URL: http, https, mailto, ftp or tel).
Empty operations, malformed JSON, duplicate or unknown properties, wrong value
shapes, invalid typed values, and unknown/inactive/non-Lead/wrong-case names
return generic 400. All supplied edits are validated before any
mutation; one invalid entry rejects the whole mixed request.
A supplied brief requires SalesBrief; nonempty custom edits require
LeadTagsAndCustomFields. A mixed request with either required
control off fails wholly with 404. No new toggle is added.
Missing/wrong/cross-tenant keys return 401; missing or foreign
Leads and disabled access return 404. Unexpected failures return
a generic 500 without private values.
PATCH returns 200 {"success":true}. GET is read-only and returns
only enabled groups: nullable salesBrief and a
customFields object of stored active Lead values, or
{} when none are stored. Disabled groups are omitted; if both
groups are off, GET returns 404. Definition metadata
is not exposed.
{"salesBrief":null,"customFields":{"Approved":"false"}}
All unrelated fields are preserved, including status, campaign, contacts, site URLs, preferred site, conversion state, omitted custom values and last activity. Normal tracking, encryption and save events apply; no status activity is created. Independently configured workflows may run later. Authorised business audit history is retained. Legacy Lead POST, PUT, GET and email lookup still exclude SalesBrief; a legacy full update carries the existing brief forward. Existing legacy custom-write semantics are unchanged.
Legacy GET /api/lead/{id} and
GET /api/lead/byEmail/{emailAddress} return complete Lead JSON
when workflows are attached. Workflow instances retain their IDs, names,
status, summary fields and workflow data. Each instance's recursive
lead back-reference is null; the top-level Lead
remains available. These reads continue to exclude SalesBrief.
Lead Conversion API
Convert a Lead using the tenant's saved Lead Conversion Configuration.
The API does not accept mapping JSON from callers. Authentication uses the
apiKey header, and the request host selects the tenant
database.
Convert a Lead
POST /Api/lead/{leadId}/convert
The body may be empty or may contain only an optional Customer status ID:
{
"statusId": "550e8400-e29b-41d4-a716-446655440002"
}
On success, the existing response shape is preserved:
{ "success": true }
401— missing or invalid API key404— Lead does not exist for the current tenant400— Lead is already converted or the Customer status is invalid
sure-cli lead convert --id calls this route directly.
--email first resolves the Lead through the Lead email lookup,
then calls the same route. MVC, API, and CLI conversion therefore use the
same server-side apply operation and persist equivalent results.
Lead Status API
Lead statuses are maintained in the shared, tenant-local status lookup. Lead status inputs accept a status name or the matching Lead status GUID. Responses continue to return the status name, so the public contract remains compatible with existing integrations. Required names are tenant data; a missing or renamed required row makes that operation fail. Customer status IDs and Customer-typed lookup rows are not valid Lead statuses.
The stable cross-product wire names are New,
Processing, BuildFailed, and
ReadyForEmail. Automation responses expose these
names only and do not expose CRM UUIDs.
Change Lead Status
POST /Api/lead/{leadId}/status
{
"status": "Processing",
"notes": "optional note"
}
status may be a Lead status name or the matching
Lead status GUID. An unknown name or a GUID that does not identify
a Lead status is rejected with 400; the Lead is not
changed.
Lead Status List API
Bounded, status-filtered Lead listing for automated sample-site claiming. Available only when the
AutomatedSampleSiteClaim feature is enabled for the current tenant. Disabled access
returns 404. Responses contain opaque external references only — never CRM UUIDs,
campaign IDs, contact details, or other PII.
The CLI discovery commands reuse this
unchanged endpoint. sure-cli lead list --status BuildFailed
reads one bounded page; use its external reference for explicit detail.
Detail independently requires SalesBrief.
List Leads by Status
GET /Api/leads/byStatus?status={status}
| Parameter | Required | Notes |
|---|---|---|
status |
Yes | Lead status name (case-insensitive), e.g.
New or Processing, or the matching
Lead status GUID. Numeric values and comma-separated
combinations are rejected (400). |
pageSize |
No | Defaults to 20. Values above 20 are clamped
to 20; values below 1 become 1. |
cursor |
No | Opaque position data from a previous nextCursor; do not
edit or rely on its contents. Malformed cursors return
400. |
hasSalesBrief |
No | When true, restricts results to Leads that have a
SalesBrief. Null, blank, or whitespace-only briefs are not
brief-ready. Omitted or false keeps current behaviour
exactly. |
Results are ordered oldest first by (createdAt, externalReference) and are
tenant-isolated by the request host. Listing is read-only — it does not change Lead
status, attempt counts, or ownership. Authentication uses the apiKey
header.
Response
{
"leads": [
{
"externalReference": "lead_opaque_reference",
"status": "New",
"createdAt": "2026-01-01T00:00:00Z"
}
],
"nextCursor": "opaque-or-null"
}
nextCursor is null on the last page. Walk pages by passing
the previous nextCursor until it is null. Do not invent or edit
cursors. A valid status with no matches returns
{"leads":[],"nextCursor":null} with HTTP 200. Pagination is not
a database snapshot: concurrent writes can change membership between pages.
The CLI's optional --all bounds traversal to 1,000 requests and
10 MiB cumulative response bytes without changing this API's page limits.
Errors
401— missing, wrong, or cross-tenant API key400— missing/invalid status name or GUID, or malformed cursor404— feature disabled for the tenant
Lead Claim API
Atomic per-Lead compare-and-set for sample-site claims and other configured
Lead transitions. The AutomatedSampleSiteClaim feature must be
enabled for the request's tenant; disabled access returns 404.
Identify the Lead by its opaque external reference, never a CRM UUID.
Claim Lead by External Reference
POST /Api/leads/claim
{
"externalReference": "lead_opaque_reference"
}
Each omitted name defaults independently: fromStatus to
New and toStatus to Processing.
Existing sample-site callers can keep sending the body above. For another
journey, supply tenant-local Lead status names:
{
"externalReference": "lead_opaque_reference",
"fromStatus": "Approved for build",
"toStatus": "Build underway"
}
Names are trimmed and matched using the tenant's existing status-name lookup
(case-insensitive). Explicit null, empty or whitespace-only names, unknown
or Customer-only names, and names resolving to the same status return
400. Status GUIDs are not an alternate input. Production jobs
should use their own in-progress status: sample-site completion acts on
Processing.
Authenticate with the apiKey header; the request host selects
the tenant database. Exactly one competing claim from the same status wins;
all losers and successful-claim replays receive a generic 409.
The same endpoint can finish a job by claiming its in-progress status to a
configured follow-up status.
A winner changes only the Lead's status column and raises the existing Lead
modified event with the previous and target status IDs. The update and
applicable workflow trigger queue writes commit together. Queue persistence
failure rolls back the transition. Losing, invalid and rejected requests
enqueue nothing. Active Lead workflows can filter the trigger on
StatusId; execution and email delivery happen asynchronously
through the existing worker. This does not guarantee crash-safe exactly-once
workflow execution or email delivery.
Response
{
"externalReference": "lead_opaque_reference",
"status": "Processing"
}
The 200 response contains only the opaque external reference and
canonical target status name. It does not return CRM UUIDs, campaign IDs or
contact details. Errors, application logs and metric tags do not echo request
references, status names or Lead content.
Errors
401— missing, wrong or cross-tenant API key400— missing/blank external reference or invalid status names, including explicit null/blank or equal resolved statuses404— feature disabled, Lead missing or foreign-tenant reference409— expected status does not match, including a replay; the response does not disclose the current status or Lead data
Lead Build Failure API
Bounded, idempotent BuildFailed report for automated
sample-site production. Available only when the
AutomatedSampleSiteClaim feature is enabled
for the current tenant. Disabled access returns
404. The Lead is identified solely by its
opaque external reference — never a CRM UUID. A successful
report atomically transitions Processing →
BuildFailed and stores exactly one bounded
LeadActivityHistory reason: the status change and the
activity commit together or roll back together.
Report a Build Failure by External Reference
POST /Api/leads/sample-site/failure
{
"externalReference": "lead_opaque_reference",
"reason": "bounded non-leaking failure reason"
}
Authentication uses the apiKey header. The
request host selects the tenant database. The reason is
required, trimmed, and limited to 500 characters; it is
stored once in LeadActivityHistory and is never returned,
logged, or metered. If the activity cannot be stored the
whole report fails and the Lead remains
Processing with no activity. Replays against
an already BuildFailed Lead return the same
success only when the durable failure activity already
exists, without overwriting the stored reason or
duplicating activity. The report writes only the Lead
status column plus that single activity row.
Response
{
"externalReference": "lead_opaque_reference",
"status": "BuildFailed"
}
Responses contain the opaque external reference and status only — never CRM UUIDs, campaign IDs, reason text, contact details, or other PII.
Errors
401— missing, wrong, or cross-tenant API key400— blank external reference, or missing/blank/oversized (over 500 characters) reason404— feature disabled, Lead missing, or foreign-tenant reference409— Lead exists but is neitherProcessingnor aBuildFailedLead with a durable failure activity (bounded conflict; does not reveal the current status, reference, or reason)
Sample-site Completion API
Atomic, idempotent completion for an externally delivered
sample site. It is available only when the
AutomatedSampleSiteClaim feature is enabled
for the current tenant. Disabled access returns
404. The request identifies a Lead only by
its opaque external reference — never a CRM UUID.
Surely CRM remains the source of truth for the Lead status and site URL fields. The external Orchestrator calls this public API after delivery; it does not write that CRM state outside this endpoint.
Complete Sample Sites by External Reference
POST /Api/leads/sample-site/completion
The existing single-site request remains supported:
{
"externalReference": "lead_opaque_reference",
"url": "https://example-sample.clydewyre.co.uk/"
}
To record several successful sites, send the batch form.
urls is ordered and may be empty. The order is
retained for delivery results; completion never chooses a
PreferredSite, and an existing human choice is
preserved. Do not send url with this form.
{
"externalReference": "lead_opaque_reference",
"urls": [
"https://first-example-sample.clydewyre.co.uk/",
"https://second-example-sample.clydewyre.co.uk/"
],
"minimumSuccessfulSites": 3
}
Authentication uses the apiKey header and the
request host selects the tenant database. Every URL must
be raw, absolute HTTPS, whitespace-free, at most 500
characters, and distinct. The minimum must be a positive
integer. Mixed, incomplete, or invalid forms return
400.
On the first request, the CRM atomically stores every URL
one per line in SampleSiteUrls, leaves
PreferredSite unchanged, and writes one fixed
StatusChanged activity. It changes
Processing → ReadyForEmail when
the number of URLs reaches the supplied minimum; otherwise
it changes to BuildFailed. If the activity
or applicable workflow trigger queue cannot be stored, all changes roll back.
The first transition raises the existing Lead modified event with the changed
status and URLs, so a configured ReadyForEmail follow-up can run.
The update, ordered URLs, activity and queue writes share one transaction.
Workflows execute asynchronously; this is not crash-safe exactly-once email
delivery. The marker is:
{
"previousStatus": "Processing",
"newStatus": "ReadyForEmail or BuildFailed",
"source": "ExternalSampleSiteCompletion"
}
An exact replay succeeds only when the stored status,
ordered URL text, treating CRLF and LF line endings as
equivalent, and durable endpoint marker match the request.
The stored PreferredSite is
intentionally ignored, so a later human choice does not
turn an otherwise identical retry into a conflict. A
different URL, URL order, result status, or missing marker
returns a generic non-leaking 409 without
mutation. This applies to both the batch and legacy
single-site forms. A successful replay returns 200 without a
new event, workflow queue item or duplicate follow-up. If a workflow later
changes the status, the existing stored-state replay checks still apply.
Response
{
"status": "ReadyForEmail"
}
The status is ReadyForEmail at the batch
threshold and BuildFailed below it. The
response is status-only. It never returns the external
reference, URL, CRM UUID, campaign ID, business name,
contact data, or other PII. Application logs and metric tags
are aggregate-only and likewise never include those values.
Errors
401— missing, wrong, or cross-tenant API key400— blank external reference, an invalid URL, duplicate batch URLs, a non-positive minimum, or mixed/incomplete request forms404— feature disabled, Lead missing, or foreign-tenant reference409— an existing Lead is notProcessing, or its durable completion state does not exactly match the submitted result; the response does not reveal the Lead state or stored data, and the conflict does not mutate the Lead
Lead Tasks API
Create and manage tasks linked to leads. Tasks support assignment, due dates, priorities, and status tracking.
List Lead Tasks
curl -X GET "https://app.surelycrm.co.uk/Api/lead-tasks?search=&leadId=&assignedToUserId=&status=&priority=&page=1" \
-H "apiKey: 550e8400-e29b-41d4-a716-446655440000"
Get Lead Task by Id
curl -X GET "https://app.surelycrm.co.uk/Api/lead-tasks/550e8400-e29b-41d4-a716-446655440000" \
-H "apiKey: 550e8400-e29b-41d4-a716-446655440000"
Get Tasks for Lead
curl -X GET "https://app.surelycrm.co.uk/Api/leads/550e8400-e29b-41d4-a716-446655440000/tasks" \
-H "apiKey: 550e8400-e29b-41d4-a716-446655440000"
Create Lead Task
curl -X POST "https://app.surelycrm.co.uk/Api/lead-tasks" \
-H "Content-Type: application/json" \
-H "apiKey: 550e8400-e29b-41d4-a716-446655440000" \
-d '{
"leadId": "550e8400-e29b-41d4-a716-446655440000",
"title": "Follow up call",
"description": "Call to discuss proposal",
"assignedToUserId": "550e8400-e29b-41d4-a716-446655440001",
"status": "NotStarted",
"priority": "High",
"dueDate": "2026-08-10"
}'
Update Lead Task
curl -X PUT "https://app.surelycrm.co.uk/Api/lead-tasks/550e8400-e29b-41d4-a716-446655440000" \
-H "Content-Type: application/json" \
-H "apiKey: 550e8400-e29b-41d4-a716-446655440000" \
-d '{
"leadId": "550e8400-e29b-41d4-a716-446655440000",
"title": "Follow up call",
"status": "InProgress",
"priority": "High",
"dueDate": "2026-08-10"
}'
Change Lead Task Status
curl -X POST "https://app.surelycrm.co.uk/Api/lead-tasks/550e8400-e29b-41d4-a716-446655440000/status" \
-H "Content-Type: application/json" \
-H "apiKey: 550e8400-e29b-41d4-a716-446655440000" \
-d '{
"status": "Completed"
}'
Need more endpoints? Contact SurelyCrm Support to discuss custom API integrations or webhook requirements.