The PHP example is a complete pull-first reference client for PHP 8.2 or newer with the cURL extension. Start with a sandbox certificate. Keep private keys, P12 passwords, link codes, cursors, resource IDs, and response bodies out of source control, command lines, analytics, and routine logs.
Download the complete PHP source.
Requirements and environment#
Store the BOT-issued certificate chain and the private key created with your CSR in a protected secret directory. The service account running PHP should be the only account able to read the key.
export BOT_CERT='/secure/path/bot-integration-certificate-chain.pem'
export BOT_KEY='/secure/path/bot-integration-client-key.pem'
export BOT_KEY_PASSWORD='load-this-from-your-secret-manager'
export BOT_EXPECTED_ENVIRONMENT='sandbox'
php bot-integration-client.php
Do not put a real password in a shell profile. BOT_KEY_PASSWORD is optional for an unencrypted service key and should otherwise be injected by the process manager or secret store.
Configure mTLS safely#
The reference client presents the integration certificate while retaining normal HTTPS certificate and hostname validation. Redirects are disabled so the client certificate cannot be forwarded to another host.
$options = [
CURLOPT_SSLCERT => $certificate,
CURLOPT_SSLKEY => $privateKey,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 20,
];
if ($privateKeyPassword !== null) {
$options[CURLOPT_KEYPASSWD] = $privateKeyPassword;
}
Never enable CURLOPT_SSL_VERIFYPEER=false, never follow redirects, and never reuse a sandbox certificate in production.
Capabilities and production gate#
Call capabilities first after setup or certificate rotation. Verify the environment, integration status, approved scopes, quotas, and independent production-data gate before doing any data work.
$expectedEnvironment = getenv('BOT_EXPECTED_ENVIRONMENT') ?: 'sandbox';
$capabilities = botRequest('GET', '/capabilities')['body'];
if (!is_array($capabilities)
|| ($capabilities['environment'] ?? null) !== $expectedEnvironment) {
throw new RuntimeException('Certificate is bound to the wrong environment.');
}
if ($expectedEnvironment === 'production'
&& (($capabilities['integration_status'] ?? null) !== 'active'
|| ($capabilities['production_data_enabled'] ?? null) !== true)) {
throw new RuntimeException('Production is not active/data-enabled.');
}
A production certificate alone does not enable data. Stop safely when any gate is missing.
Redeem a ten-digit link code#
Treat the code as a string so leading zeroes survive. Do not log it or retry an ambiguous timeout blindly; a code is integration-bound, valid for ten minutes, and can be consumed once.
$code = requiredEnvironment('BOT_LINK_CODE');
if (preg_match('/^[0-9]{10}$/D', $code) !== 1) {
throw new InvalidArgumentException('The link code must contain ten digits.');
}
$linked = botRequest('POST', '/link-codes/redeem', ['code' => $code])['body'];
The response contains only random integration-specific connection and resource IDs. It never reveals the BOT account, VIN, or real order number.
Initial and delta sync#
Call /sync without updated_since for the initial or recovery sync. Persist one opaque cursor and matching ETag per integration and environment. Apply every page in order and commit its data plus next_cursor and ETag in the same database transaction.
synchronize(
getenv('BOT_SYNC_CURSOR') ?: null,
getenv('BOT_SYNC_ETAG') ?: null,
static function (
array $items,
string $nextCursor,
?string $nextEtag,
bool $hasMore
): void {
// One transaction:
// 1. apply every upsert or tombstone in order;
// 2. persist nextCursor and nextEtag;
// 3. commit everything together.
}
);
Never decode or manufacture a cursor. A 304 leaves state unchanged. A 410 cursor response requires a new no-cursor reconciliation sync. A tombstone means authorization ended immediately: delete the record and every derived copy.
Process the documented fields#
Use BOT's returned model and resolved variant. Preserve option codes, the resolved readable configuration, and the mandatory nullable order delivery_center. Never invent a trim, colour, wheel, or interior from a raw option code.
$projection = projectAllowlistedResource('order', $payload['data']);
$model = $projection['model'];
$variant = $projection['variant'];
$deliveryCenter = $projection['delivery_center'];
$optionCodes = $projection['option_codes'];
$color = $projection['configuration']['color'];
$wheels = $projection['configuration']['wheels'];
$interior = $projection['configuration']['interior'];
The helper prefers a documented nested configuration member when present and supports the compatible top-level form otherwise. It retains false for a boolean such as tow_hitch; it does not treat it as missing.
Detail and complete history#
Use only a random resource ID obtained through redemption or sync. The complete client optionally reads a detail resource and then paginates its history:
export BOT_RESOURCE_COLLECTION='orders'
export BOT_RESOURCE_ID='ord_random_integration_specific_id'
php bot-integration-client.php
$detail = botRequest(
'GET',
'/' . $collection . '/' . rawurlencode($resourceId)
)['body'];
$projection = projectAllowlistedResource(
$collection === 'orders' ? 'order' : 'vehicle',
$detail['data']
);
readCompleteHistory($collection, $resourceId, $cursor, $etag, $commitPage);
History cursors are resource-bound and independent from the sync cursor. has_more=false does not mean complete while history_complete=false.
Read aggregate statistics#
Statistics contain fixed, privacy-thresholded cells and no individual records. The metric vocabulary is documented in the field catalogue.
$statistics = botRequest('GET', '/statistics/orders')['body'];
$data = is_array($statistics['data'] ?? null) ? $statistics['data'] : [];
foreach (($data['orders_total'] ?? []) as $cell) {
printf("%s: %d opted-in orders\n",
$cell['dimensions']['country'],
$cell['value']);
}
Do not sum suppressed breakdowns to invent totals. Use orders_total for the country total and handle 404 statistics_not_available until a safe generation exists.
Acknowledge deletion#
Only acknowledge after all controlled copies, caches, search indexes, exports, queued payloads, and derived individual data have been deleted.
$revocationId = requiredEnvironment('BOT_REVOCATION_ID');
if (getenv('BOT_DELETION_COMPLETED') !== '1') {
throw new RuntimeException('Deletion has not been confirmed locally.');
}
acknowledgeDeletion($revocationId);
The complete helper uses a stable idempotency key based on the revocation ID. Honor Retry-After on 429; never switch certificates to evade a quota.
Expected sandbox run#
With the documented static fixture selected, the diagnostic client validates a Model Y / Long Range AWD order, its seven option codes, Quicksilver paint, Induction 20-inch wheels, and the visibly marked delivery center. The complete JSON expected result is displayed below this guide without making an API call.