Tekmetric REST API — authentication, paginated endpoints, sync patterns, and undocumented behaviors. Use when integrating with Tekmetric shop management data (customers, vehicles, repair orders, employees, appointments).
Tekmetric REST API — authentication, paginated endpoints, sync patterns, and undocumented behaviors. Use when integrating with Tekmetric shop management data (customers, vehicles, repair orders, employees, appointments).
Tekmetric API — Skill Reference
Full endpoint documentation: See Tekmetric-API.txt in this skill directory.
This skill focuses on patterns, gotchas, and integration knowledge that go beyond the raw docs.
Environments
Environment
Base URL
Rate Limit
Sandbox
https://sandbox.tekmetric.com
300 req/min
Production
https://shop.tekmetric.com
600 req/min
All endpoints are under /api/v1/.
Authentication
OAuth2 client credentials flow. Token does not expire until explicitly revoked.
scope is a space-separated list of Shop IDs the token has access to
Use the token as: -H "Authorization: Bearer <access_token>"
Token is long-lived — cache it and reuse until revoked
Token Lifecycle (Verified 2026-02-15)
The token endpoint is idempotent — calling it repeatedly returns the same token UUID, not a new one
Tokens do not expire unless explicitly revoked by Tekmetric
There is no self-service token rotation — you cannot programmatically revoke and reissue
The expires_in field may or may not be present in the response; if absent, treat as non-expiring
Security implications: Since the token is permanent and non-rotatable, treat the Client ID + Client Secret as the primary secrets to protect. Store them in sealed secrets (never in code or env files). The access token should be cached in memory only (never persisted to disk or logged). If a token is ever compromised, contact Tekmetric to revoke and reissue.
Pagination
All list endpoints return a Spring Data Page envelope:
⚠️ Always check the HTTP status code first — don't assume a JSON body exists.
Exponential Backoff for 429s
wait = min(2^n + random_ms, 60_000) # n starts at 1
Monetary Values
All monetary fields are in cents (integer). Examples:
laborSales: 13000 → $130.00
partsSales: 25997 → $259.97
cost: 15999 → $159.99
Development Rules
🔴 ALWAYS curl-test before committing code
Before writing or modifying any code that interacts with the Tekmetric API, verify the actual API behavior with a direct curl call first. Do not trust the official documentation alone — it is incomplete and sometimes inaccurate.
This rule applies when:
Integrating a new endpoint — curl it, inspect the full response shape, note which fields are present/absent
Using a new query parameter — curl with and without it to confirm the actual filtering behavior
Combining parameters — the API has known cases where parameter combinations produce surprising results (e.g., mixing updatedDateStart with deletedDateStart)
Debugging unexpected sync behavior — curl the raw API to isolate whether the issue is API-side or code-side
This rule does NOT apply to:
Refactoring code that handles a response shape you've already verified
Changes to local-only logic (database upserts, worker scheduling, etc.)
Why this matters: This API has undocumented behaviors that have caused production bugs. The employees endpoint silently omits shopId, error responses come in 3 different formats, and page sizes are silently capped. Every one of these was discovered through direct curl testing, not from reading docs.
These are field-hardened findings from production integration work. They are not in the official Tekmetric documentation.
🔴 CRITICAL: Split Calls Required for Deleted Records
When querying /repair-orders (and likely other endpoints), combining regular date params with deleted date params causes the API to return ONLY deleted records:
# ❌ WRONG — returns ONLY deleted records, not both
GET /repair-orders?shop=1&updatedDateStart=...&deletedDateStart=...
# ✅ CORRECT — two separate calls, merge in app code
# Call 1: active/updated records
GET /repair-orders?shop=1&updatedDateStart=...&updatedDateEnd=...
# Call 2: deleted records
GET /repair-orders?shop=1&deletedDateStart=...&deletedDateEnd=...
Confirmed October 2025. This applies to Repair Orders, Customers, and Vehicles.
Exception: Appointments use includeDeleted=true parameter instead of separate calls.
🔴 CRITICAL: Employees Endpoint Does NOT Return shopId
The /employees endpoint accepts a shop query parameter for filtering, but the response body does not include a shopId field. This means:
You must associate shop_id from the request context, not the response
If you rely on shopId being in the response for database mapping, it will always be nil
This caused a bug where has_any_employees? checks always returned false, triggering full re-syncs every cycle
🟡 Page Size Silently Capped
Requesting size=500 does not return an error — it silently returns 100 results. Always use size=100 explicitly to make code behavior match expectations.
🟡 Jobs Endpoint Filters by Job Update Date
The standalone /jobs endpoint filters by job.updatedDate, which may miss jobs that were part of a Repair Order update but not individually updated. For reliable job syncing, extract jobs from the embedded jobs array in /repair-orders/{id} responses instead.
🟡 Query Parameter Format
The API accepts both formats for query parameters:
# Both work — Req library handles either
{"shop", shop_id} # string-keyed tuple
[shop: shop_id] # atom-keyed keyword list
Standardize on string-keyed tuples for consistency across modules.
🟡 Token Scope
The scope field in the token response is a space-separated string of Shop IDs, not a permission scope. Example: "scope": "1 2" means access to shops 1 and 2.
🟢 Observed Latency
Network latency (~250-400ms per request) is typically the bottleneck before hitting rate limits. Sequential throughput maxes out around 200-240 req/min on the sandbox, well below the 300 req/min rate limit.
Elixir / Req Integration Pattern
Provider-Aware Client
defmodule SyncPlugins.Tekmetric.Client do
@doc "GET request using the provider's base_url and credentials."
def get(%SmsProvider{} = provider, path, params \\ []) do
Req.get(
url: provider.base_url <> "/api/v1" <> path,
headers: [{"Authorization", "Bearer #{get_token(provider)}"}],
params: params
)
end
end
Paginated Fetch
defmodule SyncPlugins.Tekmetric.Paging do
@doc "Fetch all pages using a closure that binds the provider."
def list_all_pages(get_fn, path, params, opts \\ []) do
size = Keyword.get(opts, :size, 100)
do_fetch(get_fn, path, [{"size", size} | params], 0, [])
end
defp do_fetch(get_fn, path, params, page, acc) do
case get_fn.(path, [{"page", page} | params]) do
{:ok, %{body: %{"content" => content, "last" => true}}} ->
{:ok, acc ++ content}
{:ok, %{body: %{"content" => content}}} ->
do_fetch(get_fn, path, params, page + 1, acc ++ content)
{:error, _} = error ->
error
end
end
end
Typical Module Pattern
defmodule SyncPlugins.Tekmetric.Customers do
def list_customers(%SmsProvider{} = provider, shop_id, params \\ []) do
get_fn = fn path, p -> Client.get(provider, path, p) end
Paging.list_all_pages(get_fn, "/customers", [{"shop", shop_id} | params])
end
def sync_recent(%SmsProvider{} = provider, tenant_id, shop_id, %DateTime{} = since) do
updated = list_customers(provider, shop_id, [
{"updatedDateStart", DateTime.to_iso8601(since)}
])
deleted = list_customers(provider, shop_id, [
{"deletedDateStart", DateTime.to_iso8601(since)}
])
# Merge by ID — deleted records may overlap with updated
merged = Map.merge(
Map.new(updated, &{&1["id"], &1}),
Map.new(deleted, &{&1["id"], &1})
) |> Map.values()
upsert_many(provider, tenant_id, merged)
end
end
Sync Strategy Summary
Entity
Updated Records
Deleted Records
Merge?
Customers
updatedDateStart/End
deletedDateStart/End
Yes
Vehicles
updatedDateStart/End
deletedDateStart/End
Yes
Repair Orders
updatedDateStart/End
deletedDateStart/End
Yes
Jobs
Derived from RO /repair-orders/{id}
Mark missing as deleted
N/A
Employees
updatedDateStart/End
Full re-fetch (no delete filter)
No
Appointments
updatedDateStart/End + includeDeleted=true
Same call
No
Shops
Full list (small dataset)
N/A
No
Local Development & Testing
Retrieving Sandbox Credentials
Sandbox credentials are stored as Kubernetes secrets in the breakdown-admin-secrets secret (namespace: default).
Git Bash:
# Extract credentials
TK_ID=$(kubectl get secret breakdown-admin-secrets -n default \
-o jsonpath='{.data.tekmetric_sandbox_client_id}' | base64 -d)
TK_SECRET=$(kubectl get secret breakdown-admin-secrets -n default \
-o jsonpath='{.data.tekmetric_sandbox_client_secret}' | base64 -d)
# Set for local use (optional — add to shell profile for persistence)export TEKMETRIC_SANDBOX_CLIENT_ID="$TK_ID"export TEKMETRIC_SANDBOX_CLIENT_SECRET="$TK_SECRET"
PowerShell:
# Extract credentials
$TK_ID = kubectl get secret breakdown-admin-secrets -n default `
-o jsonpath='{.data.tekmetric_sandbox_client_id}' | ForEach-Object {
[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($_))
}
$TK_SECRET = kubectl get secret breakdown-admin-secrets -n default `
-o jsonpath='{.data.tekmetric_sandbox_client_secret}' | ForEach-Object {
[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($_))
}
# Set for local use
$env:TEKMETRIC_SANDBOX_CLIENT_ID = $TK_ID
$env:TEKMETRIC_SANDBOX_CLIENT_SECRET = $TK_SECRET
Testing API Calls
Git Bash (curl):
# 1. Get a token
BASIC=$(echo -n "${TK_ID}:${TK_SECRET}" | base64)
TOKEN=$(curl -s -X POST 'https://sandbox.tekmetric.com/api/v1/oauth/token' \
-H "Authorization: Basic ${BASIC}" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" | grep -o '"access_token":"[^"]*"' | cut -d'"' -f4)
# 2. List shops
curl -s 'https://sandbox.tekmetric.com/api/v1/shops' \
-H "Authorization: Bearer $TOKEN"# 3. Fetch customers (paginated)
curl -s 'https://sandbox.tekmetric.com/api/v1/customers?shop=2&size=5' \
-H "Authorization: Bearer $TOKEN"# 4. Fetch a single repair order with embedded jobs
curl -s 'https://sandbox.tekmetric.com/api/v1/repair-orders?shop=2&size=1' \
-H "Authorization: Bearer $TOKEN"