P Promec
ENESPT
▶ Demo 3 months for €1
REST API v1

API reference

Every version 1 endpoint with its parameters, request and response examples, scopes, errors and webhook events.

Base URLEach shop runs the API on its own domain: https://<your-domain>/api/v1. Your shop's URL is shown in Developers / API; the examples use https://your-shop.example.

Authentication

Send the key in the Authorization: Bearer pt_live_… header (or X-Api-Key). pt_test_… keys can read, but any operation with side effects answers 403 test_key_forbidden.

Conventions

ISO 8601 UTC dates; EUR amounts with two decimals and currency: "EUR"; integer ids; stable snake_case field names. Workshop staff only appear as { id, name }.

Pagination

Lists return { data, next_cursor, has_more }. Fetch the next page by repeating the call with ?cursor=<next_cursor>. limit ranges 1–200 (default 50).

Error format

{
  "error": {
    "code": "insufficient_scope",
    "message": "The API key lacks the «budgets:read» scope."
  }
}
HTTPCodeMessage
401missing_api_keyMissing API key: send it in the Authorization: Bearer pt_… or X-Api-Key header.
401invalid_api_keyThe API key is not valid for this instance.
401revoked_api_keyThe API key has been revoked.
401expired_api_keyThe API key has expired.
403ip_not_allowedThe source IP address is not on this key's allow list.
403insufficient_scopeThe API key lacks the scope required for this operation.
403test_key_forbiddenA test key (pt_test_) cannot perform operations with side effects: use a live key.
429rate_limitedYou have exceeded this key's request limit. Wait and retry.
503instance_rate_limitedThe instance is receiving too many API requests right now. Retry in a few seconds.
429plan_quota_exceededThe workshop's API plan daily request quota is used up. It resets at 00:00 UTC; for more volume, upgrade the plan.
403plan_scope_not_allowedThe workshop's API plan does not include this scope. Upgrade the plan to use it.
400bad_requestThe request is not valid.
404not_foundResource not found.
502upstream_errorAn external service rejected the operation.
500internal_errorInternal error.
422validation_errorThe request body is not valid: check the «fields» list.
400idempotency_key_requiredMissing Idempotency-Key header (required on every POST; 8 to 255 visible characters).
409idempotency_conflictThat Idempotency-Key was already used in the last 24 h with a different request.
409idempotency_in_progressAnother request with the same Idempotency-Key is in progress. Retry in a few seconds.
409client_existsA client with that phone, email or tax id already exists.
409client_erasedThe client requested erasure of their data (GDPR): the record accepts no changes or linked records.
409vehicle_existsThat client already has a vehicle with that plate.
409vehicle_belongs_to_other_clientThat plate is already registered to another client.
409budget_lockedThe budget is closed and no longer accepts this change.
409invalid_status_transitionThe budget cannot move to that status from its current one.
403status_transition_forbiddenThat status change is not available through the API.
403client_acceptance_requiredBudget acceptance must be done by the client from their signed tracking link.
403booking_mode_propose_onlyThe workshop works in «propose appointment» mode: only proposals the workshop confirms can be created.
409slot_unavailableThat slot is not available.
413payload_too_largeThe file exceeds the maximum size (4 MB).
415unsupported_media_typeUnsupported file type: only JPEG, PNG, WebP or PDF.

Writes and idempotency

Every POST requires the Idempotency-Key header (a fresh UUID per operation). Repeating the same key with the same body within 24 h returns the stored response with Idempotent-Replay: true; with a different body it answers 409 idempotency_conflict. Bodies are validated against their schema and unknown fields are rejected: 422 validation_error with a fields list (path and message).

Guides

Guide: create a budget and propose an appointment

Typical flow for a CRM or booking site. Every POST carries an Idempotency-Key (a fresh UUID per operation; reuse it only when retrying the same one). You need a live key with clients:write, vehicles:write, budgets:write, appointments:read and appointments:write.

1. Client: create it or get the existing one

POST /api/v1/clients

{ "name": "Laura Gómez", "phone": "600111222", "email": "laura@ejemplo.com" }

With ?on_conflict=return_existing, if the phone, email or tax id exist you get that client (200) instead of 409.

2. The client's vehicle

POST /api/v1/vehicles

{ "client_id": 1204, "plate": "1234KLM", "brand": "Seat", "model": "León" }

If the plate belongs to another client: 409 vehicle_belongs_to_other_client (the workshop decides).

3. Budget with its lines

POST /api/v1/budgets

{ "client_id": 1204, "vehicle_id": 871, "lines": [{ "description": "Oil and filter change", "quantity": 1, "unit_price": 65 }] }

The response includes the computed totals and tracking_url: share it with the client so they can accept and sign.

4. Free slots

GET /api/v1/appointments/availability

?from=2026-10-14&to=2026-10-18&duration_minutes=60

5. Propose the appointment

POST /api/v1/appointments

{ "budget_id": 1234, "start": "2026-10-14T09:00:00+02:00", "duration_minutes": 60 }

In «propose» mode it stays status=proposed until the workshop confirms it (you get appointment.confirmed by webhook). If the slot was taken: 409 slot_unavailable with alternatives.

Scopes

ScopeNameSide effectsDescription
clients:readRead clientsNoView client records and contact details.
clients:writeCreate and edit clientsYesAdd new clients and change existing ones.
vehicles:readRead vehiclesNoView vehicles, plates and their history.
vehicles:writeCreate and edit vehiclesYesAdd vehicles and change their details.
budgets:readRead budgetsNoView budgets, their lines and status.
budgets:writeCreate and edit budgetsYesCreate budgets, add lines and change their status.
invoices:readRead invoicesNoView issued invoices, amounts and payments. Issuing invoices is not available through the API.
appointments:readRead appointmentsNoView the appointment diary and available slots.
appointments:writeBook and cancel appointmentsYesCreate, move and cancel appointments in the diary.
communications:readRead communicationsNoView the log of emails, SMS, WhatsApp and calls (including full content).
communications:sendSend email and SMSYesSend transactional emails and SMS from the instance; uses credit.
catalog:readRead catalogNoView services, rates and price-list items.
stock:readRead stockNoView parts stock and part numbers.
stock:writeAdjust stockYesRecord parts in and out.
webhooks:manageManage webhooksYesCreate, list and delete this key's event subscriptions.
reports:readRead reportsNoView aggregated revenue, activity and performance figures.

General

GET /api/v1/ping Any key

Test the connection

Returns the key name, environment, scopes and rate-limit state. Any valid key works.

Any valid key, no specific scope.

Example

curl "https://your-shop.example/api/v1/ping" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

{
  "ok": true,
  "key": {
    "id": 7,
    "name": "CRM",
    "environment": "live",
    "scopes": [
      "clients:read"
    ],
    "expires_at": null
  },
  "rate_limit": {
    "per_minute": {
      "limit": 60,
      "used": 1,
      "remaining": 59
    },
    "per_day": {
      "limit": 20000,
      "used": 1,
      "remaining": 19999
    }
  },
  "instance": "taller.ejemplo.com",
  "server_time": "2026-10-06T09:30:00.000Z",
  "version": "v1"
}

GET /api/v1/workshop Any key

Workshop public data

Name, legal name, tax id, address, contact details, weekly schedule, appointment booking mode, upcoming holidays and timezone.

Any valid key, no specific scope.

Example

curl "https://your-shop.example/api/v1/workshop" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

{
  "name": "Taller Ejemplo",
  "legal_name": "Taller Ejemplo S.L.",
  "tax_id": "B12345678",
  "address": "C/ Industria 4",
  "city": "Barcelona",
  "zip": "08020",
  "province": "Barcelona",
  "phone": "+34931234567",
  "whatsapp": "+34600111222",
  "email": "taller@ejemplo.com",
  "web": "https://www.ejemplo.com",
  "logo_url": null,
  "timezone": "Europe/Madrid",
  "currency": "EUR",
  "booking_mode": "propose",
  "schedule": {
    "mon": {
      "start": "08:00",
      "end": "19:00",
      "closed": false
    },
    "tue": {
      "start": "08:00",
      "end": "19:00",
      "closed": false
    },
    "wed": {
      "start": "08:00",
      "end": "19:00",
      "closed": false
    },
    "thu": {
      "start": "08:00",
      "end": "19:00",
      "closed": false
    },
    "fri": {
      "start": "08:00",
      "end": "19:00",
      "closed": false
    },
    "sat": {
      "start": "08:00",
      "end": "19:00",
      "closed": false
    },
    "sun": {
      "start": "08:00",
      "end": "19:00",
      "closed": false
    }
  },
  "lunch_break": {
    "start": "13:30",
    "end": "15:00"
  },
  "min_slot_minutes": 60,
  "upcoming_holidays": [
    {
      "date": "2026-10-12",
      "name": "Fiesta Nacional de España",
      "scope": "nacional"
    }
  ]
}

