# Signal Trade API Python reference client

This dependency-free reference client calculates Signal trade estimates, loads a dealership's stock and applies its paginated change feed using the Signal Trade v1 API. It also loads and incrementally syncs saved appraisals, preparation jobs, and CRM records.

The same client includes stock create and update helpers plus optional CRM assignee, create and update helpers. See [the CRM helpers guide](./signal_trade_crm_helpers_README.md) for CRM write examples and safe timeout/conflict handling.

For the complete stock-to-Signal-advert path, see the [recoverable publishing guide](../node/stock-publishing-README.md). Python includes `create_stock_advert`, `get_stock_advert`, `change_stock_advert`, `upload_stock_advert_photo`, `reorder_stock_advert_photos` and `remove_stock_advert_photo`. Confirmed writes require a caller-saved operation UUID; original receipts and current records are separate. Stock creation accepts legacy `create_id` or new `operation_id` (pass `create_id=None`), never both. Ordinary stock updates and each bulk row may add `operation_id`; sale retries keep their separate `sale_id`. Persist exact requests before sending and retain original versions on uncertain retries. The runnable journal example uses Node and SQLite; the Python helpers expose the same endpoint contracts.

Native inspection synchronization is separate from the old safe figure feed below. Use a workflow-scoped key and the [native appraisal mirror and helper guide](./native-appraisal-sync-README.md) to discover complete native records, hydrate current facts, apply removals and commit a sequence checkpoint. The runnable Python/Node examples keep private records and checkpoint together; neither downloads private files.

## Requirements

- Python 3.10 or newer
- A Signal Trade API key with `stock:read` access
- Server-side execution; do not place a bearer key in browser code, a URL, or source control

The client uses only Python's standard library. GET requests have a 20 second timeout and are attempted up to four times for network failures, timeouts and HTTP `429`, `502`, `503` or `504` responses. It honours `Retry-After` up to a 10 second wait and refuses redirects to a different origin so the bearer key is not forwarded to another host. Other HTTP errors fail immediately. Stock rows must include an ID; event rows must include an ID, supported type, action, record ID and valid timestamp. Invalid data stops the sync before its checkpoint advances. Only GET requests are retried; database and callback failures are left to your integration. The checkpoint is not saved until event paging, current-record lookups and every callback have succeeded.

Every paginated response must contain `pagination.nextCursor`: a non-empty opaque string for the next page, or an explicit `null` for the final page. The Python and Node clients reject a missing or invalid cursor and stop if a cursor repeats within the same page chain. A failed event poll leaves its saved checkpoint unchanged. Keep the same key and filters, including the complete `ids` list, on every page; lookups by IDs can still span several pages when you choose a smaller page size.

The stock sync helper fetches the current state of all affected vehicle IDs in batches of no more than 100. Each batch must include an explicit `pagination.nextCursor: null` and no duplicated, unexpected or invalid records before callbacks run. An absent vehicle may be deleted locally only if the completed poll contains a validated deletion event for its ID; any other missing ID stops the poll. A present current record is upserted even if it was deleted and recreated. The older figure-appraisal, preparation and CRM batches still require every requested ID. Native inspection sync has its own explicit deletion-evidence rules and sequence checkpoint.

## Verify the connection

Before syncing records, call `get_trade_api_account` to confirm the business and exact permission attached to a key. It returns the business name and ID, connection label, non-secret key prefix and scope; it does not return customer or vehicle records or reveal the full key.

```python
import os

from signal_trade_stock_sync import get_trade_api_account

api_key = os.environ["SIGNAL_TRADE_API_KEY"]
base_url = os.getenv("SIGNAL_TRADE_BASE_URL", "https://signalautomotive.uk")
account = get_trade_api_account(api_key, base_url)
print(f"{account['business']['name']} · {account['apiKey']['scope']}")
```

## Calculate a trade valuation

Use an `appraisal:read` key to request a Signal estimate and buying plan. The calculation does not save an appraisal, and the client does not automatically retry this POST request. Prices are integer pence and the response identifies its evidence source and confidence; estimates may be unavailable when matching evidence is thin.

```python
import os

from signal_trade_stock_sync import calculate_trade_valuation

api_key = os.environ["SIGNAL_TRADE_API_KEY"]
base_url = os.getenv("SIGNAL_TRADE_BASE_URL", "https://signalautomotive.uk")
result = calculate_trade_valuation(
    api_key,
    {
        "registration": "AB12CDE",
        "mileage": 24500,
        "condition": "good",
        "sellerType": "private",
    },
    base_url,
)
valuation = result["valuation"]
print(valuation["prices"]["retailPence"])
print(valuation["buyingPlan"]["maximumBuyPence"])
print(valuation["evidence"]["provider"], valuation["evidence"]["confidence"])
print(valuation["interpretation"])
```

