Getting Started with ACP

Go from zero to your first ACP envelope in 10 minutes. By the end of this guide you will generate a valid ACP envelope, validate it against the official JSON Schema, and transmit it to a CRM endpoint. Code examples in JavaScript, Python, and PHP.

~10 minutes

What You'll Build

By the end of this guide you will have produced and transmitted a complete ACP envelope containing:

  1. A valid ACP envelope with protocol metadata and origin attribution
  2. A lead intelligence object with contact, vehicle interests, intents, and scoring
  3. A transaction lifecycle state tracking the deal from inquiry through qualification
  4. A conversation transcript with role-attributed, timestamped messages
  5. A behavioral event log capturing every significant interaction signal
  6. A schema-validated payload confirmed against the official JSON Schema
  7. A CRM delivery via authenticated POST with idempotency and error handling

What is ACP?

The Automotive Commerce Protocol (ACP) is an open JSON format for transmitting AI-generated automotive commerce data. It replaces ADF-XML with a modern, event-driven envelope that captures the full context of an AI sales conversation — customer intelligence, deal state, transcripts, behavioral events, and AI analysis — in a single JSON document.

ADF-XML (Legacy)
~15
data fields per lead submission
ACP Envelope
~120+
data fields per conversation

Prerequisites

📦
JSON Schema Validator
Ajv (JS), jsonschema (Py), opis/json-schema (PHP)
📄
ACP Schema File
acp-schema-v1.0.0.json from GitHub
🔧
Runtime
Node 18+, Python 3.10+, or PHP 8.1+

Core Concepts

Before building, understand three things:

  1. Envelope = complete snapshot. Every ACP envelope is a self-contained document. Receivers replace stored state entirely — no merge logic.
  2. 8 top-level objects. Seven required (acp, source, dealership, lead, transaction, conversation, events) plus one optional (ai_analysis).
  3. Content type: application/acp+json. Receivers should also accept application/json.

Step 1 — Install Dependencies

JavaScript
Python
PHP
# Install schema validation
npm install ajv ajv-formats

# Download the ACP schema
curl -o acp-schema-v1.0.0.json \
  https://raw.githubusercontent.com/acp-foundation/acp-spec/main/schema/acp-schema-v1.0.0.json
# Install schema validation
pip install jsonschema

# Download the ACP schema
curl -o acp-schema-v1.0.0.json \
  https://raw.githubusercontent.com/acp-foundation/acp-spec/main/schema/acp-schema-v1.0.0.json
# Install schema validation
composer require opis/json-schema

# Download the ACP schema
curl -o acp-schema-v1.0.0.json \
  https://raw.githubusercontent.com/acp-foundation/acp-spec/main/schema/acp-schema-v1.0.0.json

Step 2 — Define the Envelope Shell

Every ACP envelope starts with three metadata objects: protocol identity (acp), who produced it (source), and who receives it (dealership).

JavaScript
Python
PHP
import crypto from 'crypto';

function createEnvelope() {
  const now = new Date().toISOString();
  const rand = crypto.randomBytes(4).toString('hex');

  return {
    // Protocol metadata
    acp: {
      version: "1.0.0",
      protocol: "acp",
      generated_at: now,
      envelope_id: `acp_${Date.now()}_${rand}`,
      parent_envelope_id: null
    },

    // Who produced this envelope
    source: {
      provider: "your-platform-name",
      agent_type: "sales_chat",
      channel: "web_chat"
    },

    // Which dealership receives it
    dealership: {
      id: "dealer_001",
      name: "Acme Motors",
      crm_provider: "salesforce",
      timezone: "America/New_York"
    },

    // Populated in subsequent steps
    lead: {},
    transaction: {},
    conversation: {},
    events: []
  };
}
import uuid
from datetime import datetime, timezone

def create_envelope():
    now = datetime.now(timezone.utc).isoformat()
    rand = uuid.uuid4().hex[:8]

    return {
        # Protocol metadata
        "acp": {
            "version": "1.0.0",
            "protocol": "acp",
            "generated_at": now,
            "envelope_id": f"acp_{int(datetime.now().timestamp())}_{rand}",
            "parent_envelope_id": None
        },

        # Who produced this envelope
        "source": {
            "provider": "your-platform-name",
            "agent_type": "sales_chat",
            "channel": "web_chat"
        },

        # Which dealership receives it
        "dealership": {
            "id": "dealer_001",
            "name": "Acme Motors",
            "crm_provider": "salesforce",
            "timezone": "America/New_York"
        },

        # Populated in subsequent steps
        "lead": {},
        "transaction": {},
        "conversation": {},
        "events": []
    }
