API Documentation


Find API guidance quickly

Search authentication, endpoints, and request parameters.

Tenant API overview

Send requests to https://your-workspace.crewvana.com/api on your own workspace domain, not the support or central domain. Replace resource IDs and placeholders with values from that workspace. Numeric resource IDs are local to a workspace.

This reference covers all 38 routes registered in the tenant integration API: readiness, 22 read routes, 12 service resource writes, Form test submission and two legacy POST routes. Mobile/browser session endpoints, model locks, public customer forms, webhooks, MCP, and support-agent APIs are separate interfaces.

The read API retrieves Contacts, customer Subscriptions and their definitions/occurrences, Jobs, Visits, Invoices, payment history, Forms, submissions, and Automation state. General CRUD is available for Subscriptions, Jobs and Visits; supported lifecycle actions preserve execution and billing history. It does not provide general Contact/Form/Automation creation, update, deletion, publication, enrollment creation, or message sending. Opportunities belong in pipelines; Contacts represent customer identity. The registered Contact pipeline route is obsolete and is not an Opportunity movement API.

Authenticating

The 22 read endpoints, service resource writes and Form test submission accept Authorization: Bearer YOUR_API_KEY or HTTP Basic authentication with YOUR_API_KEY as the username and a blank password. Create and manage keys in Settings > API. These routes do not accept an api_key query/body parameter.

If the key requires an IP allowlist, the caller must match an allowed IP address or configured IPv4 range. Missing/invalid keys return 401; a disallowed IP returns 403. Keys grant workspace-wide reads and Form test submission. Service resource writes also require an active permission-checked actor and retry identity; see Service resource writes and input contracts.

Use HTTPS, store keys on the server, and send Accept: application/json on every request. Never put keys in browser code or share them. The readiness route needs no key. The two legacy routes do not use the read API authentication middleware; see their endpoint limitations.

API limitations and pagination

The authenticated reads, service resource writes and Form test submission share Laravel’s throttle:60,1 middleware: 60 requests per minute. Its default rate-limit identity is the caller IP; rotating keys does not provide a separate bucket. A 429 response includes Retry-After and rate-limit headers. The readiness and legacy routes are outside this explicit throttle.

Paginated results provide count, offset, next_offset, and has_more. Count is the returned page size, not the total. Continue with next_offset until it is null; keep the original filters. Do not exceed the endpoint’s documented maximum offset. There is no cursor or total-count pagination.

Query booleans should be sent as 0 or 1. Dates are inclusive comparisons against stored timestamps, not calendar-day ranges. Use explicit timestamp bounds; a to date by itself can exclude most of that day. Ordering and snapshots can change between requests; offset pages are not transactionally frozen.

Detail responses have bounded related data. A has_more or has_earlier flag means truncation, not a continuation token. No separate API cursor exists for Contact detail history. Text/payload fields may be truncated or sanitized; known credential-like payload keys are replaced with [redacted]. This is not a guarantee that free-text fields contain no sensitive information.

Errors and troubleshooting

Always send Accept: application/json so validation failures return JSON instead of a browser redirect. Check HTTP status before interpreting a response; not every error uses the same envelope.

A valid numeric resource ID belonging to a different workspace is not readable through your workspace’s endpoint. Unknown numeric IDs return 404. Unknown paths and some unsupported methods fall through to the tenant API’s 421 response. Non-numeric detail IDs also miss the numeric routes and can receive 421.

Authenticated endpoint validation uses HTTP 422 with message and errors (field-name arrays). IP/authentication/not-found errors use success: false plus message. A legacy plan failure uses HTTP 422 with upgrade_required, required_plan, feature, feature_label, upgrade_url, message, and errors.general_error. Plan names come from current configuration; no fixed paid tier is guaranteed here.

If a request fails: verify the workspace host, Authorization header, IP allowlist, HTTP method, numeric IDs, required query, parameter bounds, and workspace plan. After 429, wait for Retry-After; retry safe read requests with backoff. Do not blindly retry a legacy trigger because dispatch can repeat.

Status codes

PropertyFormatDescription
401AuthenticationAPI key required or invalid (read and Form test routes).
403IP restrictionCaller IP is not allowed for the key (read and Form test routes).
404Missing resourceContact, Subscription, Invoice, Form, or Automation not found.
421Unknown route or methodAPI path/method not registered; fallback response has success: false and message.
422Validation or plan gateCorrect errors; inspect upgrade_required for legacy plan failures.
429ThrottledWait for Retry-After before retrying.
500Server errorUnexpected failure; the legacy pipeline placeholder can fail while wrapping its response.

Illustrative response

{
    "message": "The query field is required.",
    "errors": {
        "query": [
            "The query field is required."
        ]
    }
}

Check API readiness

ANY /api

Authentication: None

No authentication is required. Confirms routing/tenant initialization, not API-key validity, permissions, feature availability, or successful downstream processing. Use GET for the example.

No request parameters are required.

Response fields

PropertyFormatDescription
successBooleanTrue for successful read responses.
messageStringAPI ready.

Illustrative response

{
    "success": true,
    "message": "API ready."
}

cURL

curl 'https://your-workspace.crewvana.com/api' \
  --header 'Accept: application/json'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'GET',
    CURLOPT_HTTPHEADER => ['Accept: application/json'],
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($request);
curl_close($request);
echo $response;

NodeJs

// Node.js 18 or later, in an ES module (.mjs). Keep API keys on the server.
const response = await fetch('https://your-workspace.crewvana.com/api', {
  method: 'GET',
  headers: {
    Accept: 'application/json',
  },
});
console.log(await response.json());

Python

# Install requests with: python -m pip install requests
import requests