GET /api/v1/openapi.json No authentication

OpenAPI 3.1 specification

Generated from this very catalog. No authentication. Accepts ?lang=es|en|ca|pt|fr|bg for the descriptions.

No authentication.

Query

NameTypeRequiredDescription
langstring [es, en, ca, pt, fr, bg] · default esNoLanguage of the descriptions.

Example

curl "https://your-shop.example/api/v1/openapi.json"

Response

{}

Clients

GET /api/v1/clients clients:read

List clients

Ordered by ascending id. Clients erased under GDPR appear anonymised, with erased_at set.

Required scope: clients:read.

Query

NameTypeRequiredDescription
searchstringNoSearches name, phone, email and tax id (2 characters minimum).
expandstring [vehicles]NoOptional relations to include.
limitinteger · default 50NoResults per page (1–200).
cursorstringNoOpaque cursor returned as next_cursor by the previous page.

Example

curl "https://your-shop.example/api/v1/clients" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

{
  "data": [
    {
      "id": 1204,
      "name": "Laura Gómez",
      "phone": "+34600111222",
      "phone_secondary": null,
      "whatsapp_phone": null,
      "email": "laura@ejemplo.com",
      "billing_email": null,
      "tax_id": "12345678Z",
      "address": "C/ Mayor 12",
      "city": "Barcelona",
      "zip": "08001",
      "province": "Barcelona",
      "country": "ES",
      "preferred_language": "es",
      "preferred_contact_method": "whatsapp",
      "vip": false,
      "marketing_opt_out": false,
      "channel_opt_out": {
        "email": false,
        "sms": false,
        "whatsapp": false,
        "call": false
      },
      "erased_at": null,
      "vehicles": [
        {
          "id": 871,
          "client_id": 1204,
          "client": null,
          "plate": "1234 KLM",
          "vin": "VSSZZZ5FZJR123456",
          "brand": "Seat",
          "model": "León",
          "variant": "1.5 TSI",
          "year": 2019,
          "registration_date": "2019-03-15",
          "fuel": "Gasolina",
          "transmission": "Manual",
          "engine_code": "DADA",
          "horsepower": 130,
          "displacement": "1498",
          "color_code": null,
          "environmental_label": "C",
          "km": 84500,
          "itv_expiry_date": "2027-03-15",
          "status": "activo"
        }
      ]
    }
  ],
  "next_cursor": "aWQ6MTIzNA",
  "has_more": true
}

POST /api/v1/clients clients:write

Create a client

Never merges into an existing record: if the tax id, email or phone already belong to another client it answers 409 client_exists with existing_id (or 200 with that client when ?on_conflict=return_existing). Phones are stored like in the app (Spain as 9 digits, other countries with prefix), emails lower-cased; typos are not auto-corrected. Marketing opt-outs are recorded in the consent log.

Required scope: clients:write.

Query

NameTypeRequiredDescription
on_conflictstring [error, return_existing] · default errorNoerror (default): 409 when it exists. return_existing: 200 with the existing client.

Headers

NameTypeRequiredDescription
Idempotency-KeystringYesUnique key per operation (UUID recommended). Repeating it with the same body within 24 h returns the stored response with Idempotent-Replay: true; with a different body, 409 idempotency_conflict.

Body

{
  "name": "Laura Gómez",
  "phone": "600111222",
  "phone_secondary": null,
  "email": "laura@ejemplo.com",
  "billing_email": null,
  "tax_id": "12345678Z",
  "address": "C/ Mayor 12",
  "city": "Barcelona",
  "zip": "08001",
  "province": "Barcelona",
  "country": "ES",
  "preferred_language": "es",
  "preferred_contact_method": "whatsapp",
  "marketing_opt_out": false,
  "channel_opt_out": {
    "email": false,
    "sms": false,
    "whatsapp": false,
    "call": false
  }
}

Example

curl -X POST "https://your-shop.example/api/v1/clients" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"name":"Laura Gómez","phone":"600111222","phone_secondary":null,"email":"laura@ejemplo.com","billing_email":null,"tax_id":"12345678Z","address":"C/ Mayor 12","city":"Barcelona","zip":"08001","province":"Barcelona","country":"ES","preferred_language":"es","preferred_contact_method":"whatsapp","marketing_opt_out":false,"channel_opt_out":{"email":false,"sms":false,"whatsapp":false,"call":false}}'

Response

{
  "id": 1204,
  "name": "Laura Gómez",
  "phone": "+34600111222",
  "phone_secondary": null,
  "whatsapp_phone": null,
  "email": "laura@ejemplo.com",
  "billing_email": null,
  "tax_id": "12345678Z",
  "address": "C/ Mayor 12",
  "city": "Barcelona",
  "zip": "08001",
  "province": "Barcelona",
  "country": "ES",
  "preferred_language": "es",
  "preferred_contact_method": "whatsapp",
  "vip": false,
  "marketing_opt_out": false,
  "channel_opt_out": {
    "email": false,
    "sms": false,
    "whatsapp": false,
    "call": false
  },
  "erased_at": null,
  "vehicles": [
    {
      "id": 871,
      "client_id": 1204,
      "client": {
        "id": 1204,
        "name": "Laura Gómez"
      },
      "plate": "1234 KLM",
      "vin": "VSSZZZ5FZJR123456",
      "brand": "Seat",
      "model": "León",
      "variant": "1.5 TSI",
      "year": 2019,
      "registration_date": "2019-03-15",
      "fuel": "Gasolina",
      "transmission": "Manual",
      "engine_code": "DADA",
      "horsepower": 130,
      "displacement": "1498",
      "color_code": null,
      "environmental_label": "C",
      "km": 84500,
      "itv_expiry_date": "2027-03-15",
      "status": "activo"
    }
  ]
}

GET /api/v1/clients/{id} clients:read

Client detail

Required scope: clients:read.

Parameters

NameTypeRequiredDescription
idintegerYesClient id.

Query

NameTypeRequiredDescription
expandstring [vehicles]NoOptional relations to include.

Example

curl "https://your-shop.example/api/v1/clients/1234" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

{
  "id": 1204,
  "name": "Laura Gómez",
  "phone": "+34600111222",
  "phone_secondary": null,
  "whatsapp_phone": null,
  "email": "laura@ejemplo.com",
  "billing_email": null,
  "tax_id": "12345678Z",
  "address": "C/ Mayor 12",
  "city": "Barcelona",
  "zip": "08001",
  "province": "Barcelona",
  "country": "ES",
  "preferred_language": "es",
  "preferred_contact_method": "whatsapp",
  "vip": false,
  "marketing_opt_out": false,
  "channel_opt_out": {
    "email": false,
    "sms": false,
    "whatsapp": false,
    "call": false
  },
  "erased_at": null,
  "vehicles": [
    {
      "id": 871,
      "client_id": 1204,
      "client": {
        "id": 1204,
        "name": "Laura Gómez"
      },
      "plate": "1234 KLM",
      "vin": "VSSZZZ5FZJR123456",
      "brand": "Seat",
      "model": "León",
      "variant": "1.5 TSI",
      "year": 2019,
      "registration_date": "2019-03-15",
      "fuel": "Gasolina",
      "transmission": "Manual",
      "engine_code": "DADA",
      "horsepower": 130,
      "displacement": "1498",
      "color_code": null,
      "environmental_label": "C",
      "km": 84500,
      "itv_expiry_date": "2027-03-15",
      "status": "activo"
    }
  ]
}

PATCH /api/v1/clients/{id} clients:write

Update a client

Only the fields sent change (null clears a field). Changing email or phone is allowed and records old → new in the client's activity with the key name; emits client.updated. Setting another client's email, phone or tax id answers 409 client_exists. GDPR-erased clients answer 409 client_erased.

Required scope: clients:write.

Parameters

NameTypeRequiredDescription
idintegerYesClient id.

Headers

NameTypeRequiredDescription
Idempotency-KeystringNoUnique key per operation (UUID recommended). Repeating it with the same body within 24 h returns the stored response with Idempotent-Replay: true; with a different body, 409 idempotency_conflict.

Body

{
  "name": "Laura Gómez Ruiz",
  "phone": "600111222",
  "phone_secondary": null,
  "email": "laura@ejemplo.com",
  "billing_email": null,
  "tax_id": "12345678Z",
  "address": "C/ Mayor 12",
  "city": "Barcelona",
  "zip": "08001",
  "province": "Barcelona",
  "country": "ES",
  "preferred_language": "es",
  "preferred_contact_method": "whatsapp",
  "marketing_opt_out": false,
  "channel_opt_out": {
    "email": false,
    "sms": false,
    "whatsapp": false,
    "call": false
  }
}