The API uses Signal's historical evidence and the approved dealership's recorded outcomes. It does not call licensed valuation feeds or provide BCA or Auto Trader data. Review the source and limits before using the estimate in a purchase decision.

## First stock sync

Capture the stock feed checkpoint **before** loading the full inventory, then replay changes from that point:

```python
import os

from signal_trade_stock_sync import (
    get_stock_event_checkpoint,
    load_all_stock,
    sync_stock,
)

api_key = os.environ["SIGNAL_TRADE_API_KEY"]
base_url = os.getenv("SIGNAL_TRADE_BASE_URL", "https://signalautomotive.uk")
checkpoint = read_checkpoint_from_your_database()

if checkpoint is None:
    checkpoint = get_stock_event_checkpoint(api_key, base_url)
    load_all_stock(
        api_key,
        on_vehicle=lambda vehicle: upsert_vehicle_by_signal_id(vehicle),
        on_sites=lambda sites: replace_sites(sites),
        base_url=base_url,
    )

result = sync_stock(
    api_key,
    checkpoint,
    on_vehicle=lambda vehicle: upsert_vehicle_by_signal_id(vehicle),
    on_delete=lambda record_id: remove_vehicle_by_signal_id(record_id),
    on_sites_changed=lambda: refresh_sites(base_url, api_key),
    save_checkpoint=lambda value: write_checkpoint_to_your_database(value),
    base_url=base_url,
)
print(result)
```

This lower-level example shows the sequence, not durable storage. Implement the callbacks inside your own transaction, bind the database to the current business and scope, and replace/prune the full dataset only after a complete reconciliation. Upsert and delete by Signal record ID, committing records and checkpoint together. The runnable mirror below supplies these safeguards. A failed callback leaves the old checkpoint, so any independently committed callbacks must be safe to repeat.

## Runnable SQLite stock mirror

For a working stock copy, save `signal_trade_stock_sync.py` and `signal_trade_sqlite_example.py` together. Set `SIGNAL_TRADE_API_KEY` privately on the server to your stock-read key, then run:

```sh
python signal_trade_sqlite_example.py
```

Read the result for the confirmed local stock count and saved checkpoint. The default database is `signal-trade-stock.db`; set `SIGNAL_TRADE_SQLITE_PATH` for another location. Run the command again to fetch changes. Each invocation runs once, so your receiving system chooses its schedule within the API's hourly limits.

| Option | Use |
| --- | --- |
| `--status` | Inspect local saved sync information without contacting Signal. |
| `--reconcile` | Refresh and reconcile the full inventory now. |
| `--rebind` | Deliberately rebuild the same dealership's copy after changing a key or its permission, or upgrade an older unbound mirror. |

Add the option after the filename, for example `python signal_trade_sqlite_example.py --reconcile`.

The mirror reconciles the full inventory at least daily when invoked. It captures a server checkpoint before loading every stock page, catches up changes and checks the dealership and exact permission again before committing. Missing old rows are pruned only after the complete operation succeeds. Ordinary polls apply vehicles, deletions, branches and checkpoint in one SQLite transaction. Interrupted runs and failed reads retain the previous complete copy; retry after resolving the reported problem. One writer is allowed at a time; readers can use the last committed copy while it runs.

The database is bound to its server, business, key ID and exact permission without storing the bearer token. Another dealership or server needs a separate database. Key or permission changes require `--rebind`; a successful lower-permission rebuild removes older finance fields from the current mirror, while your own backups and exports need separate retention handling. Keep the database on trusted server storage. Python and Node stock mirrors share the same database format.

`sync_stock` collects affected IDs across the complete event window, then reads their current state. Present records are kept after deletion and recreation; missing IDs are removed only with deletion evidence in that poll. A vehicle with stage `removed` still exists. Other missing IDs stop the poll, so a history gap needs investigation and a deliberate full reconciliation. The event feed is pull based and only includes retained audit history. This reference copy does not automatically publish a website or prove that an external dealer system is integrated.

Existing integrations can use `sync_stock_deletions` to read the separate retained ID-only deletion feed. It has its own checkpoint: pass `None` on the first call to read the full retained history, then persist the returned checkpoint separately from `sync_stock`'s checkpoint. The helper validates and deduplicates each page before running `on_delete` and tracks returned cursors to detect a broken page chain. It saves the checkpoint only after every page and callback succeeds; if a later page fails, earlier callbacks may replay and must be idempotent by Signal ID. The Node client provides the equivalent `syncStockDeletions` helper.