response = requests.request(
    'GET',
    'https://your-workspace.crewvana.com/api',
    headers={'Accept': 'application/json'},
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api')
request = Net::HTTP::Get.new(url)
request['Accept'] = 'application/json'
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
  http.request(request)
end
puts response.body

Read a Contact’s Invoices

GET /api/contacts/{contact}/invoices

Authentication: API key (Bearer or Basic)

Returns persisted invoice status, job association/status, totals, completed-payment sum, balance, payment count, and link existence/expiry/used timestamps. Missing Contact returns 404; an existing Contact with no matches returns an empty list. Ordered by created_at descending, then ID descending.

paid_amount sums completed invoice_amount entries, including negative refunds, and excludes tips, pending and failed payments. document_status is Draft, Issued or Cancelled; payment_status is Unpaid, Partial or Paid; delivery_status is Not sent, Sent, Delivered or Failed. status remains a compatibility field. balance_due is zero for Cancelled invoices, otherwise max(0, total minus paid_amount). Issuance and delivery timestamps require actual evidence; copying a link does not count as sending. Reads never synchronize state or issue links. Tokens and provider secrets are excluded.

Input

ParameterFormatRequiredDescription
contactInteger (path)RequiredContact ID in this workspace.
service_job_idIntegerOptionalPositive Job ID; exact match within this Contact.
invoice_numberStringOptionalExact invoice number, 1–120 characters. Filters combine with AND.
limitIntegerOptional1–25; default 10.
offsetIntegerOptional0–1000; default 0.

Response fields

PropertyFormatDescription
tenant_id, generated_at_utc, contact_idIdentity and timestampActive workspace, UTC generation time and requested Contact. Stored timestamps use the Contact read API representation.
invoicesArrayid, contact_id, service_job_id, opportunity_id, invoice_number, status, document_status, payment_status, delivery_status, issued_at, sent_at, delivered_at, failed_at, cancelled_at, cancelled_by_user_id, cancellation_reason, overdue, can_collect_payment, sub_total, tax, total, due_date, created_at, updated_at, job_status, job_paid_status, paid_amount, balance_due, payment_count, has_payment_link, payment_link_expires_at, payment_link_used_at.
count, offset, next_offset, has_morePaginationReturned count, current offset, next offset or null, and truncation flag.

Illustrative response

{
    "success": true,
    "tenant_id": "example",
    "contact_id": 123,
    "invoices": [],
    "count": 0,
    "offset": 0,
    "next_offset": null,
    "has_more": false
}

cURL

curl 'https://your-workspace.crewvana.com/api/contacts/{contact}/invoices?limit=10' \
  --header 'Accept: application/json' \
  --user 'YOUR_API_KEY:'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/contacts/{contact}/invoices?limit=10');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'GET',
    CURLOPT_HTTPHEADER => ['Accept: application/json'],
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($request);
curl_close($request);
echo $response;

NodeJs

// Node.js 18 or later, in an ES module (.mjs). Keep API keys on the server.
const response = await fetch('https://your-workspace.crewvana.com/api/contacts/{contact}/invoices?limit=10', {
  method: 'GET',
  headers: {
    Accept: 'application/json',
    Authorization: 'Basic ' + Buffer.from('YOUR_API_KEY:').toString('base64'),
  },
});
console.log(await response.json());

Python

# Install requests with: python -m pip install requests
import requests

response = requests.request(
    'GET',
    'https://your-workspace.crewvana.com/api/contacts/{contact}/invoices?limit=10',
    headers={'Accept': 'application/json'},
    auth=('YOUR_API_KEY', ''),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/contacts/{contact}/invoices?limit=10')
request = Net::HTTP::Get.new(url)
request['Accept'] = 'application/json'
request.basic_auth('YOUR_API_KEY', '')
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
  http.request(request)
end
puts response.body

Read an Invoice and its Payments

GET /api/invoices/{invoice}

Authentication: API key (Bearer or Basic)

Returns the same invoice summary as the Contact invoice list plus payment history ordered by paid_at descending, then ID descending. Missing invoice returns 404. Invoice aggregates use every completed transaction regardless of history pagination. Return payments.next_offset as payment_offset to continue; retain payment_limit.

Payment rows include every status as stored. Reads make no business-data changes and reveal no provider payloads, external references, idempotency keys or card details. Link metadata does not prove delivery; compare Contact conversation events for sending and delivery evidence. Separate reads are not a transactionally frozen snapshot.

Input

ParameterFormatRequiredDescription
invoiceInteger (path)RequiredInvoice ID in this workspace.
payment_limitIntegerOptional1–100; default 50.
payment_offsetIntegerOptional0–5000; default 0.

Response fields

PropertyFormatDescription
tenant_id, generated_at_utcIdentity and timestampActive workspace and generation time in UTC.
invoiceObjectSame summary fields as GET /api/contacts/{contact}/invoices.
payments.itemsArrayid, invoice_id, service_job_id, contact_id, opportunity_id, method, source, provider, status, amount, invoice_amount, tip_amount, paid_at, created_at, updated_at.
payments.count, payments.offset, payments.next_offset, payments.has_morePaginationReturned count, current payment offset, next offset or null, and truncation flag.

Illustrative response

{
    "success": true,
    "tenant_id": "example",
    "invoice": {
        "id": 456,
        "status": "Draft",
        "total": "75.00",
        "paid_amount": 0,
        "balance_due": 75,
        "payment_count": 0
    },
    "payments": {
        "items": [],
        "count": 0,
        "offset": 0,
        "next_offset": null,
        "has_more": false
    }
}

cURL

curl 'https://your-workspace.crewvana.com/api/invoices/{invoice}?payment_limit=50' \
  --header 'Accept: application/json' \
  --user 'YOUR_API_KEY:'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/invoices/{invoice}?payment_limit=50');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'GET',
    CURLOPT_HTTPHEADER => ['Accept: application/json'],
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($request);
curl_close($request);
echo $response;

NodeJs

// Node.js 18 or later, in an ES module (.mjs). Keep API keys on the server.
const response = await fetch('https://your-workspace.crewvana.com/api/invoices/{invoice}?payment_limit=50', {
  method: 'GET',
  headers: {
    Accept: 'application/json',
    Authorization: 'Basic ' + Buffer.from('YOUR_API_KEY:').toString('base64'),
  },
});
console.log(await response.json());

Python

# Install requests with: python -m pip install requests
import requests

response = requests.request(
    'GET',
    'https://your-workspace.crewvana.com/api/invoices/{invoice}?payment_limit=50',
    headers={'Accept': 'application/json'},
    auth=('YOUR_API_KEY', ''),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/invoices/{invoice}?payment_limit=50')
request = Net::HTTP::Get.new(url)
request['Accept'] = 'application/json'
request.basic_auth('YOUR_API_KEY', '')
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
  http.request(request)
end
puts response.body

Read a Contact’s Subscriptions

GET /api/contacts/{contact}/subscriptions

Authentication: API key (Bearer or Basic)

Lists customer service agreements, newest ID first, independently of platform billing. Missing Contact returns 404. An existing Contact without matches returns an empty page. Reads never activate, generate, schedule or repair agreements.

Input

ParameterFormatRequiredDescription
contactInteger (path)RequiredContact ID in this workspace.
statusStringOptionalExact draft, quoted, scheduled, active, paused, ended, expired, cancelled or archived lifecycle.
limitIntegerOptional1–100; default 25.
offsetIntegerOptional0–10000; default 0.

Response fields

PropertyFormatDescription
tenant_id, generated_at_utcIdentity and timestampActive workspace and response creation time in UTC.
subscriptions.itemsArrayAgreement IDs, Contact/Opportunity/plan links, lifecycle, timezone, effective range, lifecycle timestamps, generation horizon, occurrence limit, current version, billing terms, tax flag, per-service total, legacy start/end and creation/update timestamps. plan contains ID, name, billing_interval and billing_frequency.
subscriptions.count, offset, has_more, next_offsetPaginationPage count, current offset, continuation flag and next offset or null.

Illustrative response

{
    "success": true,
    "tenant_id": "example",
    "contact_id": 123,
    "subscriptions": {
        "items": [],
        "count": 0,
        "offset": 0,
        "has_more": false,
        "next_offset": null
    }
}

cURL

curl 'https://your-workspace.crewvana.com/api/contacts/123/subscriptions?limit=25' \
  --header 'Accept: application/json' \
  --user 'YOUR_API_KEY:'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/contacts/123/subscriptions?limit=25');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'GET',
    CURLOPT_HTTPHEADER => ['Accept: application/json'],
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($request);
curl_close($request);
echo $response;

NodeJs

// Node.js 18 or later, in an ES module (.mjs). Keep API keys on the server.
const response = await fetch('https://your-workspace.crewvana.com/api/contacts/123/subscriptions?limit=25', {
  method: 'GET',
  headers: {
    Accept: 'application/json',
    Authorization: 'Basic ' + Buffer.from('YOUR_API_KEY:').toString('base64'),
  },
});
console.log(await response.json());

Python

# Install requests with: python -m pip install requests
import requests

response = requests.request(
    'GET',
    'https://your-workspace.crewvana.com/api/contacts/123/subscriptions?limit=25',
    headers={'Accept': 'application/json'},
    auth=('YOUR_API_KEY', ''),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/contacts/123/subscriptions?limit=25')
request = Net::HTTP::Get.new(url)
request['Accept'] = 'application/json'
request.basic_auth('YOUR_API_KEY', '')
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
  http.request(request)
end
puts response.body

Read a Subscription Definition

GET /api/subscriptions/{subscription}

Authentication: API key (Bearer or Basic)

Returns the customer agreement summary, its latest saved definition, recurrence, commercial Job lines, ordered Visit preferences and generation cursor. Billing Frequency and saved service recurrence are separate fields; a plan name alone does not establish recurrence. Missing Subscription returns 404.

current_definition is null before a definition exists. Definition contains legacy_backfill without exposing arbitrary snapshots. Job templates are capped at 25; each has up to 100 commercial lines and 25 Visit templates. Legacy items/packages/discounts are each capped at 100. Each nested page reports has_more; nested truncation is not a follow-up cursor for this endpoint. Provider payloads, arbitrary metadata, constraints and credentials are omitted. No business records change.

Input

ParameterFormatRequiredDescription
subscriptionInteger (path)RequiredCustomer Subscription ID in this workspace.

Response fields

PropertyFormatDescription
subscriptionObjectSame summary and plan fields as the Contact Subscription list.
current_definitionObject or nullID/version/state, timezone, effective range, initial Visit date, horizon/limit, short-month and DST policies, week start, legacy_backfill, actor/frozen/creation/update times. recurrence contains frequency, interval, weekly_days, monthly mode/day/week/weekday, annual month/day and local_time.
current_definition.job_templatesPage objectOrdered Job IDs/stable keys/sequences/names, cycle offsets, tax flags, lines and visits. Lines expose type, Price Book IDs, name, quantity, amount and duration; Visits expose IDs, stable keys, sequence, name, offsets, duration, preference mode and preferred start/end.
generation_stateObject or nullLast cadence/date, generated-through date, last run/success and retry count.
legacy_commercial_linesObjectitems, packages and discounts page objects; each row includes ID, Price Book ID, quantity and amount.

Illustrative response (selected fields)

{
    "success": true,
    "tenant_id": "example",
    "subscription": {
        "id": 456,
        "status": "draft"
    },
    "current_definition": null,
    "generation_state": null
}

Read Subscription Occurrences

GET /api/subscriptions/{subscription}/occurrences

Authentication: API key (Bearer or Basic)

Lists saved Job occurrences ordered by local_slot_date, cadence sequence and ID. from_date/to_date compare inclusive local calendar dates, not UTC timestamps. Generated Job data is returned only when its Subscription matches the requested agreement. Missing/broken Job links are null. Reads do not generate, retry dispatch or alter schedules.

Each occurrence includes up to 25 saved Visit occurrences and 25 scheduling conflicts, with explicit nested truncation flags. Visit occurrence resolved times show generation outcomes; use Contact detail Jobs/Visits for current persisted execution schedules. No arbitrary snapshots, hashes, conflict details or failure payloads are returned.

Input

ParameterFormatRequiredDescription
subscriptionInteger (path)RequiredCustomer Subscription ID in this workspace.
limitIntegerOptional1–100; default 25.
offsetIntegerOptional0–10000; default 0.
from_dateDateOptionalInclusive local slot date, YYYY-MM-DD.
to_dateDateOptionalInclusive local slot date, YYYY-MM-DD; at or after from_date when supplied.

Response fields

PropertyFormatDescription
occurrences.itemsArrayOccurrence ID, version/template IDs, cadence_sequence, local_slot_date, linked service_job_id, state and timestamps. job contains Contact ID, status/payment state, subtotal/total, completion/cancellation and creation/update times.
occurrences.items[].visitsPage objectVisit occurrence ID, Visit ID, sequence, requested local date/start/end, resolved UTC start/end and state.
occurrences.items[].conflictsPage objectConflict ID, Visit occurrence ID, code, severity, state and resolution timestamp.
occurrences.count, offset, has_more, next_offsetPaginationUse next_offset with the same filters until null.

Illustrative response

{
    "success": true,
    "tenant_id": "example",
    "subscription_id": 456,
    "occurrences": {
        "items": [],
        "count": 0,
        "offset": 0,
        "has_more": false,
        "next_offset": null
    }
}

cURL

curl 'https://your-workspace.crewvana.com/api/subscriptions/456/occurrences?limit=25' \
  --header 'Accept: application/json' \
  --user 'YOUR_API_KEY:'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/subscriptions/456/occurrences?limit=25');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'GET',
    CURLOPT_HTTPHEADER => ['Accept: application/json'],
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($request);
curl_close($request);
echo $response;

NodeJs

// Node.js 18 or later, in an ES module (.mjs). Keep API keys on the server.
const response = await fetch('https://your-workspace.crewvana.com/api/subscriptions/456/occurrences?limit=25', {
  method: 'GET',
  headers: {
    Accept: 'application/json',
    Authorization: 'Basic ' + Buffer.from('YOUR_API_KEY:').toString('base64'),
  },
});
console.log(await response.json());

Python

# Install requests with: python -m pip install requests
import requests

response = requests.request(
    'GET',
    'https://your-workspace.crewvana.com/api/subscriptions/456/occurrences?limit=25',
    headers={'Accept': 'application/json'},
    auth=('YOUR_API_KEY', ''),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/subscriptions/456/occurrences?limit=25')
request = Net::HTTP::Get.new(url)
request['Accept'] = 'application/json'
request.basic_auth('YOUR_API_KEY', '')
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
  http.request(request)
end
puts response.body

Read a Contact

GET /api/contacts/{contact}

Authentication: API key (Bearer or Basic)

Returns an operational snapshot with identity, conversations, Jobs, subscriptions, failures, and audit history. A missing Contact returns 404. Related collections are bounded; has_more/has_earlier do not provide a follow-up cursor.

Input

ParameterFormatRequiredDescription
contactInteger (path)RequiredNumeric Contact ID in this workspace.
event_limitIntegerOptional1–500; default 200. Latest events across the returned conversations.
audit_limitIntegerOptional1–250; default 100.

Response fields

PropertyFormatDescription
successBooleanTrue for successful read responses.
tenant_idStringWorkspace identifier.
generated_at_utcStringUTC timestamp when the detail snapshot was generated.
contactObjectid, full_name, first_name, last_name, company_name, status, archived, source, created_at, updated_at, campaign_source, channel_source, timezone. archived is Boolean.
identityObjectphones, emails, addresses, shared_identity_contacts. Phone/email rows: id, contact_id, phone or email, type, is_primary (Boolean), sort_order, created_at, updated_at. Address rows: id, contact_id, is_primary, sort_order, address_line1, address_line2, city, state, postal_code, country, created_at, updated_at. Shared identities: identity_type, contact_id, full_name, archived, identity_value; may include the queried Contact.
communication_preferenceObject or nullid, contact_id, dnd_all, dnd_call, dnd_sms, dnd_email, dnd_incoming, marketing_email_subscribed, marketing_sms_subscribed, marketing_email_unsubscribed_at, marketing_sms_unsubscribed_at, preferred_method, created_at, updated_at. DND/subscription flags are Booleans.
duplicate_historyObjectcandidates (up to 100): id, contact_id_1, contact_id_2, match_criteria (decoded), match_value, status, resolved_by_user_id, resolved_at, created_at, updated_at. merges (up to 100): id, master_contact_id, secondary_contact_ids, contact_names, duplicate_ids (decoded), merged_by_user_id, restored_by_user_id, merged_at, restored_at, created_at, updated_at.
conversationsObjectitems (latest 50), count, has_more, endpoint_groups, events, event_count, has_earlier_events. Conversation rows: id, comm_channel_id, remote_endpoint_type, remote_endpoint, remote_endpoint_source, remote_endpoint_bound_at, is_open, is_starred, last_activity_at, created_at, updated_at, event_count, first_event_at, last_event_at, linked_contact_ids. Alias conversations are excluded when alias data exists.
conversations.endpoint_groupsArraycomm_channel_id, remote_endpoint_type, remote_endpoint, conversation_ids, conversation_count for groups of returned conversation rows.
conversations.eventsArrayLatest event_limit events, returned in chronological order: id, comm_conversation_id, comm_channel_id, direction, event_type, status, transport_message_id, body (2,000 characters), payload (sanitized), user_id, occurred_at, created_at, updated_at.
jobsObjectitems (latest 250), count, has_more. Job rows: id, contact_id, contact_subscription_id, opportunity_id, recurring_scheduled_date, contact_address_id, contact_phone_id, contact_email_id, status, paid_status, scheduled_by_user_id, sub_total, total, completed_at, cancelled_at, created_at, updated_at, visits, subscription_occurrence, message_logs.
jobs.items[].visitsArrayid, service_job_id, sequence, is_default, scheduled_start, scheduled_end, status, cancelled_at, cancelled_by_user_id, created_at, updated_at, technicians. Technician rows: visit_id, user_id, name, checked_in_at, checked_out_at.
jobs.items[].subscription_occurrenceObject or nullid, contact_subscription_id, contact_subscription_version_id, contact_subscription_job_template_id, cadence_sequence, local_slot_date, occurrence_key, service_job_id, state, failure_message, created_at, updated_at.
jobs.items[].message_logsArrayid, service_job_id, scenario_key, dedupe_key, sent_at, created_at, updated_at.
subscriptionsObjectitems (latest 100), count, has_more. Rows: id, contact_id, opportunity_id, price_book_subscription_id, status, timezone, effective_start_date, effective_end_date, activated_at, paused_at, cancelled_at, archived_at, generation_horizon_days, occurrence_limit, current_version_number, starts_at, ends_at, created_at, updated_at, job_occurrences. Occurrences share the Job occurrence fields above, with at most 1,000 occurrence rows across the returned subscriptions.
failed_messagesObjectitems (latest 100), count, has_more. Rows: id, contact_id, scenario_key, status, preferred_method, attempted_channel, failure_reason, last_error, retry_count, last_attempted_at, sent_at, created_at, updated_at.
auditObjectitems (latest audit_limit), count, has_earlier. Rows: id, contact_id, user_id, action, field, old_value, new_value, created_at. Old/new values are truncated to 2,000 characters.

Illustrative response (selected fields)

{
    "success": true,
    "tenant_id": "example-workspace",
    "generated_at_utc": "2026-09-29T12:00:00+00:00",
    "contact": {
        "id": 123,
        "full_name": "Example Customer",
        "archived": false
    },
    "conversations": {
        "items": [],
        "count": 0,
        "has_more": false,
        "endpoint_groups": [],
        "events": [],
        "event_count": 0,
        "has_earlier_events": false
    }
}

cURL

curl 'https://your-workspace.crewvana.com/api/contacts/123?event_limit=20&audit_limit=20' \
  --header 'Accept: application/json' \
  --user 'YOUR_API_KEY:'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/contacts/123?event_limit=20&audit_limit=20');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'GET',
    CURLOPT_HTTPHEADER => ['Accept: application/json'],
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($request);
curl_close($request);
echo $response;

NodeJs

// Node.js 18 or later, in an ES module (.mjs). Keep API keys on the server.
const response = await fetch('https://your-workspace.crewvana.com/api/contacts/123?event_limit=20&audit_limit=20', {
  method: 'GET',
  headers: {
    Accept: 'application/json',
    Authorization: 'Basic ' + Buffer.from('YOUR_API_KEY:').toString('base64'),
  },
});
console.log(await response.json());

Python

# Install requests with: python -m pip install requests
import requests

response = requests.request(
    'GET',
    'https://your-workspace.crewvana.com/api/contacts/123?event_limit=20&audit_limit=20',
    headers={'Accept': 'application/json'},
    auth=('YOUR_API_KEY', ''),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/contacts/123?event_limit=20&audit_limit=20')
request = Net::HTTP::Get.new(url)
request['Accept'] = 'application/json'
request.basic_auth('YOUR_API_KEY', '')
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
  http.request(request)
end
puts response.body

Submit a Form test

POST /api/forms/test

Authentication: API key (Bearer or Basic)

Submit exactly one of form_id or form_name. Form IDs are workspace-local; form_name matches the full configured name using the database name comparison, with no partial search. Unknown Forms return 404. JSON and application/x-www-form-urlencoded bodies are supported. No browser session or CSRF token is required.

The server generates fresh dummy Contact details and valid configured fields, including supported custom fields and choices. Caller-supplied Contact details are ignored. The same Test action as Desktop runs normal persistence, attribution, tags, address/custom values, Opportunity routing, activity, Conversation events, notifications and Automation. Inactive Forms follow the public submission contract and are accepted.

Success returns HTTP 201 and the created Contact ID. These are persistent submissions included in Form counts. Missing or conflicting selectors, unavailable required fields/choices, and generated identity conflicts return 422 with message and errors. Authentication/IP restrictions return 401/403; throttling returns 429.

This is a write with real notifications and Automation effects. Every successful call creates a new dummy Contact; it never updates an existing Contact. The endpoint has no idempotency key. Do not blindly retry after a timeout or server failure; inspect Form submissions first. A successful submission does not guarantee completion or delivery of downstream Automation.

Input

ParameterFormatRequiredDescription
form_idInteger (body)One selector requiredPositive Form ID. Omit form_name when using this selector.
form_nameString (body), 1–255 charactersOne selector requiredFull Form name. Outer whitespace is trimmed. Omit form_id when using this selector.

Response fields

PropertyFormatDescription
successBooleanTrue when the test submission has completed.
form_idIntegerSelected Form ID.
form_nameStringSelected Form name.
contact_idIntegerNew dummy Contact ID.
is_testBooleanTrue for this generated test submission.

Illustrative response

{
    "success": true,
    "form_id": 123,
    "form_name": "Request a Quote",
    "contact_id": 456,
    "is_test": true
}

cURL

curl 'https://your-workspace.crewvana.com/api/forms/test' \
  --header 'Accept: application/json' \
  --user 'YOUR_API_KEY:' \
  --data 'form_id=123'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/forms/test');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => ['Accept: application/json'],
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_POSTFIELDS => 'form_id=123',
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($request);
curl_close($request);
echo $response;

NodeJs

// Node.js 18 or later, in an ES module (.mjs). Keep API keys on the server.
const response = await fetch('https://your-workspace.crewvana.com/api/forms/test', {
  method: 'POST',
  headers: {
    Accept: 'application/json',
    Authorization: 'Basic ' + Buffer.from('YOUR_API_KEY:').toString('base64'),
    'Content-Type': 'application/x-www-form-urlencoded',
  },
  body: 'form_id=123',
});
console.log(await response.json());

Python

# Install requests with: python -m pip install requests
import requests

response = requests.request(
    'POST',
    'https://your-workspace.crewvana.com/api/forms/test',
    headers={'Accept': 'application/json'},
    auth=('YOUR_API_KEY', ''),
    data={"form_id":123},
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/forms/test')
request = Net::HTTP::Post.new(url)
request['Accept'] = 'application/json'
request.basic_auth('YOUR_API_KEY', '')
request['Content-Type'] = 'application/x-www-form-urlencoded'
request.body = 'form_id=123'
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
  http.request(request)
end
puts response.body

Read a Form

GET /api/forms/{form}

Authentication: API key (Bearer or Basic)

Read Form configuration, ordered fields, and attached Contact tags. A missing Form returns 404.

Input

ParameterFormatRequiredDescription
formInteger (path)RequiredNumeric Form ID in this workspace.

Response fields

PropertyFormatDescription
successBooleanTrue for successful read responses.
tenant_idStringWorkspace identifier.
generated_at_utcStringUTC timestamp when the detail snapshot was generated.
formObjectid, name, is_active, form_style_id, pipeline_id, pipeline_status_id, default_source, campaign_source, channel_source, show_name, show_description, description, success_message, redirect_url, created_at, updated_at, fields, contact_tags. The token is not returned.
form.fieldsArrayFirst 100 by order, then id: id, form_id, field, label, required (Boolean), order, custom_field_id, created_at, updated_at.
form.contact_tagsArrayFirst 100 by name, then id: id, name, color. No truncation flags or follow-up cursors are provided for these two collections.

Illustrative response (selected fields)

{
    "success": true,
    "tenant_id": "example-workspace",
    "generated_at_utc": "2026-09-29T12:00:00+00:00",
    "form": {
        "id": 123,
        "name": "Quote request",
        "is_active": true,
        "fields": [],
        "contact_tags": []
    }
}

cURL

curl 'https://your-workspace.crewvana.com/api/forms/123' \
  --header 'Accept: application/json' \
  --user 'YOUR_API_KEY:'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/forms/123');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'GET',
    CURLOPT_HTTPHEADER => ['Accept: application/json'],
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($request);
curl_close($request);
echo $response;

NodeJs

// Node.js 18 or later, in an ES module (.mjs). Keep API keys on the server.
const response = await fetch('https://your-workspace.crewvana.com/api/forms/123', {
  method: 'GET',
  headers: {
    Accept: 'application/json',
    Authorization: 'Basic ' + Buffer.from('YOUR_API_KEY:').toString('base64'),
  },
});
console.log(await response.json());

Python

# Install requests with: python -m pip install requests
import requests

response = requests.request(
    'GET',
    'https://your-workspace.crewvana.com/api/forms/123',
    headers={'Accept': 'application/json'},
    auth=('YOUR_API_KEY', ''),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/forms/123')
request = Net::HTTP::Get.new(url)
request['Accept'] = 'application/json'
request.basic_auth('YOUR_API_KEY', '')
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
  http.request(request)
end
puts response.body

List Form submissions

GET /api/forms/{form}/submissions

Authentication: API key (Bearer or Basic)

Includes only submission activities, not every Form activity. Related Contact or communication fields may be null. A missing Form returns 404.

Input

ParameterFormatRequiredDescription
formInteger (path)RequiredNumeric Form ID.
contact_idIntegerOptionalMinimum 1; filter to one Contact.
fromDateOptionalInclusive lower bound on occurred_at. Include a time to avoid excluding part of a day.
toDateOptionalInclusive upper bound on occurred_at; a date without a time means midnight, not the end of the day.
limitIntegerOptional1–100; default 50.
offsetIntegerOptional0–5000; default 0.

Response fields

PropertyFormatDescription
successBooleanTrue for successful read responses.
submissionsArrayid, form_id, contact_id, opportunity_id, comm_event_id, activity_type, payload (decoded and sanitized), occurred_at, created_at, updated_at, contact_name, contact_status, contact_archived (Boolean or null), comm_conversation_id, comm_channel_id, event_direction, event_type, event_status, event_occurred_at. Sorted by occurred_at descending, then id descending.
countIntegerNumber of rows on this page, not the total match count.
offsetIntegerCurrent offset.
next_offsetInteger or nullPass as offset for the next page; null when no further page exists.
has_moreBooleanWhether more matching rows exist.
tenant_idStringWorkspace identifier.
form_idIntegerRequested Form ID.

Illustrative response

{
    "success": true,
    "tenant_id": "example-workspace",
    "form_id": 123,
    "submissions": [],
    "count": 0,
    "offset": 0,
    "next_offset": null,
    "has_more": false
}

cURL

curl 'https://your-workspace.crewvana.com/api/forms/123/submissions?limit=50' \
  --header 'Accept: application/json' \
  --user 'YOUR_API_KEY:'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/forms/123/submissions?limit=50');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'GET',
    CURLOPT_HTTPHEADER => ['Accept: application/json'],
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($request);
curl_close($request);
echo $response;

NodeJs

// Node.js 18 or later, in an ES module (.mjs). Keep API keys on the server.
const response = await fetch('https://your-workspace.crewvana.com/api/forms/123/submissions?limit=50', {
  method: 'GET',
  headers: {
    Accept: 'application/json',
    Authorization: 'Basic ' + Buffer.from('YOUR_API_KEY:').toString('base64'),
  },
});
console.log(await response.json());

Python

# Install requests with: python -m pip install requests
import requests

response = requests.request(
    'GET',
    'https://your-workspace.crewvana.com/api/forms/123/submissions?limit=50',
    headers={'Accept': 'application/json'},
    auth=('YOUR_API_KEY', ''),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/forms/123/submissions?limit=50')
request = Net::HTTP::Get.new(url)
request['Accept'] = 'application/json'
request.basic_auth('YOUR_API_KEY', '')
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
  http.request(request)
end
puts response.body

Read an Automation

GET /api/automations/{automation}

Authentication: API key (Bearer or Basic)

Read draft state, the current immutable publication, and bounded history. A missing Automation returns 404. This endpoint does not publish or execute an Automation.

Input

ParameterFormatRequiredDescription
automationInteger (path)RequiredNumeric Automation ID.

Response fields

PropertyFormatDescription
successBooleanTrue for successful read responses.
tenant_idStringWorkspace identifier.
generated_at_utcStringUTC timestamp when the detail snapshot was generated.
automationObjectid, name, is_active, delay_seconds, draft_revision, published_revision, published_at, current_publication_id, has_unpublished_changes, created_at, updated_at, automation_folder_id, published_by_user_id, is_published_for_runtime, draft_nodes, current_publication, publications, history. is_published_for_runtime is true only when active with a current publication.
automation.draft_nodesArrayUp to 500 nodes ordered by roots first, parent_id, sort_order, id. Fields: id, automation_id, parent_id, parent_condition, type, name, sort_order, context_id, context_type, context_sub_id, context_sub_type, custom_vars, display_json, display_name, url, json, created_at, updated_at. JSON fields are decoded/sanitized.
automation.current_publicationObject or nullid, automation_id, revision, schema_version, content_hash, published_at, published_by_user_id, created_at, updated_at, snapshot (decoded and sanitized publication data).
automation.publicationsArrayUp to 50 publication metadata rows by revision descending; publication fields above except snapshot. Use the publications endpoint for paginated snapshots.
automation.historyArrayUp to 100 events by created_at/id descending: id, automation_id, user_id, event_type, name, created_at, updated_at. No truncation flags are provided for draft/history collections.

Illustrative response (selected fields)

{
    "success": true,
    "tenant_id": "example-workspace",
    "generated_at_utc": "2026-09-29T12:00:00+00:00",
    "automation": {
        "id": 123,
        "name": "Follow up",
        "is_active": false,
        "is_published_for_runtime": false,
        "current_publication": null,
        "draft_nodes": [],
        "publications": [],
        "history": []
    }
}

cURL

curl 'https://your-workspace.crewvana.com/api/automations/123' \
  --header 'Accept: application/json' \
  --user 'YOUR_API_KEY:'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/automations/123');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'GET',
    CURLOPT_HTTPHEADER => ['Accept: application/json'],
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($request);
curl_close($request);
echo $response;

NodeJs

// Node.js 18 or later, in an ES module (.mjs). Keep API keys on the server.
const response = await fetch('https://your-workspace.crewvana.com/api/automations/123', {
  method: 'GET',
  headers: {
    Accept: 'application/json',
    Authorization: 'Basic ' + Buffer.from('YOUR_API_KEY:').toString('base64'),
  },
});
console.log(await response.json());

Python

# Install requests with: python -m pip install requests
import requests

response = requests.request(
    'GET',
    'https://your-workspace.crewvana.com/api/automations/123',
    headers={'Accept': 'application/json'},
    auth=('YOUR_API_KEY', ''),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/automations/123')
request = Net::HTTP::Get.new(url)
request['Accept'] = 'application/json'
request.basic_auth('YOUR_API_KEY', '')
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
  http.request(request)
end
puts response.body

List Automation publications

GET /api/automations/{automation}/publications

Authentication: API key (Bearer or Basic)

Returns immutable publication snapshots, not live draft nodes. A missing Automation returns 404.

Input

ParameterFormatRequiredDescription
automationInteger (path)RequiredNumeric Automation ID.
limitIntegerOptional1–25; default 10.
offsetIntegerOptional0–5000; default 0.

Response fields

PropertyFormatDescription
successBooleanTrue for successful read responses.
publicationsArrayid, automation_id, revision, schema_version, content_hash, published_at, published_by_user_id, created_at, updated_at, snapshot (decoded and sanitized publication data). Sorted by revision descending.
countIntegerNumber of rows on this page, not the total match count.
offsetIntegerCurrent offset.
next_offsetInteger or nullPass as offset for the next page; null when no further page exists.
has_moreBooleanWhether more matching rows exist.
tenant_idStringWorkspace identifier.
automation_idIntegerRequested Automation ID.

Illustrative response

{
    "success": true,
    "tenant_id": "example-workspace",
    "automation_id": 123,
    "publications": [],
    "count": 0,
    "offset": 0,
    "next_offset": null,
    "has_more": false
}

cURL

curl 'https://your-workspace.crewvana.com/api/automations/123/publications?limit=10' \
  --header 'Accept: application/json' \
  --user 'YOUR_API_KEY:'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/automations/123/publications?limit=10');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'GET',
    CURLOPT_HTTPHEADER => ['Accept: application/json'],
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($request);
curl_close($request);
echo $response;

NodeJs

// Node.js 18 or later, in an ES module (.mjs). Keep API keys on the server.
const response = await fetch('https://your-workspace.crewvana.com/api/automations/123/publications?limit=10', {
  method: 'GET',
  headers: {
    Accept: 'application/json',
    Authorization: 'Basic ' + Buffer.from('YOUR_API_KEY:').toString('base64'),
  },
});
console.log(await response.json());

Python

# Install requests with: python -m pip install requests
import requests

response = requests.request(
    'GET',
    'https://your-workspace.crewvana.com/api/automations/123/publications?limit=10',
    headers={'Accept': 'application/json'},
    auth=('YOUR_API_KEY', ''),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/automations/123/publications?limit=10')
request = Net::HTTP::Get.new(url)
request['Accept'] = 'application/json'
request.basic_auth('YOUR_API_KEY', '')
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
  http.request(request)
end
puts response.body

List Automation logs

GET /api/automations/{automation}/logs

Authentication: API key (Bearer or Basic)

Read execution log summaries. A missing Automation returns 404.

Input

ParameterFormatRequiredDescription
automationInteger (path)RequiredNumeric Automation ID.
fromDateOptionalInclusive lower bound on created_at. Include a time to avoid excluding part of a day.
toDateOptionalInclusive upper bound on created_at; a date without a time means midnight, not the end of the day.
limitIntegerOptional1–100; default 50.
offsetIntegerOptional0–5000; default 0.

Response fields

PropertyFormatDescription
successBooleanTrue for successful read responses.
logsArrayid, automation_id, status, message (up to 4,000 characters), created_at, updated_at, enrollment_id (associated enrollment or null). Sorted by created_at descending, then id descending.
countIntegerNumber of rows on this page, not the total match count.
offsetIntegerCurrent offset.
next_offsetInteger or nullPass as offset for the next page; null when no further page exists.
has_moreBooleanWhether more matching rows exist.
tenant_idStringWorkspace identifier.
automation_idIntegerRequested Automation ID.

Illustrative response

{
    "success": true,
    "tenant_id": "example-workspace",
    "automation_id": 123,
    "logs": [],
    "count": 0,
    "offset": 0,
    "next_offset": null,
    "has_more": false
}

cURL

curl 'https://your-workspace.crewvana.com/api/automations/123/logs?limit=50' \
  --header 'Accept: application/json' \
  --user 'YOUR_API_KEY:'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/automations/123/logs?limit=50');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'GET',
    CURLOPT_HTTPHEADER => ['Accept: application/json'],
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($request);
curl_close($request);
echo $response;

NodeJs

// Node.js 18 or later, in an ES module (.mjs). Keep API keys on the server.
const response = await fetch('https://your-workspace.crewvana.com/api/automations/123/logs?limit=50', {
  method: 'GET',
  headers: {
    Accept: 'application/json',
    Authorization: 'Basic ' + Buffer.from('YOUR_API_KEY:').toString('base64'),
  },
});
console.log(await response.json());

Python

# Install requests with: python -m pip install requests
import requests

response = requests.request(
    'GET',
    'https://your-workspace.crewvana.com/api/automations/123/logs?limit=50',
    headers={'Accept': 'application/json'},
    auth=('YOUR_API_KEY', ''),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/automations/123/logs?limit=50')
request = Net::HTTP::Get.new(url)
request['Accept'] = 'application/json'
request.basic_auth('YOUR_API_KEY', '')
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
  http.request(request)
end
puts response.body

List Automation enrollments

GET /api/automations/{automation}/enrollments

Authentication: API key (Bearer or Basic)

Read enrollments with their log, executions, and waits. This does not create, cancel, or retry enrollments. Related execution/wait rows are limited to 1,000 per collection across the entire page, ordered by enrollment ID then row ID. A missing Automation returns 404.

Input

ParameterFormatRequiredDescription
automationInteger (path)RequiredNumeric Automation ID.
subject_typeStringOptionalOne of contact, service_job, user, team.
subject_idIntegerOptionalMinimum 1. Use with subject_type to disambiguate IDs across object types.
fromDateOptionalInclusive lower bound on enrolled_at. Include a time to avoid excluding part of a day.
toDateOptionalInclusive upper bound on enrolled_at; a date without a time means midnight, not the end of the day.
limitIntegerOptional1–50; default 25.
offsetIntegerOptional0–5000; default 0.

Response fields

PropertyFormatDescription
successBooleanTrue for successful read responses.
enrollmentsArrayid, automation_id, automation_log_id, automation_publication_id, runtime_version, subject_type, subject_id, subject_label, context (sanitized), enrolled_at, created_at, updated_at, log (object or null), executions, waits. Sorted by enrolled_at descending, then id descending.
countIntegerNumber of rows on this page, not the total match count.
offsetIntegerCurrent offset.
next_offsetInteger or nullPass as offset for the next page; null when no further page exists.
has_moreBooleanWhether more matching rows exist.
tenant_idStringWorkspace identifier.
automation_idIntegerRequested Automation ID.
enrollments[].logObject or nullid, automation_id, status, message (4,000 characters), created_at, updated_at.
enrollments[].executionsArrayid, automation_id, automation_node_id, automation_log_id, automation_enrollment_id, automation_publication_id, publication_node_key, occurrence_key, continuation_state (decoded/sanitized), status, action_name, subject_type, subject_id, subject_label, result (4,000 characters), executed_at, created_at, updated_at.
enrollments[].waitsArrayid, automation_id, automation_node_id, automation_enrollment_id, automation_execution_id, automation_publication_id, publication_node_key, continuation_branch, next_sort_order, status, mode, settings, context (decoded/sanitized), due_at, timeout_at, satisfied_at, satisfied_branch, failure_message (4,000 characters), claimed_at, claim_expires_at, created_at, updated_at.
executions_has_moreBooleanMore than 1,000 related execution rows exist across this page; no separate related-row cursor is exposed.
waits_has_moreBooleanMore than 1,000 related wait rows exist across this page; no separate related-row cursor is exposed.

Illustrative response

{
    "success": true,
    "tenant_id": "example-workspace",
    "automation_id": 123,
    "enrollments": [],
    "count": 0,
    "offset": 0,
    "next_offset": null,
    "has_more": false,
    "executions_has_more": false,
    "waits_has_more": false
}

cURL

curl 'https://your-workspace.crewvana.com/api/automations/123/enrollments?limit=25' \
  --header 'Accept: application/json' \
  --user 'YOUR_API_KEY:'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/automations/123/enrollments?limit=25');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'GET',
    CURLOPT_HTTPHEADER => ['Accept: application/json'],
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($request);
curl_close($request);
echo $response;

NodeJs

// Node.js 18 or later, in an ES module (.mjs). Keep API keys on the server.
const response = await fetch('https://your-workspace.crewvana.com/api/automations/123/enrollments?limit=25', {
  method: 'GET',
  headers: {
    Accept: 'application/json',
    Authorization: 'Basic ' + Buffer.from('YOUR_API_KEY:').toString('base64'),
  },
});
console.log(await response.json());

Python

# Install requests with: python -m pip install requests
import requests

response = requests.request(
    'GET',
    'https://your-workspace.crewvana.com/api/automations/123/enrollments?limit=25',
    headers={'Accept': 'application/json'},
    auth=('YOUR_API_KEY', ''),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/automations/123/enrollments?limit=25')
request = Net::HTTP::Get.new(url)
request['Accept'] = 'application/json'
request.basic_auth('YOUR_API_KEY', '')
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
  http.request(request)
end
puts response.body

Trigger an automation (legacy)

POST /api/automation/trigger

Authentication: Legacy plan gate; read API key middleware absent · Plan feature: automation

Requires the automation plan feature. This legacy route does not use the read-route key/IP authentication middleware or explicit read-route throttle. It is not recommended for new integrations until its access controls and contract are aligned.

The current handler dispatches a Webhook trigger with optional Contact context. The required name is not consumed by the handler; a success response confirms dispatch, not workflow completion. It does not select a named Automation.

Use application/x-www-form-urlencoded, not JSON: the legacy request reader consumes form/query variables. Unknown input parameters can return 422. Supplying an unknown Contact integration link fails; no Contact is created.

Input

ParameterFormatRequiredDescription
nameStringRequiredRequired by legacy validation, but currently not used to select/filter an Automation.
contactObject (form fields)OptionalSend contact[integration] and contact[external_id] to identify a Contact through its existing integration link.

Response fields

PropertyFormatDescription
successBooleanTrue when a successful legacy dispatch response is wrapped.
messageStringWebhook trigger dispatched

Illustrative response

{
    "message": "Webhook trigger dispatched",
    "success": true
}

cURL

curl 'https://your-workspace.crewvana.com/api/automation/trigger' \
  --header 'Accept: application/json' \
  --user 'YOUR_API_KEY:' \
  --data 'name=YOUR_TRIGGER_NAME'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/automation/trigger');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => ['Accept: application/json'],
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_POSTFIELDS => 'name=YOUR_TRIGGER_NAME',
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($request);
curl_close($request);
echo $response;

NodeJs

// Node.js 18 or later, in an ES module (.mjs). Keep API keys on the server.
const response = await fetch('https://your-workspace.crewvana.com/api/automation/trigger', {
  method: 'POST',
  headers: {
    Accept: 'application/json',
    Authorization: 'Basic ' + Buffer.from('YOUR_API_KEY:').toString('base64'),
    'Content-Type': 'application/x-www-form-urlencoded',
  },
  body: 'name=YOUR_TRIGGER_NAME',
});
console.log(await response.json());

Python

# Install requests with: python -m pip install requests
import requests

response = requests.request(
    'POST',
    'https://your-workspace.crewvana.com/api/automation/trigger',
    headers={'Accept': 'application/json'},
    auth=('YOUR_API_KEY', ''),
    data={"name":"YOUR_TRIGGER_NAME"},
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/automation/trigger')
request = Net::HTTP::Post.new(url)
request['Accept'] = 'application/json'
request.basic_auth('YOUR_API_KEY', '')
request['Content-Type'] = 'application/x-www-form-urlencoded'
request.body = 'name=YOUR_TRIGGER_NAME'
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
  http.request(request)
end
puts response.body

Obsolete Contact pipeline endpoint

POST /api/contact/move-in-pipeline

Authentication: Legacy plan gate; read API key middleware absent · Plan feature: pipeline

Obsolete: Opportunities, not Contacts, belong in pipelines. Do not use this endpoint for new integrations. It remains listed only because its legacy URL is still registered. No replacement Opportunity movement endpoint is currently registered in the tenant integration API.

Requires the pipeline plan feature. The handler is a placeholder: it sleeps for one second and returns a string without performing a pipeline mutation. The legacy response wrapper cannot turn that string into the declared message output and can raise a server error.

The fields below describe legacy request metadata only, not a supported movement contract. Declared integer formats are not Laravel integer validation rules. The legacy package reads form/query inputs.

This legacy route does not use the read-route key/IP authentication middleware or explicit read-route throttle. Its presence in this inventory is not a guarantee of usable write functionality.

Input

ParameterFormatRequiredDescription
contact_idIntegerRequiredContact ID declared by the legacy request metadata.
pipeline_idIntegerRequiredPipeline ID declared by the legacy request metadata.
status_idIntegerRequiredStage ID declared by the legacy request metadata.

Response fields

PropertyFormatDescription
Successful move responseUnavailableNo supported successful mutation contract exists for the current placeholder handler.

Service resource writes and input contracts

All resource routes require the tenant key/IP middleware and share the 60-per-minute throttle. Writes additionally require JSON, actor_user_id, and Idempotency-Key. The key grants integration access; a selected active User supplies permission checks and attribution, not proof of that User’s login. Never select another actor merely to bypass permission denial.

Job creation requires calendar.create. Job/Visit changes require calendar.update plus service_jobs.update for an assigned Visit or service_jobs.update_all. Job deletion requires calendar.delete. Subscription management requires payments.update or Job create permission. Pending migration api_mutation_receipt must be applied before writes can run.

Job lines support item/package/discount with the corresponding price_book_*_id, positive integer quantity, optional price (item/package) or amount (discount), and existing line_item_id for an update. At most 100 ordered lines. New packages/discounts require active catalog entries; existing matching snapshots can retain retired entries. Fixed-price items retain authoritative catalog/saved prices. API commercial edits are blocked when execution or financial history exists.

Job create accepts at most 25 ordered Visits. Visits accept nullable scheduled_start/end formatted YYYY-MM-DD HH:MM:SS in business-local wall time, technician_ids (up to 25 unique active schedulable Users), and allow_block_overlap. Scheduling overlaps with Blocks require an explicit override. Read timestamps are saved values; legacy manual and generated records may use different storage conventions, so do not infer a UTC offset from a bare timestamp.

Subscription PUT replaces the complete definition using its current lock_version; it does not rewrite generated history. Required: contact_id, active price_book_subscription_id, timezone, effective_start_date, initial_visit_date (YYYY-MM-DD), generation_horizon_days (1–730), billing_terms (per-service/monthly/upfront), recurrence and job_templates. Optional: opportunity_id, effective_end_date, occurrence_limit (1–100000), apply_tax, total_due_per_service, short_month_policy (clamp_to_last_day/skip), dst_gap_policy (conflict/shift_forward), dst_fold_policy (earlier/later), week_starts_on (0–6).

Recurrence requires frequency (day/week/month/year) and local_time (HH:MM:SS); interval defaults to 1. Optional weekly_days (0–6), monthly_mode (day_of_month/nth_weekday), day_of_month, week_of_month, day_of_week, annual_month and annual_day follow canonical recurrence validation. Each of up to 25 Job templates requires name, 1–100 lines and 1–25 Visits. Optional stable_key, notes, cycle_offset_days and apply_tax preserve template identity and defaults.

Subscription lines require line_type (item/package/discount/custom), name, positive quantity and nonnegative amount; catalog IDs and duration_minutes are optional. Visit templates require name, duration_minutes (1–10080) and preference_mode (exact/window/none). Optional stable_key, offset_days, preferred_start_time/end_time and preferred_technicians [{user_id, preference_mode: preferred|required}] define dispatch intent. Unknown input fields and arbitrary metadata/default snapshots are rejected. Definition template data is bounded; preserve stable keys when revising an existing agreement.

Successful create returns 201; other writes return 200 with success, tenant_id and resource ID. Subscription writes also return lock_version, current_version_number and lifecycle status. Failed writes roll back; only successes get receipts. Use a new key for a changed request. Receipts are tenant-local, keyed by API-key identity, and retained to prevent later retries from duplicating resources. They do not reconstruct historical requests or device errors.

List customer Subscriptions

GET /api/subscriptions

Authentication: API key and IP allowlist

Optional filters: contact_id, price_book_subscription_id, and exact lifecycle status. Returns subscriptions.items with lock_version, plan summary and pagination.

Input

ParameterFormatRequiredDescription
limitIntegerOptional1–100, default 25.
offsetIntegerOptional0–10000, default 0.

cURL

curl 'https://your-workspace.crewvana.com/api/subscriptions?limit=25' \
  --header 'Accept: application/json' \
  --user 'YOUR_API_KEY:'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/subscriptions?limit=25');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'GET',
    CURLOPT_HTTPHEADER => ['Accept: application/json'],
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($request);
curl_close($request);
echo $response;

NodeJs

// Node.js 18 or later, in an ES module (.mjs). Keep API keys on the server.
const response = await fetch('https://your-workspace.crewvana.com/api/subscriptions?limit=25', {
  method: 'GET',
  headers: {
    Accept: 'application/json',
    Authorization: 'Basic ' + Buffer.from('YOUR_API_KEY:').toString('base64'),
  },
});
console.log(await response.json());

Python

# Install requests with: python -m pip install requests
import requests

response = requests.request(
    'GET',
    'https://your-workspace.crewvana.com/api/subscriptions?limit=25',
    headers={'Accept': 'application/json'},
    auth=('YOUR_API_KEY', ''),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/subscriptions?limit=25')
request = Net::HTTP::Get.new(url)
request['Accept'] = 'application/json'
request.basic_auth('YOUR_API_KEY', '')
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
  http.request(request)
end
puts response.body

Create a Subscription draft

POST /api/subscriptions

Authentication: API key and IP allowlist

Creates a draft and immutable definition version; does not generate Jobs. Definition fields and limits are listed below.

Input

ParameterFormatRequiredDescription
actor_user_idInteger (JSON body)RequiredActive workspace User whose application permissions authorize the operation. API-key identity is recorded separately.
Idempotency-KeyHTTP headerRequired1–100 ASCII letters, digits, dot, underscore, colon or hyphen. Reuse for the same request only. A different method/path/body returns 409; successful replay returns the original status/body and Idempotency-Replayed: true.

cURL

curl 'https://your-workspace.crewvana.com/api/subscriptions' \
  --request POST \
  --user 'YOUR_API_KEY:' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: replace-with-a-unique-request-id' \
  --data '{"actor_user_id":2,"contact_id":42,"price_book_subscription_id":2,"timezone":"America/Denver","effective_start_date":"2026-10-05","initial_visit_date":"2026-10-05","generation_horizon_days":90,"billing_terms":"per-service","occurrence_limit":1,"recurrence":{"frequency":"month","interval":1,"local_time":"10:00:00","day_of_month":5},"job_templates":[{"name":"Service","lines":[{"line_type":"custom","name":"Service","quantity":1,"amount":100}],"visits":[{"name":"Visit","duration_minutes":60,"preference_mode":"exact","preferred_start_time":"10:00:00","preferred_technicians":[{"user_id":2,"preference_mode":"preferred"}]}]}]}'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/subscriptions');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/json', 'Idempotency-Key: replace-with-a-unique-request-id'],
    CURLOPT_POSTFIELDS => '{"actor_user_id":2,"contact_id":42,"price_book_subscription_id":2,"timezone":"America/Denver","effective_start_date":"2026-10-05","initial_visit_date":"2026-10-05","generation_horizon_days":90,"billing_terms":"per-service","occurrence_limit":1,"recurrence":{"frequency":"month","interval":1,"local_time":"10:00:00","day_of_month":5},"job_templates":[{"name":"Service","lines":[{"line_type":"custom","name":"Service","quantity":1,"amount":100}],"visits":[{"name":"Visit","duration_minutes":60,"preference_mode":"exact","preferred_start_time":"10:00:00","preferred_technicians":[{"user_id":2,"preference_mode":"preferred"}]}]}]}',
    CURLOPT_RETURNTRANSFER => true,
]);
echo curl_exec($request);
curl_close($request);

NodeJs

// Run on the server; keep API keys out of browser code.
const response = await fetch('https://your-workspace.crewvana.com/api/subscriptions', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer YOUR_API_KEY',
    Accept: 'application/json',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'replace-with-a-unique-request-id',
  },
  body: JSON.stringify({"actor_user_id":2,"contact_id":42,"price_book_subscription_id":2,"timezone":"America/Denver","effective_start_date":"2026-10-05","initial_visit_date":"2026-10-05","generation_horizon_days":90,"billing_terms":"per-service","occurrence_limit":1,"recurrence":{"frequency":"month","interval":1,"local_time":"10:00:00","day_of_month":5},"job_templates":[{"name":"Service","lines":[{"line_type":"custom","name":"Service","quantity":1,"amount":100}],"visits":[{"name":"Visit","duration_minutes":60,"preference_mode":"exact","preferred_start_time":"10:00:00","preferred_technicians":[{"user_id":2,"preference_mode":"preferred"}]}]}]}),
});
console.log(await response.json());

Python

import json
import requests

response = requests.request(
    'POST',
    'https://your-workspace.crewvana.com/api/subscriptions',
    auth=('YOUR_API_KEY', ''),
    headers={'Accept': 'application/json', 'Idempotency-Key': 'replace-with-a-unique-request-id'},
    json=json.loads("{\"actor_user_id\":2,\"contact_id\":42,\"price_book_subscription_id\":2,\"timezone\":\"America/Denver\",\"effective_start_date\":\"2026-10-05\",\"initial_visit_date\":\"2026-10-05\",\"generation_horizon_days\":90,\"billing_terms\":\"per-service\",\"occurrence_limit\":1,\"recurrence\":{\"frequency\":\"month\",\"interval\":1,\"local_time\":\"10:00:00\",\"day_of_month\":5},\"job_templates\":[{\"name\":\"Service\",\"lines\":[{\"line_type\":\"custom\",\"name\":\"Service\",\"quantity\":1,\"amount\":100}],\"visits\":[{\"name\":\"Visit\",\"duration_minutes\":60,\"preference_mode\":\"exact\",\"preferred_start_time\":\"10:00:00\",\"preferred_technicians\":[{\"user_id\":2,\"preference_mode\":\"preferred\"}]}]}]}"),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/subscriptions')
request = Net::HTTP::Post.new(url)
request.basic_auth('YOUR_API_KEY', '')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request['Idempotency-Key'] = 'replace-with-a-unique-request-id'
request.body = "{\"actor_user_id\":2,\"contact_id\":42,\"price_book_subscription_id\":2,\"timezone\":\"America/Denver\",\"effective_start_date\":\"2026-10-05\",\"initial_visit_date\":\"2026-10-05\",\"generation_horizon_days\":90,\"billing_terms\":\"per-service\",\"occurrence_limit\":1,\"recurrence\":{\"frequency\":\"month\",\"interval\":1,\"local_time\":\"10:00:00\",\"day_of_month\":5},\"job_templates\":[{\"name\":\"Service\",\"lines\":[{\"line_type\":\"custom\",\"name\":\"Service\",\"quantity\":1,\"amount\":100}],\"visits\":[{\"name\":\"Visit\",\"duration_minutes\":60,\"preference_mode\":\"exact\",\"preferred_start_time\":\"10:00:00\",\"preferred_technicians\":[{\"user_id\":2,\"preference_mode\":\"preferred\"}]}]}]}"
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) { |http| http.request(request) }
puts response.body

Use a fresh request ID for each intended write. Retain the same ID and body when retrying.

Replace a Subscription definition

PUT /api/subscriptions/{subscription}

Authentication: API key and IP allowlist

Supply a complete definition. Contact is immutable. Creates a new version; existing generated Jobs, Visits and protected history are retained. Cancelled/archived definitions cannot be edited.

Input

ParameterFormatRequiredDescription
actor_user_idInteger (JSON body)RequiredActive workspace User whose application permissions authorize the operation. API-key identity is recorded separately.
Idempotency-KeyHTTP headerRequired1–100 ASCII letters, digits, dot, underscore, colon or hyphen. Reuse for the same request only. A different method/path/body returns 409; successful replay returns the original status/body and Idempotency-Replayed: true.
lock_versionIntegerRequiredCopy from GET /api/subscriptions/{subscription}.

cURL

curl 'https://your-workspace.crewvana.com/api/subscriptions/10' \
  --request PUT \
  --user 'YOUR_API_KEY:' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: replace-with-a-unique-request-id' \
  --data '{"actor_user_id":2,"contact_id":42,"price_book_subscription_id":2,"timezone":"America/Denver","effective_start_date":"2026-10-05","initial_visit_date":"2026-10-05","generation_horizon_days":90,"billing_terms":"per-service","occurrence_limit":1,"recurrence":{"frequency":"month","interval":1,"local_time":"10:00:00","day_of_month":5},"job_templates":[{"name":"Service","lines":[{"line_type":"custom","name":"Service","quantity":1,"amount":100}],"visits":[{"name":"Visit","duration_minutes":60,"preference_mode":"exact","preferred_start_time":"10:00:00","preferred_technicians":[{"user_id":2,"preference_mode":"preferred"}]}]}],"lock_version":0}'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/subscriptions/10');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'PUT',
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/json', 'Idempotency-Key: replace-with-a-unique-request-id'],
    CURLOPT_POSTFIELDS => '{"actor_user_id":2,"contact_id":42,"price_book_subscription_id":2,"timezone":"America/Denver","effective_start_date":"2026-10-05","initial_visit_date":"2026-10-05","generation_horizon_days":90,"billing_terms":"per-service","occurrence_limit":1,"recurrence":{"frequency":"month","interval":1,"local_time":"10:00:00","day_of_month":5},"job_templates":[{"name":"Service","lines":[{"line_type":"custom","name":"Service","quantity":1,"amount":100}],"visits":[{"name":"Visit","duration_minutes":60,"preference_mode":"exact","preferred_start_time":"10:00:00","preferred_technicians":[{"user_id":2,"preference_mode":"preferred"}]}]}],"lock_version":0}',
    CURLOPT_RETURNTRANSFER => true,
]);
echo curl_exec($request);
curl_close($request);

NodeJs

// Run on the server; keep API keys out of browser code.
const response = await fetch('https://your-workspace.crewvana.com/api/subscriptions/10', {
  method: 'PUT',
  headers: {
    Authorization: 'Bearer YOUR_API_KEY',
    Accept: 'application/json',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'replace-with-a-unique-request-id',
  },
  body: JSON.stringify({"actor_user_id":2,"contact_id":42,"price_book_subscription_id":2,"timezone":"America/Denver","effective_start_date":"2026-10-05","initial_visit_date":"2026-10-05","generation_horizon_days":90,"billing_terms":"per-service","occurrence_limit":1,"recurrence":{"frequency":"month","interval":1,"local_time":"10:00:00","day_of_month":5},"job_templates":[{"name":"Service","lines":[{"line_type":"custom","name":"Service","quantity":1,"amount":100}],"visits":[{"name":"Visit","duration_minutes":60,"preference_mode":"exact","preferred_start_time":"10:00:00","preferred_technicians":[{"user_id":2,"preference_mode":"preferred"}]}]}],"lock_version":0}),
});
console.log(await response.json());

Python

import json
import requests

response = requests.request(
    'PUT',
    'https://your-workspace.crewvana.com/api/subscriptions/10',
    auth=('YOUR_API_KEY', ''),
    headers={'Accept': 'application/json', 'Idempotency-Key': 'replace-with-a-unique-request-id'},
    json=json.loads("{\"actor_user_id\":2,\"contact_id\":42,\"price_book_subscription_id\":2,\"timezone\":\"America/Denver\",\"effective_start_date\":\"2026-10-05\",\"initial_visit_date\":\"2026-10-05\",\"generation_horizon_days\":90,\"billing_terms\":\"per-service\",\"occurrence_limit\":1,\"recurrence\":{\"frequency\":\"month\",\"interval\":1,\"local_time\":\"10:00:00\",\"day_of_month\":5},\"job_templates\":[{\"name\":\"Service\",\"lines\":[{\"line_type\":\"custom\",\"name\":\"Service\",\"quantity\":1,\"amount\":100}],\"visits\":[{\"name\":\"Visit\",\"duration_minutes\":60,\"preference_mode\":\"exact\",\"preferred_start_time\":\"10:00:00\",\"preferred_technicians\":[{\"user_id\":2,\"preference_mode\":\"preferred\"}]}]}],\"lock_version\":0}"),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/subscriptions/10')
request = Net::HTTP::Put.new(url)
request.basic_auth('YOUR_API_KEY', '')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request['Idempotency-Key'] = 'replace-with-a-unique-request-id'
request.body = "{\"actor_user_id\":2,\"contact_id\":42,\"price_book_subscription_id\":2,\"timezone\":\"America/Denver\",\"effective_start_date\":\"2026-10-05\",\"initial_visit_date\":\"2026-10-05\",\"generation_horizon_days\":90,\"billing_terms\":\"per-service\",\"occurrence_limit\":1,\"recurrence\":{\"frequency\":\"month\",\"interval\":1,\"local_time\":\"10:00:00\",\"day_of_month\":5},\"job_templates\":[{\"name\":\"Service\",\"lines\":[{\"line_type\":\"custom\",\"name\":\"Service\",\"quantity\":1,\"amount\":100}],\"visits\":[{\"name\":\"Visit\",\"duration_minutes\":60,\"preference_mode\":\"exact\",\"preferred_start_time\":\"10:00:00\",\"preferred_technicians\":[{\"user_id\":2,\"preference_mode\":\"preferred\"}]}]}],\"lock_version\":0}"
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) { |http| http.request(request) }
puts response.body

Use a fresh request ID for each intended write. Retain the same ID and body when retrying.

Delete a Subscription draft

DELETE /api/subscriptions/{subscription}

Authentication: API key and IP allowlist

Only never-activated draft/quoted agreements without Jobs, occurrences, exceptions or source Quotes can be deleted. Otherwise use cancel/archive.

Input

ParameterFormatRequiredDescription
actor_user_idInteger (JSON body)RequiredActive workspace User whose application permissions authorize the operation. API-key identity is recorded separately.
Idempotency-KeyHTTP headerRequired1–100 ASCII letters, digits, dot, underscore, colon or hyphen. Reuse for the same request only. A different method/path/body returns 409; successful replay returns the original status/body and Idempotency-Replayed: true.
lock_versionIntegerRequiredCurrent optimistic lock version.

cURL

curl 'https://your-workspace.crewvana.com/api/subscriptions/10' \
  --request DELETE \
  --user 'YOUR_API_KEY:' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: replace-with-a-unique-request-id' \
  --data '{"actor_user_id":2,"lock_version":0}'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/subscriptions/10');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'DELETE',
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/json', 'Idempotency-Key: replace-with-a-unique-request-id'],
    CURLOPT_POSTFIELDS => '{"actor_user_id":2,"lock_version":0}',
    CURLOPT_RETURNTRANSFER => true,
]);
echo curl_exec($request);
curl_close($request);

NodeJs

// Run on the server; keep API keys out of browser code.
const response = await fetch('https://your-workspace.crewvana.com/api/subscriptions/10', {
  method: 'DELETE',
  headers: {
    Authorization: 'Bearer YOUR_API_KEY',
    Accept: 'application/json',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'replace-with-a-unique-request-id',
  },
  body: JSON.stringify({"actor_user_id":2,"lock_version":0}),
});
console.log(await response.json());

Python

import json
import requests

response = requests.request(
    'DELETE',
    'https://your-workspace.crewvana.com/api/subscriptions/10',
    auth=('YOUR_API_KEY', ''),
    headers={'Accept': 'application/json', 'Idempotency-Key': 'replace-with-a-unique-request-id'},
    json=json.loads("{\"actor_user_id\":2,\"lock_version\":0}"),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/subscriptions/10')
request = Net::HTTP::Delete.new(url)
request.basic_auth('YOUR_API_KEY', '')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request['Idempotency-Key'] = 'replace-with-a-unique-request-id'
request.body = "{\"actor_user_id\":2,\"lock_version\":0}"
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) { |http| http.request(request) }
puts response.body

Use a fresh request ID for each intended write. Retain the same ID and body when retrying.

Change Subscription lifecycle

POST /api/subscriptions/{subscription}/lifecycle

Authentication: API key and IP allowlist

Canonical lifecycle transitions apply. Activation/resume changes status only; the normal scheduler generates work later. Legacy unsafe definitions require review. Cancel stops future generation and retains existing Jobs. Archive requires cancelled/ended state.

Input

ParameterFormatRequiredDescription
actor_user_idInteger (JSON body)RequiredActive workspace User whose application permissions authorize the operation. API-key identity is recorded separately.
Idempotency-KeyHTTP headerRequired1–100 ASCII letters, digits, dot, underscore, colon or hyphen. Reuse for the same request only. A different method/path/body returns 409; successful replay returns the original status/body and Idempotency-Replayed: true.
actionStringRequiredactivate, pause, resume, cancel, archive
lock_versionIntegerRequiredCurrent lock version.
reasonString, max 1000Required for cancelNonblank cancellation reason.

cURL

curl 'https://your-workspace.crewvana.com/api/subscriptions/10/lifecycle' \
  --request POST \
  --user 'YOUR_API_KEY:' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: replace-with-a-unique-request-id' \
  --data '{"actor_user_id":2,"action":"pause","lock_version":1}'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/subscriptions/10/lifecycle');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/json', 'Idempotency-Key: replace-with-a-unique-request-id'],
    CURLOPT_POSTFIELDS => '{"actor_user_id":2,"action":"pause","lock_version":1}',
    CURLOPT_RETURNTRANSFER => true,
]);
echo curl_exec($request);
curl_close($request);

NodeJs

// Run on the server; keep API keys out of browser code.
const response = await fetch('https://your-workspace.crewvana.com/api/subscriptions/10/lifecycle', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer YOUR_API_KEY',
    Accept: 'application/json',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'replace-with-a-unique-request-id',
  },
  body: JSON.stringify({"actor_user_id":2,"action":"pause","lock_version":1}),
});
console.log(await response.json());

