v1.0
ShiftScale Integration Platform
ShiftScale is a bidirectional sync engine connecting CDK/Fortellis (dealership management) with GoHighLevel (CRM/marketing). It handles contacts, vehicles, and custom objects with rate limiting, deduplication, and real-time webhook support.
👤
Contacts
Bidirectional sync with dedup
🚗
Vehicles
Custom object mapping to GHL
⚡
Real-time
Webhook-driven instant sync
Quickstart
Get a connection syncing in three steps:
1
Create a Connection
Pair a CDK Subscription ID with a GHL Location ID. Your GHL API key is encrypted at rest.
POST /api/connections
{
"name": "Bob Boyte Honda",
"cdkSubscriptionId": "cdeee2fe-...",
"ghlLocationId": "RjStKuwEU0Ldr2KqEbuB",
"ghlApiKey": "eyJ...",
"syncDirection": "CDK_TO_GHL"
}2
Configure Field Mappings
Map CDK fields to GHL fields. Supports nested paths like emails[0].address.
POST /api/connections/{id}/mappings
{
"entityType": "CONTACT",
"cdkField": "emails[0].address",
"ghlField": "email",
"direction": "CDK_TO_GHL"
}3
Trigger a Sync
Run manually or schedule with a Job. The engine handles pagination, rate limits, and dedup.
POST /api/sync/{connectionId}/trigger
{
"entityType": "CONTACT",
"maxRecords": 50
}Architecture
Data Flow
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ CDK / CRM │ ──────▶ │ ShiftScale │ ──────▶ │ GHL │
│ (Fortellis) │ ◀────── │ Sync Engine│ ◀────── │ (CRM) │
└─────────────┘ └──────────────┘ └─────────────┘
│
┌─────┴─────┐
│ Postgres │ RecordLinks, SyncRuns,
│ DB │ FieldMappings, AuditLogs
└───────────┘Batch sync runs on a schedule (or manually) and processes records in pages of 100. Webhook sync handles individual record changes in real-time. Both paths create audit trails via SyncRun and SyncLog records.
Connections
A Connection pairs a CDK subscription with a GHL location. It stores credentials, sync direction, filters, and tags.
ParameterTypeReq.Description
namestringreqHuman-readable name (e.g. 'Bob Boyte Honda')
cdkSubscriptionIdstringreqFortellis subscription UUID
ghlLocationIdstringreqGHL location ID
ghlApiKeystringreqGHL API token (AES-256 encrypted at rest)
syncDirectionenumoptCDK_TO_GHL | GHL_TO_CDK | BIDIRECTIONAL
ghlTagsstring[]optTags applied to every synced GHL contact
syncFiltersjsonoptFilter rules: requireEmail, requirePhone, requireEither
Sync Filters
Pre-filter records before sync to skip contacts missing critical data:
// Only sync contacts that have an email OR phone
{
"requireEither": true,
"requireEmail": false,
"requirePhone": false
}Field Mappings
Field mappings define how data transforms between CDK and GHL. Each mapping specifies a source field, target field, optional transform, direction, and target type.
CDK Nested Paths
CDK stores data in nested structures. Use dot notation and array indexing:
CDK field path examples
emails[0].address → First email address
phones[0].number → First phone number
address.addressLine1 → Street address
address.city → City
address.state → State
address.zip → ZIP code
Custom Object Mappings
To sync vehicles or other entities to GHL custom objects, set ghlTargetType to custom_object and specify the object key:
{
"entityType": "VEHICLE",
"cdkField": "vin",
"ghlField": "vin",
"ghlTargetType": "custom_object",
"ghlCustomObjectKey": "custom_objects.vehicles",
"direction": "CDK_TO_GHL"
}Direction
BIDIRECTIONALBoth directions
Record Links
Every synced record creates a RecordLink — the receipt that locks a CDK record to its GHL counterpart. This prevents duplicates and enables targeted updates.
RecordLink example
{
"connectionId": "cmml1tsuh00000ajscv63aliu",
"entityType": "CONTACT",
"cdkRecordId": "12345",
"ghlRecordId": "abc-def-ghi",
"ghlObjectKey": null, // null for contacts
"cdkHash": "sha256...", // detect changes
"ghlHash": "sha256...",
"syncStatus": "COMPLETED"
}ℹ️For custom objects (vehicles, etc.), ghlObjectKey is set to the object key (e.g. custom_objects.vehicles) to distinguish them from contact records.
Sync Engine
The sync engine handles batch synchronization with built-in reliability features:
Rate Limiting
CDK: 100 req/60s sliding window. GHL: 100 req/10s. Automatic exponential backoff on 429/5xx.
Pagination
100 records/page with 500ms delay between pages. Resume token for interrupted syncs.
Deduplication
Searches target system by email/phone before creating. Skips dedup on fresh sync (no existing links).
Timeout Protection
Auto-saves as PARTIAL at 240s (Vercel limit). Re-trigger to resume from where it stopped.
Error Isolation
Per-record error handling. One failure doesn't stop the batch.
Hash Comparison
SHA-256 hash of record data. Unchanged records are skipped.
Sync Statuses
PENDINGIN_PROGRESSCOMPLETEDPARTIALFAILED
Custom Objects
GHL custom objects (e.g. vehicles, service appointments) are synced via the same mapping system. Set ghlTargetType: "custom_object" on your mappings.
💡GHL custom object fields are discovered via GET /objects/{key}. Fields are a top-level array in the response, not nested inside the object.
Vehicle Object Fields
The custom_objects.vehicles object has 17 fields:
vehicle_id, vin, licensePlateNum, makeCode, make,
modelCode, model, modelYear, exteriorColor, mileage,
status, delivered, inService, warrantyExpiration,
ownerHref, primaryDriverHref, href
Webhooks
Webhooks enable real-time sync — when a contact is created or updated in GHL, the change is automatically pushed to CDK within seconds.
Webhook Endpoints
POST/api/webhooks/connections/{id}/ghlGHL → CDK sync
POST/api/webhooks/connections/{id}/cdkCDK → GHL sync
Webhook Setup
Register a webhook to get your URL and HMAC secret:
Register webhook
POST /api/sync/{connectionId}/webhook/register
Response:
{
"webhookUrl": "https://integration.shiftscaledigital.ai/api/webhooks/connections/{id}/ghl",
"secretKey": "a1b2c3d4...",
"instructions": {
"step1": "Go to GHL → Settings → Webhooks (or Automations → Workflows)",
"step2": "Add webhook URL",
"step3": "Select events: Contact Create, Contact Update",
"step4": "Set x-webhook-secret header with the secret key",
"step5": "Changes will auto-sync to CDK"
}
}HMAC Verification
Webhooks are verified using HMAC-SHA256. Include the signature in the request header:
Header: x-webhook-secret
Value: HMAC-SHA256(secret_key, raw_request_body)
ℹ️If no WebhookEndpoint is registered for the connection, signature verification is skipped. Register one for production use.
Webhook Flow
When GHL sends a contact create/update event:
Decision flow
GHL webhook fires (contact created/updated)
│
├─ RecordLink exists for this GHL contact?
│ └─ YES → Update linked CDK record → done ✅
│
└─ NO → Search CDK by email, then phone
├─ CDK match found → Create RecordLink + Update CDK → done ✅
└─ No match → Create new CDK contact + RecordLink → done ✅
Every path ends with a locked two-way link.✅The webhook always returns 200 (even on errors) to prevent GHL from retrying. Check SyncLogs for error details.
Activity Webhook
The activity webhook provides full CRUD for CDK sales activities via a single POST endpoint. Since GHL webhooks only support POST, use the action field to specify the operation.
POST/api/webhooks/connections/{id}/ghl/activityGHL → CDK activity CRUD
⚠️The contactId must be a GHL contact that has already been synced to CDK (i.e. a RecordLink exists). Sync the contact first.
Create Activity
Schedule a new activity in CDK. This is the default action.
action: create (default)
{
"action": "create",
"contactId": "ghl_contact_id",
"type": "Phone Call",
"subject": "Follow-up call",
"description": "Discuss trade-in options",
"scheduledDate": "2025-03-20T10:00:00Z",
"opportunityId": "opp_123",
"userId": "salesperson_456"
}ParameterTypeReq.Description
contactIdstringreqGHL contact ID (must be synced)
typestringoptActivity type (e.g. 'Phone Call', 'Email', 'Appointment'). Default: 'Note'
subjectstringoptActivity subject line
descriptionstringoptActivity description / notes
scheduledDatestringoptISO 8601 date. Defaults to now
opportunityIdstringoptCDK opportunity ID
userIdstringoptCDK salesperson/user ID
List Activities
Retrieve activity history for a synced contact.
action: list
{
"action": "list",
"contactId": "ghl_contact_id",
"dateFrom": "2025-01-01",
"dateTo": "2025-03-31"
}ParameterTypeReq.Description
contactIdstringreqGHL contact ID (must be synced)
dateFromstringoptFilter start date (YYYY-MM-DD)
dateTostringoptFilter end date (YYYY-MM-DD)
Update Activity
Update fields on an existing CDK activity.
action: update
{
"action": "update",
"activityId": "cdk_activity_id",
"subject": "Updated subject",
"scheduledDate": "2025-03-25T14:00:00Z"
}ParameterTypeReq.Description
activityIdstringreqCDK activity ID to update
typestringoptNew activity type
subjectstringoptNew subject
descriptionstringoptNew description
scheduledDatestringoptNew scheduled date (ISO 8601)
Complete Activity
Mark an activity as completed in CDK.
action: complete
{
"action": "complete",
"activityId": "cdk_activity_id",
"completedDate": "2025-03-18T15:30:00Z",
"notes": "Customer confirmed appointment"
}ParameterTypeReq.Description
activityIdstringreqCDK activity ID to complete
completedDatestringoptCompletion date (ISO 8601). Defaults to now
notesstringoptCompletion notes
Response Format
// Success
{
"ok": true,
"action": "CREATE", // CREATE | LIST | UPDATE | COMPLETE
"ghlContactId": "...", // for create/list
"cdkContactId": "...", // for create/list
"cdkActivityId": "...", // for create
"activityId": "...", // for update/complete
"activity": { ... }, // CDK activity object
"activities": [ ... ] // for list action
}
// Error
{
"ok": false,
"error": "Contact not synced to CDK. Sync the contact first."
}Connections API
GET/api/connectionsList all connections
POST/api/connectionsCreate connection
GET/api/connections/{id}Get connection
PUT/api/connections/{id}Update connection
DELETE/api/connections/{id}Delete connection
POST/api/connections/{id}/testTest CDK + GHL credentials
POST/api/connections/{id}/duplicateClone connection
Mappings API
GET/api/connections/{id}/mappingsList field mappings
POST/api/connections/{id}/mappingsCreate mapping
PUT/api/connections/{id}/mappingsBulk update mappings
DELETE/api/connections/{id}/mappings?mappingId=...Delete mapping
ParameterTypeReq.Description
entityTypeenumreqCONTACT | VEHICLE | OPPORTUNITY | LEAD
cdkFieldstringreqCDK source field path
ghlFieldstringreqGHL target field
directionenumreqCDK_TO_GHL | GHL_TO_CDK | BIDIRECTIONAL
transformstringoptJSON transform config
ghlTargetTypestringopt'contact' (default) or 'custom_object'
ghlCustomObjectKeystringopte.g. 'custom_objects.vehicles'
Sync API
POST/api/sync/{connectionId}/triggerTrigger manual sync
GET/api/sync/{connectionId}/runsList sync runs
GET/api/sync/{connectionId}/runs/{runId}Get run details + logs
Trigger Parameters
ParameterTypeReq.Description
entityTypeenumreqCONTACT | VEHICLE
directionenumoptOverride connection default direction
maxRecordsnumberoptLimit records processed (for testing)
Webhooks API
POST/api/webhooks/connections/{id}/ghlGHL → CDK webhook receiver
POST/api/webhooks/connections/{id}/cdkCDK → GHL webhook receiver
POST/api/sync/{connectionId}/webhook/registerRegister webhook + get secret
GHL Webhook Payload
{
"type": "ContactCreate", // or "ContactUpdate"
"id": "ghl_contact_id",
"locationId": "...",
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"phone": "+15551234567"
}Response
{
"ok": true,
"action": "CREATE", // CREATE | UPDATE | SKIP | FAIL
"ghlContactId": "...",
"cdkRecordId": "...",
"error": null
}Records API
GET/api/connections/{id}/recordsList record links for connection
GET/api/recordsGlobal records view (all connections)
DELETE/api/connections/{id}/records/{linkId}Unlink a record
POST/api/connections/{id}/records/resetReset all record links
GET/api/connections/{id}/previewPreview sync without executing
Schema Discovery
GET/api/schemas/cdk/{subscriptionId}/{entity}Get CDK field schema
GET/api/schemas/ghl/{locationId}/fieldsGet GHL contact fields
GET/api/schemas/ghl/{locationId}/objectsList GHL custom objects
GET/api/schemas/ghl/{locationId}/objects/{key}Get custom object fields
Jobs API
Schedule recurring syncs with cron-based jobs:
GET/api/jobsList all jobs
POST/api/jobsCreate job
GET/api/jobs/{id}Get job details
PUT/api/jobs/{id}Update job
DELETE/api/jobs/{id}Delete job
POST/api/jobs/{id}/runRun job immediately
ParameterTypeReq.Description
namestringreqJob name
connectionIdstringreqTarget connection
entityTypeenumreqCONTACT | VEHICLE
cronExpressionstringreqCron schedule (e.g. '*/15 * * * *')
syncDirectionenumoptOverride connection direction
isActivebooleanoptEnable/disable (default: true)
Bulk Sync (75k+ Records)
For large dealerships with tens of thousands of contacts:
⚠️Vercel has a 300s function timeout. The sync engine auto-saves as PARTIAL at 240s and stores a resume token. Re-trigger the sync to continue from where it left off.
Recommended Setup
- Enable sync filters — skip contacts without email/phone to reduce volume
- Start with a test batch — set
maxRecords: 50 to verify mappings - Schedule recurring syncs — create a Job with a 15-minute interval to process chunks
- Monitor sync runs — check for PARTIAL/FAILED status and review logs
Performance characteristics
CDK rate limit: 100 requests / 60 seconds
Page size: 100 contacts / page
Page delay: 500ms between pages
Timeout: 240s (auto-save as PARTIAL)
Fresh sync speedup: Skips GHL dedup when no existing links
Vehicle Sync
Vehicles are synced from CDK to GHL custom objects. Since CDK has no bulk vehicle endpoint, vehicles are fetched per-contact in batches of 20.
Vehicle sync flow
1. Fetch batch of 20 CDK contacts
2. For each contact, fetch their vehicles
3. Map vehicle fields → GHL custom_objects.vehicles
4. Create/update vehicle records in GHL
5. Create RecordLink with ghlObjectKey = "custom_objects.vehicles"
6. Tag each vehicle record with ownerContactId
ℹ️Vehicle sync requires VEHICLE-type field mappings with ghlTargetType: "custom_object" and ghlCustomObjectKey: "custom_objects.vehicles".
Troubleshooting
Invalid SubscriptionId
Cause: Wrong CDK Subscription ID (org ID vs subscription ID) or expired Fortellis credentials
Fix: Verify the UUID in your Fortellis portal under Subscriptions (not Organizations)
Invalid search criteria
Cause: CDK requires at least one search field in the POST body
Fix: This is handled automatically. If seen, check that the search body is not empty.
401: Invalid Bearer Token - Token Expired
Cause: CDK Fortellis OAuth token has expired
Fix: Tokens auto-refresh. If persistent, verify FORTELLIS_API_KEY and FORTELLIS_API_SECRET env vars.
PARTIAL sync status
Cause: Sync timed out at 240s (Vercel protection)
Fix: Re-trigger the sync. It resumes from the saved resume token automatically.
0 records created, 0 updated
Cause: No matching field mappings, or all records filtered by syncFilters
Fix: Check mapping directions match sync direction. Verify syncFilters aren't too restrictive.
Rate limit (429)
Cause: Too many API requests
Fix: Handled automatically with exponential backoff. Just wait and retry the sync.
💡Always check the Audit Log page for detailed error traces. Every sync run, webhook call, and API action is logged with timestamps.
ShiftScale Integration Platform · Built by ShiftScale Digital