Example

curl -X PATCH "https://your-shop.example/api/v1/clients/1234" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"name":"Laura Gómez Ruiz","phone":"600111222","phone_secondary":null,"email":"laura@ejemplo.com","billing_email":null,"tax_id":"12345678Z","address":"C/ Mayor 12","city":"Barcelona","zip":"08001","province":"Barcelona","country":"ES","preferred_language":"es","preferred_contact_method":"whatsapp","marketing_opt_out":false,"channel_opt_out":{"email":false,"sms":false,"whatsapp":false,"call":false}}'

Response

{
  "id": 1204,
  "name": "Laura Gómez",
  "phone": "+34600111222",
  "phone_secondary": null,
  "whatsapp_phone": null,
  "email": "laura@ejemplo.com",
  "billing_email": null,
  "tax_id": "12345678Z",
  "address": "C/ Mayor 12",
  "city": "Barcelona",
  "zip": "08001",
  "province": "Barcelona",
  "country": "ES",
  "preferred_language": "es",
  "preferred_contact_method": "whatsapp",
  "vip": false,
  "marketing_opt_out": false,
  "channel_opt_out": {
    "email": false,
    "sms": false,
    "whatsapp": false,
    "call": false
  },
  "erased_at": null,
  "vehicles": [
    {
      "id": 871,
      "client_id": 1204,
      "client": {
        "id": 1204,
        "name": "Laura Gómez"
      },
      "plate": "1234 KLM",
      "vin": "VSSZZZ5FZJR123456",
      "brand": "Seat",
      "model": "León",
      "variant": "1.5 TSI",
      "year": 2019,
      "registration_date": "2019-03-15",
      "fuel": "Gasolina",
      "transmission": "Manual",
      "engine_code": "DADA",
      "horsepower": 130,
      "displacement": "1498",
      "color_code": null,
      "environmental_label": "C",
      "km": 84500,
      "itv_expiry_date": "2027-03-15",
      "status": "activo"
    }
  ]
}

Vehicles

GET /api/v1/vehicles vehicles:read

List vehicles

Includes the latest recorded mileage and the MOT (ITV) expiry date.

Required scope: vehicles:read.

Query

NameTypeRequiredDescription
platestringNoExact plate (spaces and dashes ignored).
client_idintegerNoFilter by client.
statusstring [activo, baja_temporal, baja]NoVehicle status.
limitinteger · default 50NoResults per page (1–200).
cursorstringNoOpaque cursor returned as next_cursor by the previous page.

Example

curl "https://your-shop.example/api/v1/vehicles" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

{
  "data": [
    {
      "id": 871,
      "client_id": 1204,
      "client": {
        "id": 1204,
        "name": "Laura Gómez"
      },
      "plate": "1234 KLM",
      "vin": "VSSZZZ5FZJR123456",
      "brand": "Seat",
      "model": "León",
      "variant": "1.5 TSI",
      "year": 2019,
      "registration_date": "2019-03-15",
      "fuel": "Gasolina",
      "transmission": "Manual",
      "engine_code": "DADA",
      "horsepower": 130,
      "displacement": "1498",
      "color_code": null,
      "environmental_label": "C",
      "km": 84500,
      "itv_expiry_date": "2027-03-15",
      "status": "activo"
    }
  ],
  "next_cursor": "aWQ6MTIzNA",
  "has_more": true
}

POST /api/v1/vehicles vehicles:write

Create a vehicle

Always for an existing client (client_id). The plate is normalised (upper case, no spaces or dashes). If it belongs to ANOTHER client: 409 vehicle_belongs_to_other_client. If the same client already has it: 409 vehicle_exists with existing_id (or 200 with ?on_conflict=return_existing).

Required scope: vehicles:write.

Query

NameTypeRequiredDescription
on_conflictstring [error, return_existing] · default errorNoerror (default) or return_existing.

Headers

NameTypeRequiredDescription
Idempotency-KeystringYesUnique key per operation (UUID recommended). Repeating it with the same body within 24 h returns the stored response with Idempotent-Replay: true; with a different body, 409 idempotency_conflict.

Body

{
  "client_id": 1204,
  "plate": "1234 KLM",
  "vin": "VSSZZZ5FZJR123456",
  "brand": "Seat",
  "model": "León",
  "variant": "1.5 TSI",
  "year": 2019,
  "registration_date": "2019-03-15",
  "fuel": "Gasolina",
  "transmission": "Manual",
  "engine_code": "DADA",
  "horsepower": 130,
  "displacement": "1498",
  "color_code": null,
  "environmental_label": "C",
  "itv_expiry_date": "2027-03-15",
  "status": "activo"
}

Example

curl -X POST "https://your-shop.example/api/v1/vehicles" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"client_id":1204,"plate":"1234 KLM","vin":"VSSZZZ5FZJR123456","brand":"Seat","model":"León","variant":"1.5 TSI","year":2019,"registration_date":"2019-03-15","fuel":"Gasolina","transmission":"Manual","engine_code":"DADA","horsepower":130,"displacement":"1498","color_code":null,"environmental_label":"C","itv_expiry_date":"2027-03-15","status":"activo"}'

Response

{
  "id": 871,
  "client_id": 1204,
  "client": {
    "id": 1204,
    "name": "Laura Gómez"
  },
  "plate": "1234 KLM",
  "vin": "VSSZZZ5FZJR123456",
  "brand": "Seat",
  "model": "León",
  "variant": "1.5 TSI",
  "year": 2019,
  "registration_date": "2019-03-15",
  "fuel": "Gasolina",
  "transmission": "Manual",
  "engine_code": "DADA",
  "horsepower": 130,
  "displacement": "1498",
  "color_code": null,
  "environmental_label": "C",
  "km": 84500,
  "itv_expiry_date": "2027-03-15",
  "status": "activo"
}

GET /api/v1/vehicles/{id} vehicles:read

Vehicle detail

Required scope: vehicles:read.

Parameters

NameTypeRequiredDescription
idintegerYesVehicle id.

Example

curl "https://your-shop.example/api/v1/vehicles/1234" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

{
  "id": 871,
  "client_id": 1204,
  "client": {
    "id": 1204,
    "name": "Laura Gómez"
  },
  "plate": "1234 KLM",
  "vin": "VSSZZZ5FZJR123456",
  "brand": "Seat",
  "model": "León",
  "variant": "1.5 TSI",
  "year": 2019,
  "registration_date": "2019-03-15",
  "fuel": "Gasolina",
  "transmission": "Manual",
  "engine_code": "DADA",
  "horsepower": 130,
  "displacement": "1498",
  "color_code": null,
  "environmental_label": "C",
  "km": 84500,
  "itv_expiry_date": "2027-03-15",
  "status": "activo"
}

PATCH /api/v1/vehicles/{id} vehicles:write

Update a vehicle

Partial changes. client_id does not accept null: a vehicle is never unlinked from its owner via the API (it can move to another existing client).

Required scope: vehicles:write.

Parameters

NameTypeRequiredDescription
idintegerYesVehicle id.

Headers

NameTypeRequiredDescription
Idempotency-KeystringNoUnique key per operation (UUID recommended). Repeating it with the same body within 24 h returns the stored response with Idempotent-Replay: true; with a different body, 409 idempotency_conflict.

Body

{
  "client_id": 1204,
  "plate": "1234 KLM",
  "vin": "VSSZZZ5FZJR123456",
  "brand": "Seat",
  "model": "León",
  "variant": "1.5 TSI",
  "year": 2019,
  "registration_date": "2019-03-15",
  "fuel": "Gasolina",
  "transmission": "Manual",
  "engine_code": "DADA",
  "horsepower": 130,
  "displacement": "1498",
  "color_code": null,
  "environmental_label": "C",
  "itv_expiry_date": "2027-03-15",
  "status": "activo"
}

Example

curl -X PATCH "https://your-shop.example/api/v1/vehicles/1234" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"client_id":1204,"plate":"1234 KLM","vin":"VSSZZZ5FZJR123456","brand":"Seat","model":"León","variant":"1.5 TSI","year":2019,"registration_date":"2019-03-15","fuel":"Gasolina","transmission":"Manual","engine_code":"DADA","horsepower":130,"displacement":"1498","color_code":null,"environmental_label":"C","itv_expiry_date":"2027-03-15","status":"activo"}'

Response