Python

import json
import requests

response = requests.request(
    'POST',
    'https://your-workspace.crewvana.com/api/subscriptions/10/lifecycle',
    auth=('YOUR_API_KEY', ''),
    headers={'Accept': 'application/json', 'Idempotency-Key': 'replace-with-a-unique-request-id'},
    json=json.loads("{\"actor_user_id\":2,\"action\":\"pause\",\"lock_version\":1}"),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/subscriptions/10/lifecycle')
request = Net::HTTP::Post.new(url)
request.basic_auth('YOUR_API_KEY', '')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request['Idempotency-Key'] = 'replace-with-a-unique-request-id'
request.body = "{\"actor_user_id\":2,\"action\":\"pause\",\"lock_version\":1}"
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) { |http| http.request(request) }
puts response.body

Use a fresh request ID for each intended write. Retain the same ID and body when retrying.

List Jobs

GET /api/jobs

Authentication: API key and IP allowlist

Optional exact filters: contact_id, contact_subscription_id, status, paid_status (unpaid/partial/paid). Results contain bounded Visit summaries with technician check-ins.

Input

ParameterFormatRequiredDescription
limitIntegerOptional1–100, default 25.
offsetIntegerOptional0–10000, default 0.

cURL

curl 'https://your-workspace.crewvana.com/api/jobs?limit=25' \
  --header 'Accept: application/json' \
  --user 'YOUR_API_KEY:'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/jobs?limit=25');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'GET',
    CURLOPT_HTTPHEADER => ['Accept: application/json'],
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($request);
curl_close($request);
echo $response;