function createEnvelope(): array
{
    $now = gmdate('c');
    $rand = bin2hex(random_bytes(4));

    return [
        // Protocol metadata
        'acp' => [
            'version' => '1.0.0',
            'protocol' => 'acp',
            'generated_at' => $now,
            'envelope_id' => "acp_" . time() . "_{$rand}",
            'parent_envelope_id' => null,
        ],

        // Who produced this envelope
        'source' => [
            'provider' => 'your-platform-name',
            'agent_type' => 'sales_chat',
            'channel' => 'web_chat',
        ],

        // Which dealership receives it
        'dealership' => [
            'id' => 'dealer_001',
            'name' => 'Acme Motors',
            'crm_provider' => 'salesforce',
            'timezone' => 'America/New_York',
        ],

        // Populated in subsequent steps
        'lead' => [],
        'transaction' => [],
        'conversation' => [],
        'events' => [],
    ];
}

Step 3 — Add Lead Data

Populate the lead object with customer intelligence extracted from the conversation. At minimum you need id and status. Everything else is optional but makes the envelope dramatically more useful for CRM receivers.

JavaScript
Python
PHP
envelope.lead = {
  id: "lead_1739456200",
  status: "qualified",   // "new" | "qualified" | "contacted" | "converted" | "lost"

  contact: {
    first_name: "Sarah",
    last_name: "Chen",
    email: "sarah.chen@example.com",
    phone: "+14155551234",
    zip: "94103"
  },

  vehicle_interests: [{
    year: 2025,
    make: "Toyota",
    model: "Camry",
    trim: "XSE",
    condition: "new",
    stock_number: "T25-1847",
    features: ["sunroof", "leather"]
  }],

  budget: {
    monthly_payment_target: 450,
    down_payment: 5000
  },

  intents: [
    { type: "VEHICLE_INQUIRY",  confidence: 0.95 },
    { type: "TEST_DRIVE",       confidence: 0.88 },
    { type: "FINANCING",        confidence: 0.72 }
  ],

  score: 78,
  score_factors: [
    { factor: "email_provided",   points: 12, category: "contact" },
    { factor: "TEST_DRIVE_intent", points: 18, category: "intent" }
  ]
};
envelope["lead"] = {
    "id": "lead_1739456200",
    "status": "qualified",

    "contact": {
        "first_name": "Sarah",
        "last_name": "Chen",
        "email": "sarah.chen@example.com",
        "phone": "+14155551234",
        "zip": "94103"
    },

    "vehicle_interests": [{
        "year": 2025,
        "make": "Toyota",
        "model": "Camry",
        "trim": "XSE",
        "condition": "new",
        "stock_number": "T25-1847",
        "features": ["sunroof", "leather"]
    }],

    "budget": {
        "monthly_payment_target": 450,
        "down_payment": 5000
    },

    "intents": [
        {"type": "VEHICLE_INQUIRY",  "confidence": 0.95},
        {"type": "TEST_DRIVE",       "confidence": 0.88},
        {"type": "FINANCING",        "confidence": 0.72}
    ],

    "score": 78,
    "score_factors": [
        {"factor": "email_provided",   "points": 12, "category": "contact"},
        {"factor": "TEST_DRIVE_intent", "points": 18, "category": "intent"}
    ]
}
$envelope['lead'] = [
    'id' => 'lead_1739456200',
    'status' => 'qualified',

    'contact' => [
        'first_name' => 'Sarah',
        'last_name' => 'Chen',
        'email' => 'sarah.chen@example.com',
        'phone' => '+14155551234',
        'zip' => '94103',
    ],

    'vehicle_interests' => [[
        'year' => 2025,
        'make' => 'Toyota',
        'model' => 'Camry',
        'trim' => 'XSE',
        'condition' => 'new',
        'stock_number' => 'T25-1847',
        'features' => ['sunroof', 'leather'],
    ]],

    'budget' => [
        'monthly_payment_target' => 450,
        'down_payment' => 5000,
    ],

    'intents' => [
        ['type' => 'VEHICLE_INQUIRY',  'confidence' => 0.95],
        ['type' => 'TEST_DRIVE',       'confidence' => 0.88],
        ['type' => 'FINANCING',        'confidence' => 0.72],
    ],

    'score' => 78,
    'score_factors' => [
        ['factor' => 'email_provided',   'points' => 12, 'category' => 'contact'],
        ['factor' => 'TEST_DRIVE_intent', 'points' => 18, 'category' => 'intent'],
    ],
];

Step 4 — Attach the Conversation

Include the full conversation transcript and deal state. Every message gets a role (user, assistant, system) and timestamp.

conversation + transactionJSON
envelope.transaction = {
  "id": "txn_1739456200",
  "stage": "qualified",
  "last_updated": "2025-02-13T15:35:08Z"
};