{
  "id": 871,
  "client_id": 1204,
  "client": {
    "id": 1204,
    "name": "Laura Gómez"
  },
  "plate": "1234 KLM",
  "vin": "VSSZZZ5FZJR123456",
  "brand": "Seat",
  "model": "León",
  "variant": "1.5 TSI",
  "year": 2019,
  "registration_date": "2019-03-15",
  "fuel": "Gasolina",
  "transmission": "Manual",
  "engine_code": "DADA",
  "horsepower": 130,
  "displacement": "1498",
  "color_code": null,
  "environmental_label": "C",
  "km": 84500,
  "itv_expiry_date": "2027-03-15",
  "status": "activo"
}

Budgets

GET /api/v1/budgets budgets:read

List budgets

Never includes the workshop's internal budgets nor cost data. Lines and totals are in /budgets/{id}.

Required scope: budgets:read.

Query

NameTypeRequiredDescription
statusstringNoExact status (Pendiente, Enviado, Aprobado, Finalizado, Facturado, Rechazado…).
client_idintegerNoFilter by client.
vehicle_idintegerNoFilter by vehicle.
fromstring (date-time)NoCreated on or after this date.
tostring (date-time)NoCreated on or before this date.
updated_sincestring (date-time)NoOnly records modified on or after this date (ISO 8601).
limitinteger · default 50NoResults per page (1–200).
cursorstringNoOpaque cursor returned as next_cursor by the previous page.

Example

curl "https://your-shop.example/api/v1/budgets" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

{
  "data": [
    {
      "id": 1234,
      "status": "Aprobado",
      "created_at": "2026-10-06T09:30:00.000Z",
      "updated_at": "2026-10-06T09:30:00.000Z",
      "channel": "web",
      "client_id": 1204,
      "client": {
        "id": 1204,
        "name": "Laura Gómez"
      },
      "vehicle_id": 871,
      "vehicle": {
        "id": 871,
        "plate": "1234 KLM",
        "brand": "Seat",
        "model": "León"
      },
      "category": {
        "id": 5,
        "name": "Mantenimiento"
      },
      "subcategory": {
        "id": 5,
        "name": "Mantenimiento"
      },
      "assigned_user": {
        "id": 3,
        "name": "Marta"
      },
      "mechanic": {
        "id": 3,
        "name": "Marta"
      },
      "appointment": {
        "start": "2026-10-06T09:30:00.000Z",
        "end": "2026-10-06T10:30:00.000Z",
        "status": "confirmed",
        "client_confirmed_at": null,
        "estimated_duration_minutes": 60,
        "box": null
      },
      "date_in": null,
      "date_out": null,
      "km": 84500,
      "client_reference": null,
      "waiting_parts": false,
      "on_hold": false,
      "hold_until": null,
      "client_signed_at": null,
      "delivered_at": null,
      "reject_reason": null,
      "tracking_url": "https://taller.ejemplo.com/public/seguimiento?id=…"
    }
  ],
  "next_cursor": "aWQ6MTIzNA",
  "has_more": true
}

POST /api/v1/budgets budgets:write

Create a budget

Starts as «Pendiente» with channel «API», just like a budget created in the app (log, opening milestone, lead.created webhook). Totals are computed server-side with the workshop's tax; lines without tax_rate use the workshop rate. The vehicle, if given, must belong to the client. Internal-use categories are not allowed. With notify_client=true (and communications:send) the client gets the acknowledgement with their tracking link.

Required scope: budgets:write.

Headers

NameTypeRequiredDescription
Idempotency-KeystringYesUnique key per operation (UUID recommended). Repeating it with the same body within 24 h returns the stored response with Idempotent-Replay: true; with a different body, 409 idempotency_conflict.

Body

{
  "client_id": 1204,
  "vehicle_id": 871,
  "category_id": 5,
  "subcategory_id": 51,
  "client_reference": "PED-2026-118",
  "public_notes": "Also check the suspension noise.",
  "lines": [
    {
      "description": "Oil and filter change",
      "quantity": 1,
      "unit_price": 65,
      "tax_rate": 21,
      "discount_pct": 0,
      "line_type": "labor",
      "reference": null,
      "group_title": null
    }
  ],
  "notify_client": false
}

Example

curl -X POST "https://your-shop.example/api/v1/budgets" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"client_id":1204,"vehicle_id":871,"category_id":5,"subcategory_id":51,"client_reference":"PED-2026-118","public_notes":"Also check the suspension noise.","lines":[{"description":"Oil and filter change","quantity":1,"unit_price":65,"tax_rate":21,"discount_pct":0,"line_type":"labor","reference":null,"group_title":null}],"notify_client":false}'

Response

{
  "id": 1234,
  "status": "Aprobado",
  "created_at": "2026-10-06T09:30:00.000Z",
  "updated_at": "2026-10-06T09:30:00.000Z",
  "channel": "web",
  "client_id": 1204,
  "client": {
    "id": 1204,
    "name": "Laura Gómez"
  },
  "vehicle_id": 871,
  "vehicle": {
    "id": 871,
    "plate": "1234 KLM",
    "brand": "Seat",
    "model": "León"
  },
  "category": {
    "id": 5,
    "name": "Mantenimiento"
  },
  "subcategory": {
    "id": 5,
    "name": "Mantenimiento"
  },
  "assigned_user": {
    "id": 3,
    "name": "Marta"
  },
  "mechanic": {
    "id": 3,
    "name": "Marta"
  },
  "appointment": {
    "start": "2026-10-06T09:30:00.000Z",
    "end": "2026-10-06T10:30:00.000Z",
    "status": "confirmed",
    "client_confirmed_at": null,
    "estimated_duration_minutes": 60,
    "box": {
      "id": 2,
      "name": "Lift 2"
    }
  },
  "date_in": null,
  "date_out": null,
  "km": 84500,
  "client_reference": null,
  "waiting_parts": false,
  "on_hold": false,
  "hold_until": null,
  "client_signed_at": null,
  "delivered_at": null,
  "reject_reason": null,
  "tracking_url": "https://taller.ejemplo.com/public/seguimiento?id=…",
  "public_notes": "Also check the noise in the suspension.",
  "totals": {
    "base": 65,
    "tax": 13.65,
    "total": 78.65,
    "currency": "EUR",
    "tax_rates": [
      {
        "rate": 21,
        "base": 65,
        "quota": 13.65
      }
    ]
  },
  "lines": [
    {
      "id": 5501,
      "description": "Oil and filter change",
      "extended_detail": null,
      "reference": null,
      "line_type": "labor",
      "group_title": null,
      "quantity": 1,
      "unit_price": 65,
      "discount_pct": 0,
      "tax_rate": 21,
      "tax_exempt_code": null,
      "price_estimated": false,
      "base": 65,
      "currency": "EUR",
      "sort_order": 0
    }
  ]
}

GET /api/v1/budgets/{id} budgets:read

Budget detail

Lines (description, quantity, unit price, tax, type, discount), totals with breakdown, appointment, in/out dates, assigned user and public tracking URL.

Required scope: budgets:read.

Parameters

NameTypeRequiredDescription
idintegerYesBudget id.

Example

curl "https://your-shop.example/api/v1/budgets/1234" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

{
  "id": 1234,
  "status": "Aprobado",
  "created_at": "2026-10-06T09:30:00.000Z",
  "updated_at": "2026-10-06T09:30:00.000Z",
  "channel": "web",
  "client_id": 1204,
  "client": {
    "id": 1204,
    "name": "Laura Gómez"
  },
  "vehicle_id": 871,
  "vehicle": {
    "id": 871,
    "plate": "1234 KLM",
    "brand": "Seat",
    "model": "León"
  },
  "category": {
    "id": 5,
    "name": "Mantenimiento"
  },
  "subcategory": {
    "id": 5,
    "name": "Mantenimiento"
  },
  "assigned_user": {
    "id": 3,
    "name": "Marta"
  },
  "mechanic": {
    "id": 3,
    "name": "Marta"
  },
  "appointment": {
    "start": "2026-10-06T09:30:00.000Z",
    "end": "2026-10-06T10:30:00.000Z",
    "status": "confirmed",
    "client_confirmed_at": null,
    "estimated_duration_minutes": 60,
    "box": {
      "id": 2,
      "name": "Lift 2"
    }
  },
  "date_in": null,
  "date_out": null,
  "km": 84500,
  "client_reference": null,
  "waiting_parts": false,
  "on_hold": false,
  "hold_until": null,
  "client_signed_at": null,
  "delivered_at": null,
  "reject_reason": null,
  "tracking_url": "https://taller.ejemplo.com/public/seguimiento?id=…",
  "public_notes": "Also check the noise in the suspension.",
  "totals": {
    "base": 65,
    "tax": 13.65,
    "total": 78.65,
    "currency": "EUR",
    "tax_rates": [
      {
        "rate": 21,
        "base": 65,
        "quota": 13.65
      }
    ]
  },
  "lines": [
    {
      "id": 5501,
      "description": "Oil and filter change",
      "extended_detail": null,
      "reference": null,
      "line_type": "labor",
      "group_title": null,
      "quantity": 1,
      "unit_price": 65,
      "discount_pct": 0,
      "tax_rate": 21,
      "tax_exempt_code": null,
      "price_estimated": false,
      "base": 65,
      "currency": "EUR",
      "sort_order": 0
    }
  ]
}