NodeJs

// Node.js 18 or later, in an ES module (.mjs). Keep API keys on the server.
const response = await fetch('https://your-workspace.crewvana.com/api/jobs?limit=25', {
  method: 'GET',
  headers: {
    Accept: 'application/json',
    Authorization: 'Basic ' + Buffer.from('YOUR_API_KEY:').toString('base64'),
  },
});
console.log(await response.json());

Python

# Install requests with: python -m pip install requests
import requests

response = requests.request(
    'GET',
    'https://your-workspace.crewvana.com/api/jobs?limit=25',
    headers={'Accept': 'application/json'},
    auth=('YOUR_API_KEY', ''),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/jobs?limit=25')
request = Net::HTTP::Get.new(url)
request['Accept'] = 'application/json'
request.basic_auth('YOUR_API_KEY', '')
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
  http.request(request)
end
puts response.body

List a Contact’s Jobs

GET /api/contacts/{contact}/jobs

Authentication: API key and IP allowlist

Same filters, fields and bounds as /api/jobs, constrained to the path Contact. Unknown Contact returns 404.

Input

ParameterFormatRequiredDescription
limitIntegerOptional1–100, default 25.
offsetIntegerOptional0–10000, default 0.

cURL

curl 'https://your-workspace.crewvana.com/api/contacts/42/jobs?limit=25' \
  --header 'Accept: application/json' \
  --user 'YOUR_API_KEY:'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/contacts/42/jobs?limit=25');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'GET',
    CURLOPT_HTTPHEADER => ['Accept: application/json'],
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($request);
curl_close($request);
echo $response;

NodeJs

// Node.js 18 or later, in an ES module (.mjs). Keep API keys on the server.
const response = await fetch('https://your-workspace.crewvana.com/api/contacts/42/jobs?limit=25', {
  method: 'GET',
  headers: {
    Accept: 'application/json',
    Authorization: 'Basic ' + Buffer.from('YOUR_API_KEY:').toString('base64'),
  },
});
console.log(await response.json());

Python

# Install requests with: python -m pip install requests
import requests

response = requests.request(
    'GET',
    'https://your-workspace.crewvana.com/api/contacts/42/jobs?limit=25',
    headers={'Accept': 'application/json'},
    auth=('YOUR_API_KEY', ''),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/contacts/42/jobs?limit=25')
request = Net::HTTP::Get.new(url)
request['Accept'] = 'application/json'
request.basic_auth('YOUR_API_KEY', '')
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
  http.request(request)
end
puts response.body

Read a Job and execution records

GET /api/jobs/{job}

Authentication: API key and IP allowlist

Returns job, catalog-named line_items, Visits and technician check-in/out, Subscription occurrence, Invoice IDs, media metadata and message logs. No payment tokens, provider payloads, storage paths or API credentials. Media/logs/technicians are capped at 100 and report truncation; use /api/visits/{visit} for checklist and line execution detail.

Input

ParameterFormatRequiredDescription
line_limit / visit_limitIntegerOptional1–100; defaults 100 and 25.
line_offset / visit_offsetIntegerOptional0–10000; continue each nested page independently.

cURL

curl 'https://your-workspace.crewvana.com/api/jobs/100' \
  --header 'Accept: application/json' \
  --user 'YOUR_API_KEY:'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/jobs/100');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'GET',
    CURLOPT_HTTPHEADER => ['Accept: application/json'],
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($request);
curl_close($request);
echo $response;

NodeJs

// Node.js 18 or later, in an ES module (.mjs). Keep API keys on the server.
const response = await fetch('https://your-workspace.crewvana.com/api/jobs/100', {
  method: 'GET',
  headers: {
    Accept: 'application/json',
    Authorization: 'Basic ' + Buffer.from('YOUR_API_KEY:').toString('base64'),
  },
});
console.log(await response.json());

Python

# Install requests with: python -m pip install requests
import requests

response = requests.request(
    'GET',
    'https://your-workspace.crewvana.com/api/jobs/100',
    headers={'Accept': 'application/json'},
    auth=('YOUR_API_KEY', ''),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/jobs/100')
request = Net::HTTP::Get.new(url)
request['Accept'] = 'application/json'
request.basic_auth('YOUR_API_KEY', '')
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
  http.request(request)
end
puts response.body

Create a Job with Visits

POST /api/jobs

Authentication: API key and IP allowlist

Creates the complete graph transactionally. Default is draft when unscheduled or Scheduled when a Visit has a schedule. Optional status is draft or Scheduled. Line and Visit inputs are described below. No explicit customer confirmation is sent; normal configured model-trigger Automations still apply.

Input

ParameterFormatRequiredDescription
actor_user_idInteger (JSON body)RequiredActive workspace User whose application permissions authorize the operation. API-key identity is recorded separately.
Idempotency-KeyHTTP headerRequired1–100 ASCII letters, digits, dot, underscore, colon or hyphen. Reuse for the same request only. A different method/path/body returns 409; successful replay returns the original status/body and Idempotency-Replayed: true.

cURL

curl 'https://your-workspace.crewvana.com/api/jobs' \
  --request POST \
  --user 'YOUR_API_KEY:' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: replace-with-a-unique-request-id' \
  --data '{"actor_user_id":2,"contact_id":42,"line_items":[{"type":"item","price_book_item_id":8,"quantity":1,"price":100}],"visits":[{"scheduled_start":"2026-10-05 10:00:00","scheduled_end":"2026-10-05 11:00:00","technician_ids":[2]}]}'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/jobs');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/json', 'Idempotency-Key: replace-with-a-unique-request-id'],
    CURLOPT_POSTFIELDS => '{"actor_user_id":2,"contact_id":42,"line_items":[{"type":"item","price_book_item_id":8,"quantity":1,"price":100}],"visits":[{"scheduled_start":"2026-10-05 10:00:00","scheduled_end":"2026-10-05 11:00:00","technician_ids":[2]}]}',
    CURLOPT_RETURNTRANSFER => true,
]);
echo curl_exec($request);
curl_close($request);

