motordesk/api2
Composer 安装命令:
composer require motordesk/api2
包简介
Official PHP SDK for the MotorDesk API — automotive dealer management & retail. Full endpoint coverage, zero dependencies. Build integrations, automations, and AI agent tools.
关键字:
README 文档
README
The official PHP client for the MotorDesk API v2, the automotive dealer-management and retail platform. It covers every endpoint (185 operations) through typed resource classes and handles auth and the response envelope for you - a clean, dependency-free integration layer for dealership software, automations, and AI agents. Ships with a runnable example that takes a vehicle from draft to completed sale.
- Zero runtime dependencies - uses only
ext-curlandext-json. Install with Composer, or drop it into any project with the bundled autoloader. - Complete coverage - one method per endpoint across 16 resource groups
(
vehicles(),contacts(),leads(),invoices(),webhooks(), …). See the full endpoint reference. - Predictable - every call returns the unwrapped
data, or throws a typedApiExceptioncarrying the status, error code and per-field details. - Transparent - the exact request, response, HTTP status, and any recovered server noise are inspectable on the client after each call.
Contents
- Requirements
- Installation
- Quick start
- Configuration
- Authentication & scopes
- Making requests
- Pagination & sparse fieldsets
- Error handling
- Inspecting requests & responses
- Workflow shortcuts
- Endpoint reference
- Example integration
- Testing
- Project layout
- Gotchas
Requirements
- PHP 8.1+ with the
curlandjsonextensions (both standard). - A MotorDesk scoped API key in the form
key_id.secret.
Installation
composer require motordesk/api2
The package has no runtime dependencies (only ext-curl / ext-json). If you
don't use Composer, require the bundled PSR-4 autoloader instead:
require '/path/to/motordesk-api2/src/autoload.php'; use MotorDesk\Client;
Quick start
require 'src/autoload.php'; use MotorDesk\Client; use MotorDesk\ApiException; $client = new Client(apiKey: 'key_id.secret'); try { // List the 20 most recent vehicles $vehicles = $client->vehicles()->list(['per_page' => 20]); // Create a draft vehicle $vehicle = $client->vehicles()->create([ 'registration' => 'AB12 CDE', 'data' => ['vehicle' => ['type' => 'Car', 'make' => 'Ford', 'model' => 'Fiesta']], ]); echo "Created vehicle #{$vehicle['id']}\n"; } catch (ApiException $e) { fwrite(STDERR, "{$e->getMessage()} ({$e->errorCode})\n"); foreach ($e->detailLines() as $line) { fwrite(STDERR, " - {$line}\n"); } }
Configuration
The client is configured entirely through its constructor:
$client = new Client( apiKey: 'key_id.secret', // required baseUrl: 'https://api.motordesk.com/2.0', // default (production) verifySsl: true, // verify TLS - keep true in production timeout: 30, // per-request timeout (seconds) maxRetries: 2, // retry transient failures (see below) );
Transient failures are retried automatically with exponential backoff: network
errors and 429/5xx on idempotent methods, plus 429 on any method (a rate
limit means the request wasn't processed). POST/PATCH are never retried on a
5xx - unless you pass an idempotency key (see Idempotency),
which makes the write safe to repeat. Set maxRetries: 0 to disable.
- Production:
https://api.motordesk.com/2.0
The example app reads these from config.php (copy config.example.php) or the
MOTORDESK_API_KEY / MOTORDESK_BASE_URL / MOTORDESK_VERIFY_SSL environment
variables - but the SDK itself has no opinion on where you keep credentials.
Keep
verifySsl: truefor production. Set it tofalseonly for a local dev server with a self-signed certificate.
Authentication & scopes
Pass a scoped key as apiKey; it is sent as Authorization: Bearer key_id.secret.
Keys are scoped per area (e.g. vehicles:write, contacts:write,
invoices:write, vehicle-pricing:read). If a call needs a scope your key lacks,
the API returns a 403/422 naming the missing scope in error.details - surfaced
on ApiException (see Error handling).
Making requests
Resources & accessors
Endpoints are grouped into resource classes, each reached through an accessor on the client. Methods map one-to-one to endpoints:
$client->vehicles()->get($id); // GET /vehicles/{id} $client->vehicles()->update($id, ['data' => [...]]); // PATCH /vehicles/{id} $client->vehicles()->listMedia($id); // GET /vehicles/{id}/media $client->vehicles()->addStageNote($id, $jobId, $stage, ['note' => 'Done']); $client->contacts()->create(['name' => 'Jane Smith', 'email' => 'jane@example.com']); $client->leads()->addNote($leadId, ['note' => 'Called back']); $client->invoices()->recordPayment($id, ['amount' => 9495.00, 'method' => 'Bank Transfer']); $client->reference()->listSites(); // GET /reference/sites // Vehicle lookup - `type` is the country code (UK; GB→UK) or "VIN", not "registration": $client->vehicles()->lookup(['type' => 'UK', 'identifier' => 'MT04DSK']);
Accessors (all cached): vehicles(), contacts(), leads(), invoices(),
orders(), purchases(), appointments(), documents(), blogs(),
reviews(), calls(), deals(), listings(), reference(), webhooks(), meta().
Method naming
- CRUD:
list(),create(),get($id),update($id, $body),delete($id). - Sub-resources and actions read naturally:
listMedia(),addMedia(),publishChannels(),reorderJobStages(),recordPayment(),checkDuplicate(),completeSale(). Each method's PHPDoc names the underlyingVERB /path.
Arguments
- Path parameters are positional (
int|string), in path order:addStageNote($id, $jobId, $stage, $body). - Write methods (
POST/PUT/PATCH) take anarray $body, followed by an optional?string $idempotencyKey(see Idempotency). GETmethods take an optionalarray $queryforpage/per_page, sparsefields, and filters.
Generic verbs
For anything not yet wrapped (or to call a raw path), the verb methods are public:
$client->get('/vehicles', ['per_page' => 5]); $client->post('/vehicles/123/notes', ['note' => '…']); $client->put('/vehicles/123/channels', ['channels' => ['website']]); $client->patch('/vehicles/123', ['data' => [...]]); $client->delete('/vehicles/123/media/456'); $client->head('/vehicles/123'); // headers + status only, no body
Response headers & rate limits
Every call captures the response headers as a lowercased name => value map on
$client->lastHeaders, with lastHeader() for a single case-insensitive lookup.
rateLimit() reads the daily X-RateLimit-*-Day headers as ints (each null when
the header is absent):
$client->vehicles()->list(); $client->lastHeader('X-RateLimit-Remaining-Day'); // e.g. "4987" ['limit' => $limit, 'remaining' => $remaining, 'reset' => $reset] = $client->rateLimit();
head() issues a body-less HEAD request - a cheap existence/freshness probe, or a
way to read rate-limit state without fetching a payload. It returns the header map
and sets $client->lastStatus.
Idempotency
Write methods (POST/PUT/PATCH) and the generic post()/put()/patch()
verbs take an optional idempotency key as the last argument. It is sent as the
Idempotency-Key header; the API de-duplicates requests that reuse the same key,
so a retried call won't create a second record.
$key = bin2hex(random_bytes(16)); // stable per logical operation $client->invoices()->create($body, $key); // resource method $client->post('/invoices', $body, $key); // generic verb
Passing a key also makes the write safe to auto-retry: with a key set, a
POST/PATCH that hits a network error or transient 5xx is retried like an
idempotent method (subject to maxRetries). Reuse the same key when you retry a
logical operation yourself, and use a fresh key for each new one.
Pagination & sparse fieldsets
List endpoints paginate with page / per_page; the pagination block is exposed
on the client after each call as lastMeta:
$page = $client->vehicles()->list(['page' => 2, 'per_page' => 50]); $pagination = $client->lastMeta['pagination'] ?? null; // ['page' => 2, 'per_page' => 50, 'total' => 240, 'total_pages' => 5, 'next_cursor' => '…']
Or let the client walk every page for you with paginate(), which yields each item:
foreach ($client->paginate('/vehicles', ['status' => 'for-sale']) as $vehicle) { // … }
Use sparse fieldsets to fetch only the fields you need (dot-pathed):
$client->vehicles()->list(['fields' => 'id,status,data.stock.price_website']);
Error handling
Any non-success envelope or transport failure throws MotorDesk\ApiException:
use MotorDesk\ApiException; try { $client->vehicles()->create(['data' => ['vehicle' => ['type' => 'car']]]); } catch (ApiException $e) { $e->getMessage(); // "The data.vehicle.type field is not a recognised vehicle class…" $e->httpStatus; // 422 $e->errorCode; // "validation_failed" $e->details; // ['field' => 'data.vehicle.type', 'allowed_values' => ['Car', 'Van', …]] $e->detailLines(); // ['field: data.vehicle.type', 'allowed_values: Car; Van; …'] $e->body; // full decoded response body (for debugging) }
Inspecting requests & responses
After every call the client exposes what just happened - handy for logging, debugging, or teaching:
$client->lastRequest; // ['method' => 'POST', 'url' => '…', 'body' => [...]] $client->lastResponse; // full decoded envelope: ['success' => true, 'data' => …, 'meta' => …] $client->lastStatus; // 201 $client->lastHeaders; // ['content-type' => 'application/json; charset=utf-8', …] $client->lastWarning; // usually null
Leaked-output recovery. If a response arrives with non-JSON noise around the
envelope (e.g. a stray PHP notice prepended to the body by the server), the client
extracts the JSON envelope so the call still succeeds, and records the stripped text
on $client->lastWarning instead of failing - so the problem stays visible rather
than breaking your integration. On clean responses lastWarning is null.
Workflow shortcuts
For the common "list a car and sell it" journey, the client also offers flat convenience methods (thin wrappers over the resources above), kept for readability and backwards compatibility:
$client->createVehicle($data); // ≡ vehicles()->create() $client->addVehicleMedia($id, $media); // ≡ vehicles()->addMedia() $client->publishVehicle($id, ['website']); // ≡ vehicles()->publishChannels() $client->createContact($data); // ≡ contacts()->create() $client->reserveVehicle($id, $body); // ≡ vehicles()->reserve() $client->createInvoice($data); // ≡ invoices()->create() $client->issueInvoice($id); // ≡ invoices()->issue() $client->sendInvoice($id, $email); // ≡ invoices()->email() $client->payInvoice($id, $payment); // ≡ invoices()->recordPayment() $client->completeSale($id, $body); // ≡ vehicles()->completeSale()
Endpoint reference
The complete list of resources and methods - 185 endpoints with their
VERB /path and summary - is in docs/ENDPOINTS.md.
Example integration
A runnable demo lives in public/: a nine-step guided walkthrough that
takes a vehicle from draft to a completed sale, where each step is a standalone form
performing a single API call and showing the exact request and response.
| # | Step | SDK call |
|---|---|---|
| 1 | Add a vehicle (with registration lookup to prefill) | vehicles()->lookup(), createVehicle() |
| 2 | Upload images | addVehicleMedia() |
| 3 | Publish for sale | publishVehicle() |
| 4 | Create a contact | createContact() |
| 5 | Reserve the vehicle | reserveVehicle() |
| 6 | Create an invoice | createInvoice() |
| 7 | Issue & email the invoice | issueInvoice(), sendInvoice() |
| 8 | Mark paid → sold | payInvoice() |
| 9 | Mark the sale complete | completeSale() |
Each step's form is the focus; the API request/response is shown in a collapsible panel below it. The sidebar's ⟳ Sync from MotorDesk button re-fetches the vehicle, contact and invoice and refreshes the workflow state from their current API status - so a change made directly in MotorDesk (e.g. reserving the vehicle, or taking a deposit) is picked up and the process can be continued from the SDK tool (a reservation even adopts its customer as the workflow contact).
Run it:
cp config.example.php config.php # add your key + set base_url for your environment php -S localhost:8000 -t public # open http://localhost:8000
Prefer the command line? php examples/walkthrough.php runs the whole sequence
end-to-end without a browser.
Separate example: Vehicle Jobs
public/jobs.php is a standalone "workshop board" example for
the vehicle-jobs sub-API: apply a job board to a vehicle, then manage the
resulting job's stages, tasks, notes, clocked time, documents and purchase
invoices - covering every job endpoint: applyJobBoard(), listJobs(),
getJob(), removeJob(), addJobStage() / updateJobStage() / removeJobStage()
/ reorderJobStages(), addStageTask() / updateStageTask() / removeStageTask(),
addStageNote() / removeStageNote(), addStageClockingEntry() /
removeStageClockingEntry(), uploadStageDocument() / removeStageDocument(), and
uploadStagePurchaseInvoice() / removeStagePurchase(). A stage is marked done with
a binary complete flag. Open it at /jobs.php (linked from the dashboard).
A job is created by applying a job board - a template configured per business in MotorDesk (Settings → Job Boards). If your business has none, the example shows a notice; configure one to use it.
Separate example: Webhooks
public/webhooks.php is a standalone example for the
webhooks sub-API: subscribe a target URL to events (the literal * or a list
picked from the event catalogue), then manage the subscription and its delivery
log - covering every webhook endpoint: list(), create(), get(),
update(), delete(), ping(), rotateSecret(), listDeliveries() and
redeliver(). The event catalogue is read from reference()->listWebhookEvents().
Open it at /webhooks.php (linked from the dashboard).
The signing secret is returned only once - in the create response and again when rotated - so the example surfaces it in a one-time banner. Store it then and use it to verify the
X-MotorDesk-Signatureheader on each delivery; afterwards only the maskedsecret_hintis visible.
Testing
Unit tests run without credentials (a fake transport stands in for the network):
composer install composer test # phpunit composer analyse # phpstan composer cs # php-cs-fixer (dry-run)
There's also a live integration harness:
php tests/full-workflow.php
It runs the whole workflow against the live API, verifies every documented
state transition (draft → for-sale → reserved → sold → complete), runs targeted
probes, and prints a summary of any issues found. It creates real records and sends
a real email (to an @example.com test address), so point it at a non-production
(dev) server only.
Project layout
config.example.php → copy to config.php (example app only)
src/
autoload.php → PSR-4 autoloader (no Composer required)
MotorDesk/
Client.php → transport, verbs, resource accessors, workflow shortcuts
ApiException.php → typed error (status, code, message, details)
Resource/ → one class per API area, one method per endpoint
AbstractResource.php
Vehicles.php Contacts.php Leads.php Invoices.php Orders.php
Purchases.php Appointments.php Documents.php Blogs.php Reviews.php
Calls.php Deals.php Reference.php Webhooks.php Meta.php
Http/ → Transport interface + default CurlTransport
public/ → the example integration (forms)
examples/walkthrough.php → scripted end-to-end run
tests/ → unit suite (Unit/) + live integration harness
bin/generate-resources.php → regenerate Resource/* from the OpenAPI spec
docs/ENDPOINTS.md → full endpoint reference
Gotchas
A few non-obvious API behaviours worth knowing:
- Vehicle class is canonical and case-sensitive.
data.vehicle.typemust be one ofBike,Car,Caravan,Crossover,Farm,Motorhome,Plant,Truck,Van-caris rejected. - Publishing requires extra fields. A vehicle can't be published until
data.history.condition,data.stock.vatanddata.vehicle.drivetrainare set. - Vehicle lookup
typeis a country code orVIN.vehicles()->lookup()takestype= a 2-letter ISO country code (UK;GB→UK) orVIN, not the word "registration". - Prices.
data.stock.price_*are fixed 2-dp decimal strings ("9495.00"); invoice line prices are plain numbers. The display price isprice_website. - Media read model. A vehicle's media lives under a
mediaobject (media.photo/video/youtube- item ids);vehicles()->listMedia()returns the detailed item list. - Job stages use a binary
completeflag (no in-progress); tasks use status0/10.
统计信息
- 总下载量: 1
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 2
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-06-22