envelope.conversation = {
  "id": "conv_1739456200",
  "started_at": "2025-02-13T15:30:00Z",
  "ended_at": "2025-02-13T15:42:30Z",
  "duration_seconds": 750,
  "message_count": 8,
  "messages": [
    {
      "id": "msg_0",
      "role": "assistant",
      "content": "Welcome to Acme Motors! How can I help you today?",
      "timestamp": "2025-02-13T15:30:00Z"
    },
    {
      "id": "msg_1",
      "role": "user",
      "content": "Hi! I'm interested in the 2025 Camry XSE.",
      "timestamp": "2025-02-13T15:30:22Z"
    }
    // ... remaining messages
  ],
  "sentiment": "positive",
  "topics": ["pricing", "financing", "test_drive"]
};

Step 5 — Log Events

Events are the audit trail. Every significant action during the conversation becomes a typed, timestamped event.

eventsJSON
envelope.events = [
  {
    "type": "conversation_started",
    "timestamp": "2025-02-13T15:30:00Z",
    "data": { "channel": "web_chat" }
  },
  {
    "type": "intent_detected",
    "timestamp": "2025-02-13T15:30:22Z",
    "data": { "type": "VEHICLE_INQUIRY", "confidence": 0.95 }
  },
  {
    "type": "contact_extracted",
    "timestamp": "2025-02-13T15:33:22Z",
    "data": { "field": "email", "value": "sarah.chen@example.com" }
  },
  {
    "type": "score_updated",
    "timestamp": "2025-02-13T15:33:22Z",
    "data": { "old_score": 35, "new_score": 47 }
  },
  {
    "type": "stage_changed",
    "timestamp": "2025-02-13T15:35:08Z",
    "data": { "from": "inquiry", "to": "qualified" }
  }
];
18 standard event types are defined in the spec. See the full list in the documentation. Vendors may also emit custom events.

Step 6 — Validate

Before sending, validate your envelope against the schema. This catches structural errors before they reach the CRM.

JavaScript
Python
PHP
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import schema from './acp-schema-v1.0.0.json';

const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
const validate = ajv.compile(schema);

const valid = validate(envelope);
if (!valid) {
  console.error('Validation failed:', validate.errors);
  throw new Error('Invalid ACP envelope');
}

console.log('✓ Valid ACP envelope', envelope.acp.envelope_id);
import json
from jsonschema import validate, ValidationError

with open("acp-schema-v1.0.0.json") as f:
    schema = json.load(f)

try:
    validate(instance=envelope, schema=schema)
    print(f"✓ Valid ACP envelope {envelope['acp']['envelope_id']}")
except ValidationError as e:
    print(f"✗ Validation failed: {e.message}")
    raise
use Opis\JsonSchema\{Validator, Helper};

$schema = Helper::toJSON(
    file_get_contents('acp-schema-v1.0.0.json')
);

$validator = new Validator();
$result = $validator->validate(
    Helper::toJSON(json_encode($envelope)),
    $schema
);

if ($result->isValid()) {
    echo "✓ Valid ACP envelope {$envelope['acp']['envelope_id']}\n";
} else {
    foreach ($result->error()->subErrors() as $error) {
        echo "✗ " . $error->message() . "\n";
    }
    throw new \RuntimeException('Invalid ACP envelope');
}

Step 7 — Send to CRM

POST the validated envelope to the receiving CRM's ACP ingestion endpoint. Include the application/acp+json content type and the envelope ID in a header for idempotency.

JavaScript
Python
PHP
const response = await fetch(crmEndpoint, {
  method: "POST",
  headers: {
    "Content-Type": "application/acp+json",
    "Authorization": `Bearer ${apiKey}`,
    "X-ACP-Version": "1.0.0",
    "X-ACP-Envelope-Id": envelope.acp.envelope_id,
  },
  body: JSON.stringify(envelope)
});

if (!response.ok) {
  // Queue for retry with exponential backoff
  throw new Error(`CRM returned ${response.status}`);
}

const result = await response.json();
console.log('Synced → CRM lead:', result.lead_id);
import requests

response = requests.post(
    crm_endpoint,
    json=envelope,
    headers={
        "Content-Type": "application/acp+json",
        "Authorization": f"Bearer {api_key}",
        "X-ACP-Version": "1.0.0",
        "X-ACP-Envelope-Id": envelope["acp"]["envelope_id"],
    }
)

response.raise_for_status()
result = response.json()
print(f"Synced → CRM lead: {result['lead_id']}")
$response = Http::withHeaders([
    'Content-Type' => 'application/acp+json',
    'Authorization' => "Bearer {$apiKey}",
    'X-ACP-Version' => '1.0.0',
    'X-ACP-Envelope-Id' => $envelope['acp']['envelope_id'],
])->post($crmEndpoint, $envelope);

if ($response->failed()) {
    // Queue for retry via Laravel job
    dispatch(new RetryAcpSync($envelope, attempt: 1));
    return;
}