## Create and update stock

Use the write helpers when onboarding an acquired vehicle or keeping dealer-recorded operational details aligned. Persist each UUID v4 `create_id` in your own system before sending it. If the response is uncertain, resend the identical vehicle with the same ID; changed details under that ID are rejected. A management key is required.

```python
from signal_trade_stock_sync import create_stock_vehicle, create_stock_vehicles, get_stock_vehicle, record_stock_sale, update_stock_vehicle, update_stock_vehicles

created = create_stock_vehicle(
    api_key,
    create_id="550e8400-e29b-41d4-a716-446655440000",
    vehicle={
        "stockNumber": "ST-2048",
        "registration": "AB12CDE",
        "make": "Example",
        "model": "Model",
        "year": 2022,
        "serviceIntervalMiles": 10000,
        "serviceIntervalMonths": 12,
        "lastServiceDate": "2026-04-12",
        "lastServiceMileage": 22000,
        "insuranceExpiryDate": "",
        "leaseEndDate": "",
    },
    base_url=base_url,
)
vehicle = created["vehicle"]

# Save each UUID v4 before sending; reuse those IDs only for identical retries.
batch = create_stock_vehicles(
    api_key,
    vehicles=[
        {
            "create_id": "550e8400-e29b-41d4-a716-446655440010",
            "vehicle": {"stockNumber": "ST-2049", "registration": "CD34EFG", "make": "Example", "model": "Model", "year": 2021},
        },
        {
            "create_id": "550e8400-e29b-41d4-a716-446655440011",
            "vehicle": {"stockNumber": "ST-2050", "registration": "EF56GHI", "make": "Example", "model": "Model", "year": 2020},
        },
    ],
    base_url=base_url,
)
for row in batch["results"]:
    if row["status"] not in {"created", "replayed"}:
        print("Reconcile before retry:", row)

vehicle_update = update_stock_vehicle(
    api_key,
    vehicle_id=vehicle["id"],
    expected_updated_at=vehicle["updatedAt"],
    changes={"insuranceExpiryDate": "2027-04-12", "nextServiceMileage": 32000},
    base_url=base_url,
)

# Batch up to 25 independent updates; inspect each result before retrying.
batch = update_stock_vehicles(
    api_key,
    updates=[
        {
            "vehicle_id": vehicle["id"],
            "expected_updated_at": vehicle_update["updatedAt"],
            "changes": {"insuranceExpiryDate": "2027-04-12"},
        },
        {
            "vehicle_id": "another-stock-id",
            "expected_updated_at": "2026-09-28T12:00:00.000Z",
            "changes": {"askingPricePence": 1849500, "askingPriceVatBasis": "unknown"},
        },
    ],
    base_url=base_url,
)
for row in batch["results"]:
    if row["status"] != "updated":
        print("Reconcile before retry:", row)

# Save this UUID before the request; retry only with the identical sale details.
vehicle = get_stock_vehicle(api_key, vehicle["id"], base_url=base_url)
record_stock_sale(
    api_key,
    vehicle_id=vehicle["id"],
    sale_id="550e8400-e29b-41d4-a716-446655440004",
    sale={
        "expectedUpdatedAt": vehicle["updatedAt"],
        "soldPricePence": 1795000,
        "soldDate": "2026-09-27",
        "soldPriceVatBasis": "vat_not_applicable",
        "disposalRoute": "retail_consumer",
        "conditionAtSale": "good",
        "mileageAtSale": 25100,
    },
    base_url=base_url,
)
```

`stock:manage` can create stock and update vehicle details, status and operational dates. `stock:read_write` cannot create stock or change those management fields. `create_stock_vehicles` sends up to 25 new records in one request; persist a distinct UUID v4 `create_id` for each before sending. After an uncertain response, retry the identical batch with the same IDs or reconcile those IDs first. Inspect each row because a batch can partially succeed. Every ordinary update requires the exact `updatedAt` received from Signal; on HTTP 409, reload and reconcile before retrying with the new timestamp. `update_stock_vehicles` sends up to 25 independent updates in one request; inspect each row because a batch can partially succeed, and reconcile rejected or unprocessed rows before retrying. Record a completed sale separately with a stable UUID v4 `sale_id` and `record_stock_sale`; after an uncertain response, retry only the identical sale with that same ID. If a next-service value is omitted on creation, Signal calculates it from a supplied last-service record and interval when possible. An explicit next-service value remains the dealer's override. MOT, tax, insurance, service and lease values are dealer-entered records, not independently verified checks. Writes are not automatically retried by the reference clients.