NodeJs

// Run on the server; keep API keys out of browser code.
const response = await fetch('https://your-workspace.crewvana.com/api/jobs', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer YOUR_API_KEY',
    Accept: 'application/json',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'replace-with-a-unique-request-id',
  },
  body: JSON.stringify({"actor_user_id":2,"contact_id":42,"line_items":[{"type":"item","price_book_item_id":8,"quantity":1,"price":100}],"visits":[{"scheduled_start":"2026-10-05 10:00:00","scheduled_end":"2026-10-05 11:00:00","technician_ids":[2]}]}),
});
console.log(await response.json());

Python

import json
import requests

response = requests.request(
    'POST',
    'https://your-workspace.crewvana.com/api/jobs',
    auth=('YOUR_API_KEY', ''),
    headers={'Accept': 'application/json', 'Idempotency-Key': 'replace-with-a-unique-request-id'},
    json=json.loads("{\"actor_user_id\":2,\"contact_id\":42,\"line_items\":[{\"type\":\"item\",\"price_book_item_id\":8,\"quantity\":1,\"price\":100}],\"visits\":[{\"scheduled_start\":\"2026-10-05 10:00:00\",\"scheduled_end\":\"2026-10-05 11:00:00\",\"technician_ids\":[2]}]}"),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/jobs')
request = Net::HTTP::Post.new(url)
request.basic_auth('YOUR_API_KEY', '')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request['Idempotency-Key'] = 'replace-with-a-unique-request-id'
request.body = "{\"actor_user_id\":2,\"contact_id\":42,\"line_items\":[{\"type\":\"item\",\"price_book_item_id\":8,\"quantity\":1,\"price\":100}],\"visits\":[{\"scheduled_start\":\"2026-10-05 10:00:00\",\"scheduled_end\":\"2026-10-05 11:00:00\",\"technician_ids\":[2]}]}"
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) { |http| http.request(request) }
puts response.body