POST /api/v1/budgets/{id}/lines budgets:write

Add a line

The other lines keep their ids. Takes a prior snapshot and logs the change like any edit in the app. 409 budget_locked when the budget is invoiced, cancelled, rejected, withdrawn or already has an invoice.

Required scope: budgets:write.

Parameters

NameTypeRequiredDescription
idintegerYesBudget id.

Headers

NameTypeRequiredDescription
Idempotency-KeystringYesUnique key per operation (UUID recommended). Repeating it with the same body within 24 h returns the stored response with Idempotent-Replay: true; with a different body, 409 idempotency_conflict.

Body

{
  "description": "Oil and filter change",
  "quantity": 1,
  "unit_price": 65,
  "tax_rate": 21,
  "discount_pct": 0,
  "line_type": "labor",
  "reference": null,
  "group_title": null
}

Example

curl -X POST "https://your-shop.example/api/v1/budgets/1234/lines" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"description":"Oil and filter change","quantity":1,"unit_price":65,"tax_rate":21,"discount_pct":0,"line_type":"labor","reference":null,"group_title":null}'

Response

{
  "line": {
    "id": 5501,
    "description": "Oil and filter change",
    "extended_detail": null,
    "reference": null,
    "line_type": "labor",
    "group_title": null,
    "quantity": 1,
    "unit_price": 65,
    "discount_pct": 0,
    "tax_rate": 21,
    "tax_exempt_code": null,
    "price_estimated": false,
    "base": 65,
    "currency": "EUR",
    "sort_order": 0
  },
  "budget": {
    "id": 1234,
    "status": "Aprobado",
    "created_at": "2026-10-06T09:30:00.000Z",
    "updated_at": "2026-10-06T09:30:00.000Z",
    "channel": "web",
    "client_id": 1204,
    "client": {
      "id": 1204,
      "name": "Laura Gómez"
    },
    "vehicle_id": 871,
    "vehicle": {
      "id": 871,
      "plate": "1234 KLM",
      "brand": "Seat",
      "model": "León"
    },
    "category": {
      "id": 5,
      "name": "Mantenimiento"
    },
    "subcategory": {
      "id": 5,
      "name": "Mantenimiento"
    },
    "assigned_user": {
      "id": 3,
      "name": "Marta"
    },
    "mechanic": {
      "id": 3,
      "name": "Marta"
    },
    "appointment": {
      "start": "2026-10-06T09:30:00.000Z",
      "end": "2026-10-06T10:30:00.000Z",
      "status": "confirmed",
      "client_confirmed_at": null,
      "estimated_duration_minutes": 60,
      "box": {
        "id": null,
        "name": null
      }
    },
    "date_in": null,
    "date_out": null,
    "km": 84500,
    "client_reference": null,
    "waiting_parts": false,
    "on_hold": false,
    "hold_until": null,
    "client_signed_at": null,
    "delivered_at": null,
    "reject_reason": null,
    "tracking_url": "https://taller.ejemplo.com/public/seguimiento?id=…",
    "public_notes": "Also check the noise in the suspension.",
    "totals": {
      "base": 65,
      "tax": 13.65,
      "total": 78.65,
      "currency": "EUR",
      "tax_rates": [
        {
          "rate": 21,
          "base": 65,
          "quota": 13.65
        }
      ]
    },
    "lines": [
      {
        "id": 5501,
        "description": "Oil and filter change",
        "extended_detail": null,
        "reference": null,
        "line_type": "labor",
        "group_title": null,
        "quantity": 1,
        "unit_price": 65,
        "discount_pct": 0,
        "tax_rate": 21,
        "tax_exempt_code": null,
        "price_estimated": false,
        "base": 65,
        "currency": "EUR",
        "sort_order": 0
      }
    ]
  }
}

PATCH /api/v1/budgets/{id}/lines/{lineId} budgets:write

Update a line

Required scope: budgets:write.

Parameters

NameTypeRequiredDescription
idintegerYesBudget id.
lineIdintegerYesLine id.

Headers

NameTypeRequiredDescription
Idempotency-KeystringNoUnique key per operation (UUID recommended). Repeating it with the same body within 24 h returns the stored response with Idempotent-Replay: true; with a different body, 409 idempotency_conflict.

Body

{
  "description": "Oil and filter change",
  "quantity": 1,
  "unit_price": 65,
  "tax_rate": 21,
  "discount_pct": 0,
  "line_type": "labor",
  "reference": null,
  "group_title": null
}

Example

curl -X PATCH "https://your-shop.example/api/v1/budgets/1234/lines/5501" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"description":"Oil and filter change","quantity":1,"unit_price":65,"tax_rate":21,"discount_pct":0,"line_type":"labor","reference":null,"group_title":null}'

Response

{
  "line": {
    "id": 5501,
    "description": "Oil and filter change",
    "extended_detail": null,
    "reference": null,
    "line_type": "labor",
    "group_title": null,
    "quantity": 1,
    "unit_price": 65,
    "discount_pct": 0,
    "tax_rate": 21,
    "tax_exempt_code": null,
    "price_estimated": false,
    "base": 65,
    "currency": "EUR",
    "sort_order": 0
  },
  "budget": {
    "id": 1234,
    "status": "Aprobado",
    "created_at": "2026-10-06T09:30:00.000Z",
    "updated_at": "2026-10-06T09:30:00.000Z",
    "channel": "web",
    "client_id": 1204,
    "client": {
      "id": 1204,
      "name": "Laura Gómez"
    },
    "vehicle_id": 871,
    "vehicle": {
      "id": 871,
      "plate": "1234 KLM",
      "brand": "Seat",
      "model": "León"
    },
    "category": {
      "id": 5,
      "name": "Mantenimiento"
    },
    "subcategory": {
      "id": 5,
      "name": "Mantenimiento"
    },
    "assigned_user": {
      "id": 3,
      "name": "Marta"
    },
    "mechanic": {
      "id": 3,
      "name": "Marta"
    },
    "appointment": {
      "start": "2026-10-06T09:30:00.000Z",
      "end": "2026-10-06T10:30:00.000Z",
      "status": "confirmed",
      "client_confirmed_at": null,
      "estimated_duration_minutes": 60,
      "box": {
        "id": null,
        "name": null
      }
    },
    "date_in": null,
    "date_out": null,
    "km": 84500,
    "client_reference": null,
    "waiting_parts": false,
    "on_hold": false,
    "hold_until": null,
    "client_signed_at": null,
    "delivered_at": null,
    "reject_reason": null,
    "tracking_url": "https://taller.ejemplo.com/public/seguimiento?id=…",
    "public_notes": "Also check the noise in the suspension.",
    "totals": {
      "base": 65,
      "tax": 13.65,
      "total": 78.65,
      "currency": "EUR",
      "tax_rates": [
        {
          "rate": 21,
          "base": 65,
          "quota": 13.65
        }
      ]
    },
    "lines": [
      {
        "id": 5501,
        "description": "Oil and filter change",
        "extended_detail": null,
        "reference": null,
        "line_type": "labor",
        "group_title": null,
        "quantity": 1,
        "unit_price": 65,
        "discount_pct": 0,
        "tax_rate": 21,
        "tax_exempt_code": null,
        "price_estimated": false,
        "base": 65,
        "currency": "EUR",
        "sort_order": 0
      }
    ]
  }
}

DELETE /api/v1/budgets/{id}/lines/{lineId} budgets:write

Delete a line

Returns the budget with recalculated totals. The line remains in the prior snapshot of the version history.

Required scope: budgets:write.

Parameters

NameTypeRequiredDescription
idintegerYesBudget id.
lineIdintegerYesLine id.

Example

curl -X DELETE "https://your-shop.example/api/v1/budgets/1234/lines/5501" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