Each stock event must also have a supported action and the matching entity type (`stock_vehicle`, `vehicle` or `business`). Unknown actions and mismatched action/type pairs stop the poll before the checkpoint is saved, matching the Node.js helper's validation. Both reference clients require server timestamps to be ISO 8601 date-times with an explicit UTC marker or offset; malformed timestamps also stop the poll before checkpoint advancement.

## Runnable SQLite CRM mirror

For a small integration or a first end-to-end trial, download `signal_trade_stock_sync.py` and `signal_trade_crm_sqlite_example.py` into the same folder. Set `SIGNAL_TRADE_API_KEY` to a server-side `crm:read` key, then run:

```sh
python signal_trade_crm_sqlite_example.py
```

The example creates `signal-trade-crm.db`; set `SIGNAL_TRADE_CRM_SQLITE_PATH` to use another location. It loads customers, opportunities, tasks and activities into a JSON-backed SQLite mirror keyed by record kind and stable Signal ID. It captures the CRM feed checkpoint before loading the full records, then applies changes and advances the checkpoint in the same SQLite transaction. An interrupted poll can be safely replayed. Schedule the script at the cadence your integration needs; API rate limits apply. Keep the database and key on a trusted server, not in browser code or a public folder.

The mirror preserves the latest CRM record payload as returned by Signal. Adapt it to your own application schema before using it operationally. Do not assume that it sends messages, assigns work or synchronizes any other system; it only reads and mirrors Signal CRM records.

For Python CRM writes, see [the CRM helpers guide](./signal_trade_crm_helpers_README.md). It covers the assignee directory, idempotent customer/opportunity/task creation, and version-checked updates using a `crm:manage` key.

## Signed webhook receiver

For scheduled push notifications, download `signal_trade_webhook_receiver.py`, set `SIGNAL_TRADE_WEBHOOK_SECRET` to the one-time secret from Trade settings, and run it behind a public HTTPS reverse proxy:

```sh
python signal_trade_webhook_receiver.py
```

The example verifies HMAC-SHA256 over the exact timestamp and raw request body, rejects timestamps outside five minutes, limits request bodies to 1 MB and writes verified deliveries to a SQLite inbox before returning success. A repeated delivery with the same ID and payload is acknowledged without another inbox row; reusing a delivery ID for different content returns HTTP 409. The inbox file contains minimal event payloads and must stay on a trusted server.

This receiver durably accepts events but does not apply them to your stock or CRM system. Your integration should process pending rows from `webhook_inbox` and mark each one processed only after the corresponding business update succeeds. If both records share a database, apply the update and status change in one transaction; otherwise use your own idempotent job/outbox pattern. Keep the pull change feeds as a reconciliation path because webhooks are scheduled, can be delayed and retry on failure.

The runnable SQLite mirrors cover stock and CRM. The Python reference client also supports appraisal and preparation record sync as described next. The API returns each dealer's own authorised business records; licensed Auto Trader or BCA data, external vehicle checks and advertising-platform distribution require their own agreements.

## Appraisal, preparation and CRM sync

The Python client also provides `load_all_appraisals`, `load_all_preparation` and `load_all_crm`, plus `sync_trade_records` for checkpointed current-record sync. Use a separate saved checkpoint for each feed and the matching least-privilege key: `appraisal:read`, `prep:read` or `crm:read`. For each feed, capture the checkpoint first, complete the initial list, then replay events from that checkpoint:

```python
from signal_trade_stock_sync import (
    get_trade_event_checkpoint,
    load_all_appraisals,
    sync_trade_records,
)

checkpoint = get_trade_event_checkpoint(api_key, "appraisals")
load_all_appraisals(api_key, on_appraisal=upsert_by_signal_id)
result = sync_trade_records(
    api_key,
    "appraisals",
    checkpoint,
    on_record=lambda record, event: upsert_by_signal_id(record),
    save_checkpoint=save_appraisal_checkpoint,
)
```

For the other feeds, use `load_all_preparation` or `load_all_crm(api_key, "customers", ...)` (also `opportunities`, `tasks` or `activities`) before `sync_trade_records` with `"preparation"` or `"crm"`. The helper fetches each distinct changed record once and calls `on_record(record, event)`; CRM records are kept distinct by kind as well as ID. Upsert durably before saving the checkpoint. Make callbacks safe to replay because a failed read or write intentionally leaves the previous checkpoint in place. Appraisal keys include business-wide buying-plan and margin figures; issue them only to systems trusted with those figures.

For appraisal and preparation feeds, both clients accept only the documented created/updated actions for their event type. For CRM, customer, opportunity and task events accept their matching created/updated actions; activity events accept `activity_created` only. An unsupported action/type combination stops processing before the feed checkpoint is saved.