Use a fresh request ID for each intended write. Retain the same ID and body when retrying.

Update a Job

PATCH /api/jobs/{job}

Authentication: API key and IP allowlist

Sparse update of contact_id, opportunity_id, contact_address_id, contact_phone_id, contact_email_id, notes, apply_tax, line_items. Related identity and Opportunity IDs must belong to the Contact. Line_items replaces all lines; omit to preserve them. Commercial/customer changes require no protected history. Paid state, totals and execution status are server-owned. Schedule changes use Visit CRUD.

Input

ParameterFormatRequiredDescription
actor_user_idInteger (JSON body)RequiredActive workspace User whose application permissions authorize the operation. API-key identity is recorded separately.
Idempotency-KeyHTTP headerRequired1–100 ASCII letters, digits, dot, underscore, colon or hyphen. Reuse for the same request only. A different method/path/body returns 409; successful replay returns the original status/body and Idempotency-Replayed: true.
expected_updated_atString (JSON body)Required for existing Jobs/VisitsCopy the raw updated_at value from GET, formatted YYYY-MM-DD HH:MM:SS. Stale values return 409. It checks the resource row, not every related row.

cURL

curl 'https://your-workspace.crewvana.com/api/jobs/100' \
  --request PATCH \
  --user 'YOUR_API_KEY:' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: replace-with-a-unique-request-id' \
  --data '{"actor_user_id":2,"expected_updated_at":"2026-10-05 18:00:00","notes":"Updated notes"}'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/jobs/100');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'PATCH',
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/json', 'Idempotency-Key: replace-with-a-unique-request-id'],
    CURLOPT_POSTFIELDS => '{"actor_user_id":2,"expected_updated_at":"2026-10-05 18:00:00","notes":"Updated notes"}',
    CURLOPT_RETURNTRANSFER => true,
]);
echo curl_exec($request);
curl_close($request);

