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.
By the end of this guide you will have produced and transmitted a complete ACP envelope containing:
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.
Before building, understand three things:
acp, source, dealership, lead, transaction, conversation,
events) plus one optional (ai_analysis).
application/acp+json. Receivers should also accept application/json.# 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
Every ACP envelope starts with three metadata objects: protocol identity (acp), who produced it (source), and who receives it (dealership).
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' => [], ]; }
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.
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'], ], ];
Include the full conversation transcript and deal state. Every message gets a role (user, assistant, system) and timestamp.
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"]
};
Events are the audit trail. Every significant action during the conversation becomes a typed, timestamped event.
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" }
}
];
Before sending, validate your envelope against the schema. This catches structural errors before they reach the CRM.
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'); }
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.
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}");
If you're a CRM vendor or building an ingestion endpoint, here's the minimal receiver.
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); }
application/acp+json and application/jsonenvelope_id — return 200 for duplicates, not an errorparent_envelope_id — if present, this is a delta (events only). If null, it's a full snapshot.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.
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.