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

CDK_TO_GHL
One-way to GHL
GHL_TO_CDK
One-way to CDK
BIDIRECTIONAL
Both directions

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.

Contact Webhook

The contact webhook syncs GHL contacts to CDK in real-time. When a contact is created or updated in GHL, the change is pushed to CDK automatically.

POST/api/webhooks/connections/{id}/ghlGHL → CDK contact sync

Payload

POST /api/webhooks/connections/{id}/ghl
{
  "type": "ContactCreate",       // or "ContactUpdate"
  "id": "ghl_contact_id",
  "locationId": "...",
  "firstName": "John",
  "lastName": "Doe",
  "email": "john@example.com",
  "phone": "+15551234567"
}
ℹ️The webhook auto-deduplicates: it searches CDK by email/phone before creating. If a match is found, it links and updates instead.

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

  1. Enable sync filters — skip contacts without email/phone to reduce volume
  2. Start with a test batch — set maxRecords: 50 to verify mappings
  3. Schedule recurring syncs — create a Job with a 15-minute interval to process chunks
  4. 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