{
  "id": 1234,
  "status": "Aprobado",
  "created_at": "2026-10-06T09:30:00.000Z",
  "updated_at": "2026-10-06T09:30:00.000Z",
  "channel": "web",
  "client_id": 1204,
  "client": {
    "id": 1204,
    "name": "Laura Gómez"
  },
  "vehicle_id": 871,
  "vehicle": {
    "id": 871,
    "plate": "1234 KLM",
    "brand": "Seat",
    "model": "León"
  },
  "category": {
    "id": 5,
    "name": "Mantenimiento"
  },
  "subcategory": {
    "id": 5,
    "name": "Mantenimiento"
  },
  "assigned_user": {
    "id": 3,
    "name": "Marta"
  },
  "mechanic": {
    "id": 3,
    "name": "Marta"
  },
  "appointment": {
    "start": "2026-10-06T09:30:00.000Z",
    "end": "2026-10-06T10:30:00.000Z",
    "status": "confirmed",
    "client_confirmed_at": null,
    "estimated_duration_minutes": 60,
    "box": {
      "id": 2,
      "name": "Lift 2"
    }
  },
  "date_in": null,
  "date_out": null,
  "km": 84500,
  "client_reference": null,
  "waiting_parts": false,
  "on_hold": false,
  "hold_until": null,
  "client_signed_at": null,
  "delivered_at": null,
  "reject_reason": null,
  "tracking_url": "https://taller.ejemplo.com/public/seguimiento?id=…",
  "public_notes": "Also check the noise in the suspension.",
  "totals": {
    "base": 65,
    "tax": 13.65,
    "total": 78.65,
    "currency": "EUR",
    "tax_rates": [
      {
        "rate": 21,
        "base": 65,
        "quota": 13.65
      }
    ]
  },
  "lines": [
    {
      "id": 5501,
      "description": "Oil and filter change",
      "extended_detail": null,
      "reference": null,
      "line_type": "labor",
      "group_title": null,
      "quantity": 1,
      "unit_price": 65,
      "discount_pct": 0,
      "tax_rate": 21,
      "tax_exempt_code": null,
      "price_estimated": false,
      "base": 65,
      "currency": "EUR",
      "sort_order": 0
    }
  ]
}

POST /api/v1/budgets/{id}/status budgets:write

Change the status

Accepts Pendiente/En cotización/Enviado (before acceptance), En curso (already accepted), Finalizado (from Aprobado, En curso or En espera) and Cancelado. «Aprobado» answers 403 client_acceptance_required with the tracking_url: acceptance is signed by the client. Invoicing, rejecting or withdrawing answer 403 status_transition_forbidden; from Facturado or Cancelado, 409 budget_locked. «Enviado» also requires communications:send and sent_via: it records that YOUR system already sent it (it does not send). No change notifies the client except Finalizado with notify_client=true and communications:send.

Required scope: budgets:write.

Parameters

NameTypeRequiredDescription
idintegerYesBudget id.

Headers

NameTypeRequiredDescription
Idempotency-KeystringYesUnique key per operation (UUID recommended). Repeating it with the same body within 24 h returns the stored response with Idempotent-Replay: true; with a different body, 409 idempotency_conflict.

Body

{
  "status": "En curso",
  "sent_via": "email",
  "notify_client": false
}

Example

curl -X POST "https://your-shop.example/api/v1/budgets/1234/status" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"status":"En curso","sent_via":"email","notify_client":false}'

Response

{
  "id": 1234,
  "status": "Aprobado",
  "created_at": "2026-10-06T09:30:00.000Z",
  "updated_at": "2026-10-06T09:30:00.000Z",
  "channel": "web",
  "client_id": 1204,
  "client": {
    "id": 1204,
    "name": "Laura Gómez"
  },
  "vehicle_id": 871,
  "vehicle": {
    "id": 871,
    "plate": "1234 KLM",
    "brand": "Seat",
    "model": "León"
  },
  "category": {
    "id": 5,
    "name": "Mantenimiento"
  },
  "subcategory": {
    "id": 5,
    "name": "Mantenimiento"
  },
  "assigned_user": {
    "id": 3,
    "name": "Marta"
  },
  "mechanic": {
    "id": 3,
    "name": "Marta"
  },
  "appointment": {
    "start": "2026-10-06T09:30:00.000Z",
    "end": "2026-10-06T10:30:00.000Z",
    "status": "confirmed",
    "client_confirmed_at": null,
    "estimated_duration_minutes": 60,
    "box": {
      "id": 2,
      "name": "Lift 2"
    }
  },
  "date_in": null,
  "date_out": null,
  "km": 84500,
  "client_reference": null,
  "waiting_parts": false,
  "on_hold": false,
  "hold_until": null,
  "client_signed_at": null,
  "delivered_at": null,
  "reject_reason": null,
  "tracking_url": "https://taller.ejemplo.com/public/seguimiento?id=…",
  "public_notes": "Also check the noise in the suspension.",
  "totals": {
    "base": 65,
    "tax": 13.65,
    "total": 78.65,
    "currency": "EUR",
    "tax_rates": [
      {
        "rate": 21,
        "base": 65,
        "quota": 13.65
      }
    ]
  },
  "lines": [
    {
      "id": 5501,
      "description": "Oil and filter change",
      "extended_detail": null,
      "reference": null,
      "line_type": "labor",
      "group_title": null,
      "quantity": 1,
      "unit_price": 65,
      "discount_pct": 0,
      "tax_rate": 21,
      "tax_exempt_code": null,
      "price_estimated": false,
      "base": 65,
      "currency": "EUR",
      "sort_order": 0
    }
  ]
}

POST /api/v1/budgets/{id}/documents budgets:write

Attach a document

multipart/form-data with the «file» field (JPEG, PNG, WebP or PDF, checked by content; 4 MB max). Staff-only by default; client_visible=true shows it on the tracking page and mechanic_visible=true in the mechanic app. The idempotency fingerprint includes the file.

Required scope: budgets:write.

Parameters

NameTypeRequiredDescription
idintegerYesBudget id.

Headers

NameTypeRequiredDescription
Idempotency-KeystringYesUnique key per operation (UUID recommended). Repeating it with the same body within 24 h returns the stored response with Idempotent-Replay: true; with a different body, 409 idempotency_conflict.

Body (multipart/form-data)

NameTypeRequiredDescription
filestring (binary)YesFichero
client_visiblestring [true, false]No
mechanic_visiblestring [true, false]No

Example

curl -X POST "https://your-shop.example/api/v1/budgets/1234/documents" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Idempotency-Key: $(uuidgen)" \
  -F "file=@fichero.pdf" \
  -F "client_visible=false" \
  -F "mechanic_visible=false"

Response

{
  "id": 991,
  "budget_id": 1234,
  "filename": "parte-de-trabajo.pdf",
  "content_type": "application/pdf",
  "size": 182044,
  "url": "https://…/leads/1234/api-parte-de-trabajo.pdf",
  "client_visible": false,
  "mechanic_visible": false,
  "created_at": "2026-10-06T09:30:00.000Z"
}

Invoices

GET /api/v1/invoices invoices:read

List invoices

Issued invoices and drafts ordered by id, with a payments summary.

Required scope: invoices:read.

Query

NameTypeRequiredDescription
fromstring (date-time)NoInvoice date from.
tostring (date-time)NoInvoice date to.
client_idintegerNoFilter by client.
statusstring [draft, issued, cancelled]NoInvoice status.
seriesstringNoExact series.
limitinteger · default 50NoResults per page (1–200).
cursorstringNoOpaque cursor returned as next_cursor by the previous page.

Example

curl "https://your-shop.example/api/v1/invoices" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

{
  "data": [
    {
      "id": 412,
      "number": 87,
      "series": "F26",
      "full_number": "F2687",
      "kind": "invoice",
      "rectifies_number": null,
      "status": "issued",
      "date": "2026-10-06",
      "issued_at": "2026-10-06T09:30:00.000Z",
      "client_id": 1204,
      "client": {
        "id": 1204,
        "name": "Laura Gómez"
      },
      "budget_id": 1234,
      "vehicle_id": 871,
      "vehicle": {
        "id": 871,
        "plate": "1234 KLM",
        "brand": "Seat",
        "model": "León"
      },
      "plate": "1234 KLM",
      "km": 84500,
      "total": 78.65,
      "currency": "EUR",
      "payments": {
        "paid": 78.65,
        "pending": 0,
        "settled": true
      },
      "payment_method": "Tarjeta",
      "rebu": false,
      "verifactu_hash": "3f9a…"
    }
  ],
  "next_cursor": "aWQ6MTIzNA",
  "has_more": true
}

GET /api/v1/invoices/{id} invoices:read

Invoice detail

Required scope: invoices:read.

Parameters

NameTypeRequiredDescription
idintegerYesInvoice id.

Example

curl "https://your-shop.example/api/v1/invoices/1234" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