NodeJs

// Run on the server; keep API keys out of browser code.
const response = await fetch('https://your-workspace.crewvana.com/api/jobs/100', {
  method: 'PATCH',
  headers: {
    Authorization: 'Bearer YOUR_API_KEY',
    Accept: 'application/json',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'replace-with-a-unique-request-id',
  },
  body: JSON.stringify({"actor_user_id":2,"expected_updated_at":"2026-10-05 18:00:00","notes":"Updated notes"}),
});
console.log(await response.json());

Python

import json
import requests

response = requests.request(
    'PATCH',
    'https://your-workspace.crewvana.com/api/jobs/100',
    auth=('YOUR_API_KEY', ''),
    headers={'Accept': 'application/json', 'Idempotency-Key': 'replace-with-a-unique-request-id'},
    json=json.loads("{\"actor_user_id\":2,\"expected_updated_at\":\"2026-10-05 18:00:00\",\"notes\":\"Updated notes\"}"),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/jobs/100')
request = Net::HTTP::Patch.new(url)
request.basic_auth('YOUR_API_KEY', '')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request['Idempotency-Key'] = 'replace-with-a-unique-request-id'
request.body = "{\"actor_user_id\":2,\"expected_updated_at\":\"2026-10-05 18:00:00\",\"notes\":\"Updated notes\"}"
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) { |http| http.request(request) }
puts response.body

Use a fresh request ID for each intended write. Retain the same ID and body when retrying.

Delete a Job draft

DELETE /api/jobs/{job}

Authentication: API key and IP allowlist

Only history-free draft/quoted Jobs can be deleted. Invoices, payments, check-ins, completion, checklist answers, media and compensation protect history. Use guarded cancellation for retained operational records.

Input

ParameterFormatRequiredDescription
actor_user_idInteger (JSON body)RequiredActive workspace User whose application permissions authorize the operation. API-key identity is recorded separately.
Idempotency-KeyHTTP headerRequired1–100 ASCII letters, digits, dot, underscore, colon or hyphen. Reuse for the same request only. A different method/path/body returns 409; successful replay returns the original status/body and Idempotency-Replayed: true.
expected_updated_atString (JSON body)Required for existing Jobs/VisitsCopy the raw updated_at value from GET, formatted YYYY-MM-DD HH:MM:SS. Stale values return 409. It checks the resource row, not every related row.

cURL

curl 'https://your-workspace.crewvana.com/api/jobs/100' \
  --request DELETE \
  --user 'YOUR_API_KEY:' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: replace-with-a-unique-request-id' \
  --data '{"actor_user_id":2,"expected_updated_at":"2026-10-05 18:00:00"}'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/jobs/100');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'DELETE',
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/json', 'Idempotency-Key: replace-with-a-unique-request-id'],
    CURLOPT_POSTFIELDS => '{"actor_user_id":2,"expected_updated_at":"2026-10-05 18:00:00"}',
    CURLOPT_RETURNTRANSFER => true,
]);
echo curl_exec($request);
curl_close($request);

NodeJs

// Run on the server; keep API keys out of browser code.
const response = await fetch('https://your-workspace.crewvana.com/api/jobs/100', {
  method: 'DELETE',
  headers: {
    Authorization: 'Bearer YOUR_API_KEY',
    Accept: 'application/json',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'replace-with-a-unique-request-id',
  },
  body: JSON.stringify({"actor_user_id":2,"expected_updated_at":"2026-10-05 18:00:00"}),
});
console.log(await response.json());

Python

import json
import requests

response = requests.request(
    'DELETE',
    'https://your-workspace.crewvana.com/api/jobs/100',
    auth=('YOUR_API_KEY', ''),
    headers={'Accept': 'application/json', 'Idempotency-Key': 'replace-with-a-unique-request-id'},
    json=json.loads("{\"actor_user_id\":2,\"expected_updated_at\":\"2026-10-05 18:00:00\"}"),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/jobs/100')
request = Net::HTTP::Delete.new(url)
request.basic_auth('YOUR_API_KEY', '')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request['Idempotency-Key'] = 'replace-with-a-unique-request-id'
request.body = "{\"actor_user_id\":2,\"expected_updated_at\":\"2026-10-05 18:00:00\"}"
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) { |http| http.request(request) }
puts response.body

Use a fresh request ID for each intended write. Retain the same ID and body when retrying.

Cancel a Job

POST /api/jobs/{job}/cancel

Authentication: API key and IP allowlist

Uses canonical Job cancellation, Invoice lifecycle, Subscription exceptions and protected-future rules. Returns affected/protected Job IDs. No customer notification is sent.

Input

ParameterFormatRequiredDescription
actor_user_idInteger (JSON body)RequiredActive workspace User whose application permissions authorize the operation. API-key identity is recorded separately.
Idempotency-KeyHTTP headerRequired1–100 ASCII letters, digits, dot, underscore, colon or hyphen. Reuse for the same request only. A different method/path/body returns 409; successful replay returns the original status/body and Idempotency-Replayed: true.
expected_updated_atString (JSON body)Required for existing Jobs/VisitsCopy the raw updated_at value from GET, formatted YYYY-MM-DD HH:MM:SS. Stale values return 409. It checks the resource row, not every related row.
reasonString, max 120RequiredNonblank reason.
scopeStringRequiredone_occurrence or this_and_future
confirm_futureBooleanRequired for this_and_futureMust be true.
billing_decisionStringConditionalretain_invoice when completed work or collected money requires retaining charges; requires payments.update.

cURL

curl 'https://your-workspace.crewvana.com/api/jobs/100/cancel' \
  --request POST \
  --user 'YOUR_API_KEY:' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: replace-with-a-unique-request-id' \
  --data '{"actor_user_id":2,"expected_updated_at":"2026-10-05 18:00:00","reason":"Duplicate booking","scope":"one_occurrence"}'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/jobs/100/cancel');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/json', 'Idempotency-Key: replace-with-a-unique-request-id'],
    CURLOPT_POSTFIELDS => '{"actor_user_id":2,"expected_updated_at":"2026-10-05 18:00:00","reason":"Duplicate booking","scope":"one_occurrence"}',
    CURLOPT_RETURNTRANSFER => true,
]);
echo curl_exec($request);
curl_close($request);

NodeJs

// Run on the server; keep API keys out of browser code.
const response = await fetch('https://your-workspace.crewvana.com/api/jobs/100/cancel', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer YOUR_API_KEY',
    Accept: 'application/json',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'replace-with-a-unique-request-id',
  },
  body: JSON.stringify({"actor_user_id":2,"expected_updated_at":"2026-10-05 18:00:00","reason":"Duplicate booking","scope":"one_occurrence"}),
});
console.log(await response.json());

Python

import json
import requests

response = requests.request(
    'POST',
    'https://your-workspace.crewvana.com/api/jobs/100/cancel',
    auth=('YOUR_API_KEY', ''),
    headers={'Accept': 'application/json', 'Idempotency-Key': 'replace-with-a-unique-request-id'},
    json=json.loads("{\"actor_user_id\":2,\"expected_updated_at\":\"2026-10-05 18:00:00\",\"reason\":\"Duplicate booking\",\"scope\":\"one_occurrence\"}"),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/jobs/100/cancel')
request = Net::HTTP::Post.new(url)
request.basic_auth('YOUR_API_KEY', '')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request['Idempotency-Key'] = 'replace-with-a-unique-request-id'
request.body = "{\"actor_user_id\":2,\"expected_updated_at\":\"2026-10-05 18:00:00\",\"reason\":\"Duplicate booking\",\"scope\":\"one_occurrence\"}"
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) { |http| http.request(request) }
puts response.body

Use a fresh request ID for each intended write. Retain the same ID and body when retrying.

List Visits

GET /api/visits

Authentication: API key and IP allowlist

Optional filters: service_job_id, contact_id, technician_id, status, from, to. Timestamp bounds use YYYY-MM-DD HH:MM:SS and inclusive comparisons against stored schedule values. Results are ordered by scheduled_start then ID.

Input

ParameterFormatRequiredDescription
limitIntegerOptional1–100, default 25.
offsetIntegerOptional0–10000, default 0.

cURL

curl 'https://your-workspace.crewvana.com/api/visits?limit=25' \
  --header 'Accept: application/json' \
  --user 'YOUR_API_KEY:'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/visits?limit=25');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'GET',
    CURLOPT_HTTPHEADER => ['Accept: application/json'],
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($request);
curl_close($request);
echo $response;

NodeJs

// Node.js 18 or later, in an ES module (.mjs). Keep API keys on the server.
const response = await fetch('https://your-workspace.crewvana.com/api/visits?limit=25', {
  method: 'GET',
  headers: {
    Accept: 'application/json',
    Authorization: 'Basic ' + Buffer.from('YOUR_API_KEY:').toString('base64'),
  },
});
console.log(await response.json());

Python

# Install requests with: python -m pip install requests
import requests