$leadId = $response->json('lead_id');
Log::info("Synced → CRM lead: {$leadId}");
That's it! You've produced a complete ACP envelope — from conversation through validation to CRM delivery. The CRM now has 10–50× more data than a traditional ADF-XML submission would provide.

Receiving ACP Envelopes

If you're a CRM vendor or building an ingestion endpoint, here's the minimal receiver.

Build an Ingestion Endpoint

Express.js
Flask
Laravel
import express from 'express';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import schema from './acp-schema-v1.0.0.json';

const app = express();
const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
const validate = ajv.compile(schema);

app.post('/api/v1/acp/ingest', express.json({
  type: ['application/acp+json', 'application/json']
}), (req, res) => {
  const envelope = req.body;

  // 1. Validate schema
  if (!validate(envelope)) {
    return res.status(400).json({
      error: 'invalid_envelope',
      details: validate.errors
    });
  }

  // 2. Deduplicate by envelope_id
  const envId = envelope.acp.envelope_id;
  if (alreadyProcessed(envId)) {
    return res.status(200).json({ status: 'duplicate', envelope_id: envId });
  }

  // 3. Route to appropriate handler
  const isDelta = envelope.acp.parent_envelope_id !== null;
  if (isDelta) {
    appendEvents(envelope);
  } else {
    upsertLead(envelope);
  }

  res.status(201).json({
    status: 'accepted',
    envelope_id: envId,
    lead_id: envelope.lead.id
  });
});
from flask import Flask, request, jsonify
import json
from jsonschema import validate, ValidationError

app = Flask(__name__)

with open("acp-schema-v1.0.0.json") as f:
    schema = json.load(f)

@app.post("/api/v1/acp/ingest")
def ingest_acp():
    envelope = request.get_json()

    # 1. Validate schema
    try:
        validate(instance=envelope, schema=schema)
    except ValidationError as e:
        return jsonify({
            "error": "invalid_envelope",
            "details": e.message
        }), 400

    # 2. Deduplicate by envelope_id
    env_id = envelope["acp"]["envelope_id"]
    if already_processed(env_id):
        return jsonify({"status": "duplicate"}), 200

    # 3. Route: delta vs snapshot
    is_delta = envelope["acp"]["parent_envelope_id"] is not None
    if is_delta:
        append_events(envelope)
    else:
        upsert_lead(envelope)

    return jsonify({
        "status": "accepted",
        "envelope_id": env_id,
        "lead_id": envelope["lead"]["id"]
    }), 201
// routes/api.php
Route::post('/v1/acp/ingest', AcpIngestController::class);

// AcpIngestController.php
public function __invoke(Request $request)
{
    $envelope = $request->json()->all();

    // 1. Validate schema
    $validator = new \Opis\JsonSchema\Validator();
    $result = $validator->validate(
        Helper::toJSON(json_encode($envelope)),
        $this->schema
    );

    if (!$result->isValid()) {
        return response()->json([
            'error' => 'invalid_envelope',
        ], 400);
    }

    // 2. Deduplicate by envelope_id
    $envId = $envelope['acp']['envelope_id'];
    if (AcpEnvelope::where('envelope_id', $envId)->exists()) {
        return response()->json(['status' => 'duplicate'], 200);
    }

    // 3. Route: delta vs snapshot
    $isDelta = !empty($envelope['acp']['parent_envelope_id']);
    if ($isDelta) {
        $this->appendEvents($envelope);
    } else {
        $this->upsertLead($envelope);
    }

    return response()->json([
        'status' => 'accepted',
        'envelope_id' => $envId,
        'lead_id' => $envelope['lead']['id'],
    ], 201);
}

Receiver Checklist

  1. Accept both content types — application/acp+json and application/json
  2. Validate against the schema — reject invalid envelopes with 400
  3. Deduplicate by envelope_id — return 200 for duplicates, not an error
  4. Check parent_envelope_id — if present, this is a delta (events only). If null, it's a full snapshot.
  5. Replace, don't merge — snapshots are complete state. Overwrite the previous data.
  6. Store the raw envelope — for audit, debugging, and reprocessing

ADF-XML Compatibility

ACP envelopes can be deterministically downgraded to ADF-XML for CRMs that haven't adopted ACP yet. See the Migration Guide for a complete field mapping table and conversion code.

Data loss warning: Converting ACP → ADF-XML discards intents, scores, conversation transcripts, events, and AI analysis. Only basic contact and vehicle data survives. This is the core reason ACP exists.

Delta Streaming

During live conversations, you can send lightweight delta envelopes instead of full snapshots. A delta contains only acp (with parent_envelope_id set) and events. The schema enforces this: all other top-level objects are forbidden in deltas. Send a full snapshot when the conversation ends.

Next Steps