{
  "id": 412,
  "number": 87,
  "series": "F26",
  "full_number": "F2687",
  "kind": "invoice",
  "rectifies_number": null,
  "status": "issued",
  "date": "2026-10-06",
  "issued_at": "2026-10-06T09:30:00.000Z",
  "client_id": 1204,
  "client": {
    "id": 1204,
    "name": "Laura Gómez"
  },
  "budget_id": 1234,
  "vehicle_id": 871,
  "vehicle": {
    "id": 871,
    "plate": "1234 KLM",
    "brand": "Seat",
    "model": "León"
  },
  "plate": "1234 KLM",
  "km": 84500,
  "total": 78.65,
  "currency": "EUR",
  "payments": {
    "paid": 78.65,
    "pending": 0,
    "settled": true
  },
  "payment_method": "Tarjeta",
  "rebu": false,
  "verifactu_hash": "3f9a…",
  "billing": {
    "name": "Laura Gómez",
    "tax_id": "12345678Z",
    "address": "C/ Mayor 12",
    "city": "Barcelona",
    "zip": "08001",
    "province": "Barcelona"
  },
  "date_in": null,
  "date_out": null,
  "public_notes": null,
  "totals": {
    "base": 65,
    "tax": 13.65,
    "total": 78.65,
    "currency": "EUR",
    "tax_rates": [
      {
        "rate": 21,
        "base": 65,
        "quota": 13.65
      }
    ]
  },
  "lines": [
    {
      "id": 9001,
      "description": "Oil and filter change",
      "reference": null,
      "group_title": null,
      "quantity": 1,
      "unit_price": 65,
      "discount_pct": 0,
      "tax_rate": 21,
      "tax_exempt_code": null,
      "base": 65,
      "currency": "EUR",
      "sort_order": 0
    }
  ],
  "payment_list": [
    {
      "id": 77,
      "date": "2026-10-06T09:30:00.000Z",
      "amount": 78.65,
      "currency": "EUR",
      "method": "Tarjeta"
    }
  ]
}

GET /api/v1/invoices/{id}/pdf invoices:read

Invoice PDF

The same PDF the program generates (with the Verifactu QR when issued). application/pdf response.

Required scope: invoices:read.

Parameters

NameTypeRequiredDescription
idintegerYesInvoice id.

Example

curl "https://your-shop.example/api/v1/invoices/1234/pdf" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

(application/pdf)

Appointments

GET /api/v1/appointments appointments:read

Confirmed and proposed appointments

Defaults to the next 30 days. status=confirmed are booked appointments; status=proposed are client proposals awaiting workshop confirmation (they block the slot).

Required scope: appointments:read.

Query

NameTypeRequiredDescription
fromstring (date-time)NoRange start (defaults to now).
tostring (date-time)NoRange end (defaults to +30 days, 1 year max).
statusstring [confirmed, proposed]NoOnly one kind.
box_idintegerNoFilter by bay/box.

Example

curl "https://your-shop.example/api/v1/appointments" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

{
  "data": [
    {
      "id": "b1234",
      "budget_id": 1234,
      "status": "confirmed",
      "start": "2026-10-06T09:30:00.000Z",
      "end": "2026-10-06T10:30:00.000Z",
      "estimated_duration_minutes": 60,
      "client_confirmed_at": null,
      "budget_status": "Aprobado",
      "checked_in_at": null,
      "client": {
        "id": 1204,
        "name": "Laura Gómez"
      },
      "vehicle": {
        "id": 871,
        "plate": "1234 KLM",
        "brand": "Seat",
        "model": "León"
      },
      "mechanic": {
        "id": 3,
        "name": "Marta"
      },
      "box": {
        "id": 2,
        "name": "Lift 2"
      },
      "category": {
        "id": 5,
        "name": "Mantenimiento"
      },
      "channel": null,
      "proposed_at": null
    }
  ],
  "from": "2026-10-06T00:00:00.000Z",
  "to": "2026-11-05T00:00:00.000Z"
}

POST /api/v1/appointments appointments:write

Propose or book an appointment

Follows the workshop booking mode (booking_mode in GET /workshop). In «propose» a proposal is created (status=proposed) that blocks the slot until the workshop confirms it; asking mode=book answers 403 booking_mode_propose_only. In «book» the appointment is confirmed (status=confirmed) unless you ask mode=propose. The slot is checked with the same logic as /appointments/availability; if not free, 409 slot_unavailable with up to 3 alternatives in error.alternatives. The client is not notified unless notify_client=true with communications:send.

Required scope: appointments:write.

Headers

NameTypeRequiredDescription
Idempotency-KeystringYesUnique key per operation (UUID recommended). Repeating it with the same body within 24 h returns the stored response with Idempotent-Replay: true; with a different body, 409 idempotency_conflict.

Body

{
  "budget_id": 1234,
  "start": "2026-10-14T09:00:00+02:00",
  "box_id": 2,
  "duration_minutes": 60,
  "mode": "propose",
  "notify_client": false
}

Example

curl -X POST "https://your-shop.example/api/v1/appointments" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"budget_id":1234,"start":"2026-10-14T09:00:00+02:00","box_id":2,"duration_minutes":60,"mode":"propose","notify_client":false}'

Response

{
  "id": "b1234",
  "budget_id": 1234,
  "status": "confirmed",
  "start": "2026-10-06T09:30:00.000Z",
  "end": "2026-10-06T10:30:00.000Z",
  "estimated_duration_minutes": 60,
  "client_confirmed_at": null,
  "budget_status": "Aprobado",
  "checked_in_at": null,
  "client": {
    "id": 1204,
    "name": "Laura Gómez"
  },
  "vehicle": {
    "id": 871,
    "plate": "1234 KLM",
    "brand": "Seat",
    "model": "León"
  },
  "mechanic": {
    "id": 3,
    "name": "Marta"
  },
  "box": {
    "id": 2,
    "name": "Lift 2"
  },
  "category": {
    "id": 5,
    "name": "Mantenimiento"
  },
  "channel": null,
  "proposed_at": null
}

GET /api/v1/appointments/availability appointments:read

Free slots

Same logic as the agenda and the tracking page: workshop and bay hours, lunch break, national/regional/local holidays, open appointments and pending proposals (which block their slot). Starts every 30 min, at least 1 h from now. Defaults to the next 7 days (31 max). Includes the workshop booking_mode.

Required scope: appointments:read.

Query

NameTypeRequiredDescription
fromstring (date-time)NoFrom (defaults to now).
tostring (date-time)NoTo (defaults to +7 days; 31 days max).
duration_minutesintegerNoAppointment length (15–720). Defaults to the agenda minimum.
box_idintegerNoOnly that bay.
limitinteger · default 100NoMaximum slots (1–500).

Example

curl "https://your-shop.example/api/v1/appointments/availability" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

{
  "data": [
    {
      "start": "2026-10-14T07:00:00.000Z",
      "end": "2026-10-14T08:00:00.000Z",
      "box": {
        "id": 2,
        "name": "Lift 2"
      }
    }
  ],
  "duration_minutes": 60,
  "from": "2026-10-14T00:00:00.000Z",
  "to": "2026-10-21T00:00:00.000Z",
  "booking_mode": "propose"
}

DELETE /api/v1/appointments/{id} appointments:write

Cancel an appointment or withdraw a proposal

b<budget>: cancels the confirmed appointment (same as «Cancel appointment» in the app). p<proposal>: withdraws the pending proposal; with notify_client=true and communications:send the client is invited to pick another time. Emits appointment.cancelled.

Required scope: appointments:write.

Parameters

NameTypeRequiredDescription
idstringYesAppointment id (b1234 or p88).

Query

NameTypeRequiredDescription
notify_clientboolean · default falseNoProposals only: notify the client.

Example

curl -X DELETE "https://your-shop.example/api/v1/appointments/b1234" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

{
  "ok": true,
  "id": "b1234",
  "status": "cancelled"
}

Catalog

GET /api/v1/catalog/services catalog:read

Service categories and subcategories

Required scope: catalog:read.

Example

curl "https://your-shop.example/api/v1/catalog/services" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

{
  "data": [
    {
      "id": 5,
      "name": "Mantenimiento",
      "reference_price": 120,
      "currency": "EUR",
      "subcategories": [
        {
          "id": 51,
          "name": "Oil change",
          "reference_price": 65,
          "currency": "EUR"
        }
      ]
    }
  ]
}

GET /api/v1/catalog/rates catalog:read

Labor rates

Sale price per hour only; cost never leaves through the API.

Required scope: catalog:read.

Example

curl "https://your-shop.example/api/v1/catalog/rates" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

{
  "data": [
    {
      "id": 1,
      "name": "General labour",
      "price_per_hour": 48,
      "currency": "EUR",
      "is_default": true
    }
  ]
}

Communications

GET /api/v1/communications communications:read

Communications log