response = requests.request(
    'GET',
    'https://your-workspace.crewvana.com/api/visits?limit=25',
    headers={'Accept': 'application/json'},
    auth=('YOUR_API_KEY', ''),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/visits?limit=25')
request = Net::HTTP::Get.new(url)
request['Accept'] = 'application/json'
request.basic_auth('YOUR_API_KEY', '')
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
  http.request(request)
end
puts response.body

List a Job’s Visits

GET /api/jobs/{job}/visits

Authentication: API key and IP allowlist

Same fields, filters and bounds as /api/visits, constrained to the path Job. Unknown Job returns 404.

Input

ParameterFormatRequiredDescription
limitIntegerOptional1–100, default 25.
offsetIntegerOptional0–10000, default 0.

cURL

curl 'https://your-workspace.crewvana.com/api/jobs/100/visits?limit=25' \
  --header 'Accept: application/json' \
  --user 'YOUR_API_KEY:'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/jobs/100/visits?limit=25');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'GET',
    CURLOPT_HTTPHEADER => ['Accept: application/json'],
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($request);
curl_close($request);
echo $response;

NodeJs

// Node.js 18 or later, in an ES module (.mjs). Keep API keys on the server.
const response = await fetch('https://your-workspace.crewvana.com/api/jobs/100/visits?limit=25', {
  method: 'GET',
  headers: {
    Accept: 'application/json',
    Authorization: 'Basic ' + Buffer.from('YOUR_API_KEY:').toString('base64'),
  },
});
console.log(await response.json());

Python

# Install requests with: python -m pip install requests
import requests

response = requests.request(
    'GET',
    'https://your-workspace.crewvana.com/api/jobs/100/visits?limit=25',
    headers={'Accept': 'application/json'},
    auth=('YOUR_API_KEY', ''),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/jobs/100/visits?limit=25')
request = Net::HTTP::Get.new(url)
request['Accept'] = 'application/json'
request.basic_auth('YOUR_API_KEY', '')
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
  http.request(request)
end
puts response.body

Read a Visit’s execution records

GET /api/visits/{visit}

Authentication: API key and IP allowlist

Returns Visit identity, status, schedule and up to 100 technician check-ins; paginated line completion rows, checklist review summaries and answers with actor attribution. No provider data or customer payment links.

Input

ParameterFormatRequiredDescription
line_limit / checklist_limit / response_limitIntegerOptional1–100, default 100.
line_offset / checklist_offset / response_offsetIntegerOptional0–10000, default 0; continue each nested page.

cURL

curl 'https://your-workspace.crewvana.com/api/visits/101' \
  --header 'Accept: application/json' \
  --user 'YOUR_API_KEY:'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/visits/101');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'GET',
    CURLOPT_HTTPHEADER => ['Accept: application/json'],
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($request);
curl_close($request);
echo $response;

NodeJs

// Node.js 18 or later, in an ES module (.mjs). Keep API keys on the server.
const response = await fetch('https://your-workspace.crewvana.com/api/visits/101', {
  method: 'GET',
  headers: {
    Accept: 'application/json',
    Authorization: 'Basic ' + Buffer.from('YOUR_API_KEY:').toString('base64'),
  },
});
console.log(await response.json());

Python

# Install requests with: python -m pip install requests
import requests

response = requests.request(
    'GET',
    'https://your-workspace.crewvana.com/api/visits/101',
    headers={'Accept': 'application/json'},
    auth=('YOUR_API_KEY', ''),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/visits/101')
request = Net::HTTP::Get.new(url)
request['Accept'] = 'application/json'
request.basic_auth('YOUR_API_KEY', '')
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
  http.request(request)
end
puts response.body

Add a Visit to a Job

POST /api/jobs/{job}/visits

Authentication: API key and IP allowlist

Creates a non-default Visit with the next sequence. Requires Job update-all permission. Completed/cancelled Jobs cannot gain Visits through CRUD.

Input

ParameterFormatRequiredDescription
actor_user_idInteger (JSON body)RequiredActive workspace User whose application permissions authorize the operation. API-key identity is recorded separately.
Idempotency-KeyHTTP headerRequired1–100 ASCII letters, digits, dot, underscore, colon or hyphen. Reuse for the same request only. A different method/path/body returns 409; successful replay returns the original status/body and Idempotency-Replayed: true.

cURL

curl 'https://your-workspace.crewvana.com/api/jobs/100/visits' \
  --request POST \
  --user 'YOUR_API_KEY:' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: replace-with-a-unique-request-id' \
  --data '{"actor_user_id":2,"scheduled_start":"2026-10-06 10:00:00","scheduled_end":"2026-10-06 11:00:00","technician_ids":[2]}'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/jobs/100/visits');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/json', 'Idempotency-Key: replace-with-a-unique-request-id'],
    CURLOPT_POSTFIELDS => '{"actor_user_id":2,"scheduled_start":"2026-10-06 10:00:00","scheduled_end":"2026-10-06 11:00:00","technician_ids":[2]}',
    CURLOPT_RETURNTRANSFER => true,
]);
echo curl_exec($request);
curl_close($request);

NodeJs

// Run on the server; keep API keys out of browser code.
const response = await fetch('https://your-workspace.crewvana.com/api/jobs/100/visits', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer YOUR_API_KEY',
    Accept: 'application/json',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'replace-with-a-unique-request-id',
  },
  body: JSON.stringify({"actor_user_id":2,"scheduled_start":"2026-10-06 10:00:00","scheduled_end":"2026-10-06 11:00:00","technician_ids":[2]}),
});
console.log(await response.json());

Python

import json
import requests

response = requests.request(
    'POST',
    'https://your-workspace.crewvana.com/api/jobs/100/visits',
    auth=('YOUR_API_KEY', ''),
    headers={'Accept': 'application/json', 'Idempotency-Key': 'replace-with-a-unique-request-id'},
    json=json.loads("{\"actor_user_id\":2,\"scheduled_start\":\"2026-10-06 10:00:00\",\"scheduled_end\":\"2026-10-06 11:00:00\",\"technician_ids\":[2]}"),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/jobs/100/visits')
request = Net::HTTP::Post.new(url)
request.basic_auth('YOUR_API_KEY', '')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request['Idempotency-Key'] = 'replace-with-a-unique-request-id'
request.body = "{\"actor_user_id\":2,\"scheduled_start\":\"2026-10-06 10:00:00\",\"scheduled_end\":\"2026-10-06 11:00:00\",\"technician_ids\":[2]}"
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) { |http| http.request(request) }
puts response.body

Use a fresh request ID for each intended write. Retain the same ID and body when retrying.

Update a Visit schedule or assignment

PATCH /api/visits/{visit}

Authentication: API key and IP allowlist

Sparse update of scheduled_start/end, technician_ids, allow_block_overlap. A null start unschedules the Visit. No implicit Job creation. Completed/cancelled Visits and Jobs require their lifecycle workflow. Other-technician assignment requires update-all. Sequence, default identity, execution status and check-in timestamps cannot be overwritten.

Input

ParameterFormatRequiredDescription
actor_user_idInteger (JSON body)RequiredActive workspace User whose application permissions authorize the operation. API-key identity is recorded separately.
Idempotency-KeyHTTP headerRequired1–100 ASCII letters, digits, dot, underscore, colon or hyphen. Reuse for the same request only. A different method/path/body returns 409; successful replay returns the original status/body and Idempotency-Replayed: true.
expected_updated_atString (JSON body)Required for existing Jobs/VisitsCopy the raw updated_at value from GET, formatted YYYY-MM-DD HH:MM:SS. Stale values return 409. It checks the resource row, not every related row.

cURL

curl 'https://your-workspace.crewvana.com/api/visits/101' \
  --request PATCH \
  --user 'YOUR_API_KEY:' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: replace-with-a-unique-request-id' \
  --data '{"actor_user_id":2,"expected_updated_at":"2026-10-05 18:00:00","scheduled_start":"2026-10-06 13:00:00","scheduled_end":"2026-10-06 14:00:00"}'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/visits/101');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'PATCH',
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/json', 'Idempotency-Key: replace-with-a-unique-request-id'],
    CURLOPT_POSTFIELDS => '{"actor_user_id":2,"expected_updated_at":"2026-10-05 18:00:00","scheduled_start":"2026-10-06 13:00:00","scheduled_end":"2026-10-06 14:00:00"}',
    CURLOPT_RETURNTRANSFER => true,
]);
echo curl_exec($request);
curl_close($request);

NodeJs

// Run on the server; keep API keys out of browser code.
const response = await fetch('https://your-workspace.crewvana.com/api/visits/101', {
  method: 'PATCH',
  headers: {
    Authorization: 'Bearer YOUR_API_KEY',
    Accept: 'application/json',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'replace-with-a-unique-request-id',
  },
  body: JSON.stringify({"actor_user_id":2,"expected_updated_at":"2026-10-05 18:00:00","scheduled_start":"2026-10-06 13:00:00","scheduled_end":"2026-10-06 14:00:00"}),
});
console.log(await response.json());

Python

import json
import requests

response = requests.request(
    'PATCH',
    'https://your-workspace.crewvana.com/api/visits/101',
    auth=('YOUR_API_KEY', ''),
    headers={'Accept': 'application/json', 'Idempotency-Key': 'replace-with-a-unique-request-id'},
    json=json.loads("{\"actor_user_id\":2,\"expected_updated_at\":\"2026-10-05 18:00:00\",\"scheduled_start\":\"2026-10-06 13:00:00\",\"scheduled_end\":\"2026-10-06 14:00:00\"}"),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/visits/101')
request = Net::HTTP::Patch.new(url)
request.basic_auth('YOUR_API_KEY', '')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request['Idempotency-Key'] = 'replace-with-a-unique-request-id'
request.body = "{\"actor_user_id\":2,\"expected_updated_at\":\"2026-10-05 18:00:00\",\"scheduled_start\":\"2026-10-06 13:00:00\",\"scheduled_end\":\"2026-10-06 14:00:00\"}"
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) { |http| http.request(request) }
puts response.body

Use a fresh request ID for each intended write. Retain the same ID and body when retrying.

Delete a Visit

DELETE /api/visits/{visit}

Authentication: API key and IP allowlist

Default/only Visits and Visits with execution, media or generated occurrence history cannot be deleted. Remaining sequence and checklist assignment are reconciled canonically.

Input

ParameterFormatRequiredDescription
actor_user_idInteger (JSON body)RequiredActive workspace User whose application permissions authorize the operation. API-key identity is recorded separately.
Idempotency-KeyHTTP headerRequired1–100 ASCII letters, digits, dot, underscore, colon or hyphen. Reuse for the same request only. A different method/path/body returns 409; successful replay returns the original status/body and Idempotency-Replayed: true.
expected_updated_atString (JSON body)Required for existing Jobs/VisitsCopy the raw updated_at value from GET, formatted YYYY-MM-DD HH:MM:SS. Stale values return 409. It checks the resource row, not every related row.

cURL

curl 'https://your-workspace.crewvana.com/api/visits/101' \
  --request DELETE \
  --user 'YOUR_API_KEY:' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: replace-with-a-unique-request-id' \
  --data '{"actor_user_id":2,"expected_updated_at":"2026-10-05 18:00:00"}'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/visits/101');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'DELETE',
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/json', 'Idempotency-Key: replace-with-a-unique-request-id'],
    CURLOPT_POSTFIELDS => '{"actor_user_id":2,"expected_updated_at":"2026-10-05 18:00:00"}',
    CURLOPT_RETURNTRANSFER => true,
]);
echo curl_exec($request);
curl_close($request);

NodeJs

// Run on the server; keep API keys out of browser code.
const response = await fetch('https://your-workspace.crewvana.com/api/visits/101', {
  method: 'DELETE',
  headers: {
    Authorization: 'Bearer YOUR_API_KEY',
    Accept: 'application/json',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'replace-with-a-unique-request-id',
  },
  body: JSON.stringify({"actor_user_id":2,"expected_updated_at":"2026-10-05 18:00:00"}),
});
console.log(await response.json());

Python

import json
import requests

response = requests.request(
    'DELETE',
    'https://your-workspace.crewvana.com/api/visits/101',
    auth=('YOUR_API_KEY', ''),
    headers={'Accept': 'application/json', 'Idempotency-Key': 'replace-with-a-unique-request-id'},
    json=json.loads("{\"actor_user_id\":2,\"expected_updated_at\":\"2026-10-05 18:00:00\"}"),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/visits/101')
request = Net::HTTP::Delete.new(url)
request.basic_auth('YOUR_API_KEY', '')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request['Idempotency-Key'] = 'replace-with-a-unique-request-id'
request.body = "{\"actor_user_id\":2,\"expected_updated_at\":\"2026-10-05 18:00:00\"}"
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) { |http| http.request(request) }
puts response.body

Use a fresh request ID for each intended write. Retain the same ID and body when retrying.

Cancel one Visit

POST /api/visits/{visit}/cancel

Authentication: API key and IP allowlist

Cancels a non-completed Visit. Use Job cancellation for the last required Visit so billing and Subscription exceptions are reviewed. Does not send a message.

Input

ParameterFormatRequiredDescription
actor_user_idInteger (JSON body)RequiredActive workspace User whose application permissions authorize the operation. API-key identity is recorded separately.
Idempotency-KeyHTTP headerRequired1–100 ASCII letters, digits, dot, underscore, colon or hyphen. Reuse for the same request only. A different method/path/body returns 409; successful replay returns the original status/body and Idempotency-Replayed: true.
expected_updated_atString (JSON body)Required for existing Jobs/VisitsCopy the raw updated_at value from GET, formatted YYYY-MM-DD HH:MM:SS. Stale values return 409. It checks the resource row, not every related row.

cURL

curl 'https://your-workspace.crewvana.com/api/visits/101/cancel' \
  --request POST \
  --user 'YOUR_API_KEY:' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: replace-with-a-unique-request-id' \
  --data '{"actor_user_id":2,"expected_updated_at":"2026-10-05 18:00:00"}'

PHP

<?php
$request = curl_init('https://your-workspace.crewvana.com/api/visits/101/cancel');
curl_setopt_array($request, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_USERPWD => 'YOUR_API_KEY:',
    CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/json', 'Idempotency-Key: replace-with-a-unique-request-id'],
    CURLOPT_POSTFIELDS => '{"actor_user_id":2,"expected_updated_at":"2026-10-05 18:00:00"}',
    CURLOPT_RETURNTRANSFER => true,
]);
echo curl_exec($request);
curl_close($request);

NodeJs

// Run on the server; keep API keys out of browser code.
const response = await fetch('https://your-workspace.crewvana.com/api/visits/101/cancel', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer YOUR_API_KEY',
    Accept: 'application/json',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'replace-with-a-unique-request-id',
  },
  body: JSON.stringify({"actor_user_id":2,"expected_updated_at":"2026-10-05 18:00:00"}),
});
console.log(await response.json());

Python

import json
import requests

response = requests.request(
    'POST',
    'https://your-workspace.crewvana.com/api/visits/101/cancel',
    auth=('YOUR_API_KEY', ''),
    headers={'Accept': 'application/json', 'Idempotency-Key': 'replace-with-a-unique-request-id'},
    json=json.loads("{\"actor_user_id\":2,\"expected_updated_at\":\"2026-10-05 18:00:00\"}"),
    timeout=30,
)
print(response.json())

Ruby

require 'net/http'
require 'uri'

url = URI('https://your-workspace.crewvana.com/api/visits/101/cancel')
request = Net::HTTP::Post.new(url)
request.basic_auth('YOUR_API_KEY', '')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request['Idempotency-Key'] = 'replace-with-a-unique-request-id'
request.body = "{\"actor_user_id\":2,\"expected_updated_at\":\"2026-10-05 18:00:00\"}"
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) { |http| http.request(request) }
puts response.body

Use a fresh request ID for each intended write. Retain the same ID and body when retrying.