Latest communications (email, SMS, WhatsApp, calls, push) with full content, newest first.

Required scope: communications:read.

Query

NameTypeRequiredDescription
channelstring [all, email, sms, whatsapp, call, push] · default allNoChannel.
limitinteger · default 50NoMaximum results (1–200).

Example

curl "https://your-shop.example/api/v1/communications" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

{
  "data": [
    {
      "id": "c_8812",
      "channel": "email",
      "direction": "out",
      "recipient": "laura@ejemplo.com",
      "subject": "Your budget",
      "body": "Hi Laura, …",
      "idlead": 1234,
      "idclient": 1204,
      "clientName": "Laura Gómez",
      "status": "sent",
      "created_at": "2026-10-06T09:30:00.000Z",
      "duration": null,
      "recordingUrl": null,
      "agent": null,
      "fromNumber": null,
      "toNumber": null
    }
  ],
  "next_cursor": null,
  "has_more": false
}

GET /api/v1/logs communications:read

Communications log (legacy alias)

Same query as /communications but returns { items }. Kept for compatibility; prefer /communications.

Required scope: communications:read.

Query

NameTypeRequiredDescription
channelstring [all, email, sms, whatsapp, call, push] · default allNoChannel.
limitinteger · default 50NoMaximum results (1–200).

Example

curl "https://your-shop.example/api/v1/logs" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

{
  "items": [
    {
      "id": "c_8812",
      "channel": "email",
      "direction": "out",
      "recipient": "laura@ejemplo.com",
      "subject": "Your budget",
      "body": "Hi Laura, …",
      "idlead": 1234,
      "idclient": 1204,
      "clientName": "Laura Gómez",
      "status": "sent",
      "created_at": "2026-10-06T09:30:00.000Z",
      "duration": null,
      "recordingUrl": null,
      "agent": null,
      "fromNumber": null,
      "toNumber": null
    }
  ]
}

POST /api/v1/email communications:send

Send a transactional email

Sent with the email account configured by the workshop and logged in Communications. If it bounces, the client's email is flagged as invalid.

Required scope: communications:send.

Body

{
  "to": "cliente@ejemplo.com",
  "subject": "Your vehicle is ready",
  "text": "You can come and collect it.",
  "html": "<p>You can come and collect it.</p>",
  "fromName": "Taller",
  "replyTo": "taller@ejemplo.com"
}

Example

curl -X POST "https://your-shop.example/api/v1/email" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"to":"cliente@ejemplo.com","subject":"Your vehicle is ready","text":"You can come and collect it.","html":"<p>You can come and collect it.</p>","fromName":"Taller","replyTo":"taller@ejemplo.com"}'

Response

{
  "ok": true
}

POST /api/v1/sms communications:send

Send a transactional SMS

Sent with the SMS service configured by the workshop and logged in Communications, where its delivery status is updated.

Required scope: communications:send.

Body

{
  "to": "+34600111222",
  "body": "Your vehicle is ready for collection."
}

Example

curl -X POST "https://your-shop.example/api/v1/sms" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"to":"+34600111222","body":"Your vehicle is ready for collection."}'

Response

{
  "ok": true
}

Webhooks

GET /api/v1/webhooks webhooks:manage

List this key's webhooks

Includes the catalog of available events. Never returns secrets.

Required scope: webhooks:manage.

Example

curl "https://your-shop.example/api/v1/webhooks" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

{
  "items": [
    {
      "id": 3,
      "url": "https://tu-sistema.com/webhooks/taller",
      "events": [
        "lead.accepted"
      ],
      "description": "CRM",
      "active": true,
      "created_at": "2026-10-06T09:30:00.000Z"
    }
  ],
  "events": [
    {
      "event": "lead.accepted",
      "label": "Budget accepted",
      "description": "texto"
    }
  ]
}

POST /api/v1/webhooks webhooks:manage

Create a webhook

The signing secret (whsec_…) is only returned in this response.

Required scope: webhooks:manage.

Body

{
  "url": "https://tu-sistema.com/webhooks/taller",
  "events": [
    "lead.accepted"
  ],
  "description": "CRM"
}

Example

curl -X POST "https://your-shop.example/api/v1/webhooks" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://tu-sistema.com/webhooks/taller","events":["lead.accepted"],"description":"CRM"}'

Response

{
  "item": {
    "id": 3,
    "url": "https://tu-sistema.com/webhooks/taller",
    "events": [
      "lead.accepted"
    ],
    "description": "CRM",
    "active": true,
    "created_at": "2026-10-06T09:30:00.000Z"
  },
  "secret": "whsec_…"
}

GET /api/v1/webhooks/{id} webhooks:manage

Webhook detail and recent deliveries

Required scope: webhooks:manage.

Parameters

NameTypeRequiredDescription
idintegerYesWebhook id.

Example

curl "https://your-shop.example/api/v1/webhooks/1234" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

{
  "item": {
    "id": 3,
    "url": "https://tu-sistema.com/webhooks/taller",
    "events": [
      "lead.accepted"
    ],
    "description": "CRM",
    "active": true,
    "created_at": "2026-10-06T09:30:00.000Z"
  },
  "deliveries": [
    {}
  ]
}

PATCH /api/v1/webhooks/{id} webhooks:manage

Update a webhook

Required scope: webhooks:manage.

Parameters

NameTypeRequiredDescription
idintegerYesWebhook id.

Body

{
  "url": "texto",
  "events": [
    "texto"
  ],
  "description": "texto",
  "active": false
}

Example

curl -X PATCH "https://your-shop.example/api/v1/webhooks/1234" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"url":"texto","events":["texto"],"description":"texto","active":false}'

Response

{
  "item": {
    "id": 3,
    "url": "https://tu-sistema.com/webhooks/taller",
    "events": [
      "lead.accepted"
    ],
    "description": "CRM",
    "active": true,
    "created_at": "2026-10-06T09:30:00.000Z"
  }
}

DELETE /api/v1/webhooks/{id} webhooks:manage

Delete a webhook

Required scope: webhooks:manage.

Parameters

NameTypeRequiredDescription
idintegerYesWebhook id.

Example

curl -X DELETE "https://your-shop.example/api/v1/webhooks/1234" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

{
  "ok": true
}

POST /api/v1/webhooks/{id}/test webhooks:manage

Send a test delivery (test.ping) to the webhook

Required scope: webhooks:manage.

Parameters

NameTypeRequiredDescription
idintegerYesWebhook id.

Example

curl -X POST "https://your-shop.example/api/v1/webhooks/1234/test" \
  -H "Authorization: Bearer pt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

{
  "ok": true,
  "status": 200
}

Webhooks

Payload

{
  "event": "lead.accepted",
  "timestamp": "2026-10-06T10:15:00.000Z",
  "data": {
    "leadId": 1234,
    "from": "Enviado",
    "to": "Aprobado"
  }
}
EventDescription
lead.createdBudget created
A new budget was created (from the app, the website, email or the assistant).
lead.sentBudget sent
The budget was sent to the client.
lead.acceptedBudget accepted
The client or the workshop approved the budget.
lead.rejectedBudget rejected
The budget was rejected, with the reason if there is one.
lead.status_changedStatus change
Any budget status change (includes the ones above).
appointment.proposedAppointment proposed
A client proposes an appointment the workshop still has to confirm.
appointment.confirmedAppointment confirmed
An appointment is confirmed in the diary.
appointment.cancelledAppointment cancelled
A budget's appointment was cancelled.
vehicle.checked_inVehicle checked in
The vehicle entered the workshop (check-in or walk-in).
vehicle.readyVehicle ready
The repair is finished and the vehicle is ready for collection.
vehicle.deliveredVehicle delivered
The client collected the vehicle.
invoice.issuedInvoice issued
An invoice was issued with its final number.
payment.receivedPayment recorded
A payment was recorded against an invoice.
client.createdClient created
A client was created.
client.updatedClient updated
A client's details were changed.
communication.inboundInbound message
An email, SMS, WhatsApp or call arrived from a client.
email.sentEmail sent
An email was sent (campaigns, notices or API).
email.failedEmail failed
An email could not be sent.
email.openedEmail opened
The recipient opened the email.
email.clickedEmail click
The recipient clicked a link in the email.
email.unsubscribedEmail unsubscribe
The recipient unsubscribed from emails.
email.bouncedEmail bounced
The email bounced.
sms.sentSMS sent
An SMS was sent.
sms.failedSMS failed
An SMS could not be sent.
sms.unsubscribedSMS opt-out
The recipient asked not to receive SMS.
whatsapp.unsubscribedWhatsApp opt-out
The recipient asked not to receive WhatsApp messages.
campaign.finishedCampaign finished
A campaign finished sending.
3 months for €1 →