devel

OpenAPI. The description of every resource, to import into Postman or to generate a client: openapi-integracion.json.

Test environment. There is a sandbox with made-up data: see the section on the test environment. To ask for access or with any question, write to us through the contact form.

The Turnera integration API — v1

Turnera publishes an API so that an external program —the centre's ERP, or its accountancy firm's— can read its catalogue, its customers and its sales. It is read-only except for one thing: booking a slot in the diary, which exists for a centre's campaign platform, which brings in the customer and needs the appointment right away (section 5.1). Nothing else is modified from outside. And there is a way back: webhooks (section 5.2), with which Turnera notifies your system when an appointment changes, so it doesn't have to poll every few minutes.


1. In two minutes

# 1. Exchange the credentials for a token (half an hour of life)
curl -s -X POST https://sucentro.turnera.devel.es/api/v1/integration/token \
     -H 'Content-Type: application/json' \
     -d '{"grant_type":"client_credentials","client_id":"...","client_secret":"..."}'

# {"access_token":"eyJ...","token_type":"Bearer","expires_in":1800,"scope":"catalog:read sales:read"}

# 2. Read, with the token in the header
curl -s https://sucentro.turnera.devel.es/api/v1/integration/catalog/services \
     -H 'Authorization: Bearer eyJ...'

The base address is the centre's own: https://<centre>.turnera.devel.es/api/v1. For testing, there is a sandbox with made-up data (section 11). Each centre has its own domain and its own database, so there is no parameter saying which centre is being called — the domain says it.


2. Registering as a client

This is not done through the API. Whoever programs the ERP receives a set of credentials; not the ability to create more.

It is done by someone with the centre's administration role, in Administration → Integrations → New integration. There you decide:

Field What it is
Name How the centre will recognise it in the list. "The accountancy firm's ERP".
Contact email That of whoever maintains the integration. It is used to give notice of maintenance, version changes or API incidents. It is mandatory.
Delegation The only one the integration will see. It is fixed here because a machine cannot choose it on a screen.
Permissions What it can read. See the scopes table below.
Authorised addresses Where it may connect from. One per line: an IPv4 or a CIDR range, or any to not restrict (see "The IP").

On saving, the centre owner receives an email with the registration and its details (never the secret), so that there are no integrations that nobody knows exist. The screen shows the client_id and the client_secret only once. The secret is not stored in plain text, so if it is lost it cannot be recovered: it is rotated (§6).

What to ask the centre for

  1. The domain of their Turnera.
  2. The credentials, through a channel other than everyday email.
  3. That they authorise the fixed IP of the server where the ERP will run. During development, if you work from a laptop that changes network, the centre can set any; when moving to production it should replace it with the server's IP.

3. Authentication

OAuth 2.0 client credentials. Neither a person's user account, nor a static key in a header.

POST /api/v1/integration/token
Content-Type: application/json

{ "grant_type": "client_credentials", "client_id": "...", "client_secret": "..." }
{
  "access_token": "eyJhbGciOiJIUzI1NiJ9...",
  "token_type": "Bearer",
  "expires_in": 1800,
  "scope": "catalog:read sales:read"
}

The names in this endpoint are in snake_case because they are those of OAuth 2.0; the rest of the API is in camelCase.

401 Unauthorized
{ "title": "Unauthorized", "status": 401, "error": "invalid_client", "detail": "Las credenciales no son válidas" }

It does not distinguish which of the four failed, on purpose. If the credentials are good, the detail isn't needed; if they are being probed, it isn't wanted either.

The IP

The address is checked when requesting the token and again on every call: a stolen token lasts half an hour, and half an hour from another machine is half an hour too many. A call from an address that is not on the list gets a 403.

A change to the address list takes up to one minute to take effect.

No IP restriction. If the list contains any, access is possible from any address. It is intended for the development period, when whoever is integrating moves between networks. In that case the only safeguard is the client_secret: whoever has it can read whatever the integration's permissions open up, from anywhere. It is advisable to give it only the permissions it needs and to replace any with the server's IP when moving to production.


4. Permissions (scopes)

A short vocabulary of our own. Turnera's roles are about screens and mean nothing to an ERP.

Scope What it opens
catalog:read /catalog/services, /catalog/products
customers:read /customers
sales:read /sales, /accounting-draft
availability:read /availability
bookings:write /bookings, and being able to register appointment webhooks
customers:exists /customers/exists

Calling a resource without its scope gets a 403. There is no clinical-record scope, and it is not that it isn't granted by default: it does not exist. customers:read returns the administrative record and nothing about history, cases or progress notes.


5. The resources

All listings return the same envelope:

{
  "items": [ ... ],
  "nextCursor": "412"
}

Cursor pagination

There are no page numbers. If someone pays while the ERP is paging, a numbered page shifts out of place and a sale is lost without anyone noticing; the cursor is the last identifier read, so what has already happened doesn't move.

GET /integration/sales?limit=200
GET /integration/sales?limit=200&cursor=412
GET /integration/sales?limit=200&cursor=980   →  "nextCursor": null   ← that's all

Incremental synchronisation

All listings support updatedSince, in ISO-8601 with time zone offset:

GET /integration/catalog/services?updatedSince=2026-09-01T00:00:00Z

A date without a time zone offset forces us to guess which zone to interpret it in, and that is where the eleven o'clock sale at night gets lost. That is why it is rejected with a 400.

The comparison is greater than or equal, so you should store the highest updatedOn you have seen and use it as a high-water mark on the next pass. It may repeat a row; it never skips one.

On the first pass, without updatedSince, you get everything. That is what should happen.

GET /integration/catalog/services

{
  "id": 41,
  "name": "Sesión de fisioterapia",
  "category": "Fisioterapia",
  "description": null,
  "price": "35.00",
  "taxRate": null,
  "taxExemption": "E1",
  "durationInMinutes": 45,
  "enabled": true,
  "updatedOn": "2026-09-18T11:04:22+02:00"
}

GET /integration/catalog/products

{
  "id": 7,
  "name": "Crema hidratante",
  "category": "Cosmética",
  "brand": "…",
  "reference": "…",
  "price": "18.50",
  "taxRate": 21,
  "taxExemption": null,
  "enabled": true,
  "updatedOn": "2026-09-18T11:04:22+02:00"
}

About VAT in both: taxRate is the rate as a percentage and taxExemption is the AEAT exemption code (E1 for article 20, and the others on its list). They are mutually exclusive: an exempt operation does not carry a zero percent rate, it carries the reason why it is exempt. Both being null means that service or product does not yet have its VAT decided in the centre's catalogue. That is not an API error: it is the centre's to-do list, and the ERP should raise a warning instead of assuming 21.

GET /integration/customers

The administrative record. Never anything clinical.

{
  "id": 1204,
  "taxId": "12345678Z",
  "name": "…", "surname": "…",
  "email": "…", "phone": null, "mobile": "…",
  "address": "…", "postalCode": "…", "city": "…", "province": "…",
  "updatedOn": "2026-09-19T09:12:00+02:00"
}

GET /integration/customers/exists?email=…&mobile=…

Scope customers:exists. It tells you only whether an email or a phone number belongs to a customer of the centre, and nothing else: neither the name nor the identifier. It lets a campaign platform avoid duplicating records.

{ "exists": true }

You must send email, mobile or both; if you send both, one matching is enough. The phone number is compared by its last nine digits, ignoring spaces, hyphens and prefix, and it must have at least seven.

This query has its own limit, much stricter than the general one: 20 per minute and 2,000 per day per integration. Past the limit, 429 with Retry-After. Turnera records how much it is used (never what is asked) and emails the centre owner and the integration's contact if the usage looks unusual: the daily quota nearly exhausted, many queries and almost none matching, or repeated attempts with credentials or addresses that are not valid. It is a question designed for checking records one at a time, not for running through a list of people.

GET /integration/sales

Paid visits, with their lines.

{
  "id": 9812,
  "date": "2026-09-19T17:30:00+02:00",
  "customerId": 1204,
  "delegationId": 1,
  "total": "53.50",
  "updatedOn": "2026-09-19T17:31:02+02:00",
  "refundOf": null,
  "lines": [
    {
      "id": 44120,
      "description": "Sesión de fisioterapia",
      "amount": "35.00",
      "discountPercentage": 0,
      "serviceId": 41,
      "productId": null,
      "taxRate": null,
      "taxExemption": "E1"
    }
  ]
}

GET /integration/accounting-draft

The summary entry per day and VAT rate: one posting per day and per rate, not four hundred ticket lines. It does not paginate and requires both dates.

GET /integration/accounting-draft?from=2026-09-01&to=2026-09-30
{
  "from": "2026-09-01",
  "to": "2026-09-30",
  "disclaimer": "Borrador para revisar con su asesoría. No sustituye a sus libros.",
  "entries": [
    { "day": "2026-09-01", "taxRate": 21, "taxExemption": null,
      "total": "184.20", "tickets": 6, "firstSaleId": 9780, "lastSaleId": 9791 },
    { "day": "2026-09-01", "taxRate": null, "taxExemption": null,
      "total": "70.00", "tickets": 2, "firstSaleId": 9782, "lastSaleId": 9788 }
  ]
}

The second line of the example is what was paid with no VAT decided. It comes out separately on purpose, because it is exactly what needs reviewing. The disclaimer travels in the response and should be shown wherever the numbers are shown.

5.1 Booking in the diary

Designed for a campaign platform: it asks which times are free, its customer picks one and it books it. If in the meantime someone else has taken it —reception, the portal or another booking—, Turnera replies that it is taken.

The slots are the same ones the centre's booking portal offers: only services and rooms marked as bookable online, with the minimum notice and the days ahead that the centre has configured. The platform chooses the time; Turnera assigns the first free room and the first free professional.

GET /integration/availability?serviceId=12&day=2026-10-02

Scope availability:read. The free start times on that day for that service:

{
  "day": "2026-10-02",
  "serviceId": 12,
  "bookableUntil": "2026-11-21",
  "slots": [ { "start": "10:00:00", "end": "11:00:00" }, { "start": "11:00:00", "end": "12:00:00" } ]
}

bookableUntil is the last day that can be booked today: the diary is open as many days ahead as the centre has configured, and the window advances by itself every day. An empty list for a later day means the diary is not open yet, not that it is full. For a day within the window, it means nothing is left or that that service is not bookable online.

POST /integration/bookings

Scope bookings:write.

{
  "serviceId": 12,
  "day": "2026-10-02",
  "start": "10:00",
  "reference": "VIR-000123",
  "customer": { "name": "Ana", "surname": "García", "phone": "600123456", "email": "ana@example.org" }
}
Response What it means
201 Booked. Returns the appointment (appointmentId, reference, day, start, end, customerId).
200 That reference was already booked: returns the same appointment, without taking another slot.
409 BOOKING_SLOT_TAKEN The slot is no longer free: someone else has taken it. Request the availability again.
409 BOOKING_TOO_FAR_AHEAD The diary is not open yet for that day (later than bookableUntil).
409 BOOKING_EQUIPMENT_BUSY The service needs a piece of equipment and it is busy at that time. It is not the same as BOOKING_SLOT_TAKEN: the slot exists, but the equipment is missing. The response carries equipmentName. Try another slot.
409 BOOKING_MINIMUM_NOTICE The time has already passed or does not meet the centre's minimum notice. The response carries minimumNoticeHours.
400 A field is missing, or the customer has neither a phone nor an email.

5.2 Webhooks: having Turnera notify you

Instead of asking every few minutes whether there are new appointments, the centre can register an address on its system (Administration → Integrations → Webhooks) and Turnera calls it when an appointment changes. The bookings:write scope is required on the integration, and each integration allows up to five webhooks.

The address must be https://, public and without a username or password. Addresses on internal networks and redirects are not accepted. Once created, the address cannot be changed: to point to another one, a different webhook is registered. When it is registered, the centre owner receives an email saying where the data will be sent.

The events you can subscribe to:

Event When
booking.created An appointment is created.
booking.updated An appointment that is still active changes (time, service, customer…).
booking.cancelled An appointment is cancelled or deleted.

There is also webhook.ping, which is not subscribed to: it is the test that the centre can trigger by hand from the screen to check that its address responds.

Which appointments. By default the webhook notifies only of appointments created by that same integration. The centre can untick this to receive those from reception and the portal as well.

The notification is a POST with Content-Type: application/json:

{
  "event": "booking.created",
  "deliveryId": "5b0c2c1e-6c1e-4d6e-9f43-0b1c0d2e3f4a",
  "occurredAt": "2026-10-02T08:15:03Z",
  "data": {
    "appointmentId": 48213,
    "reference": "VIR-000123",
    "state": "booked",
    "day": "2026-10-05",
    "start": "10:00",
    "end": "10:45",
    "service": "Limpieza facial",
    "customerId": 1204,
    "bookedByThisIntegration": true
  }
}

state is booked or cancelled. If the integration also has the customers:read scope, data carries a customer block with name, surname, email and mobile; without that scope, no personal data is sent. deliveryId is unique for each notification and is what you should use to avoid processing the same one twice.

The signature. Each notification carries these headers:

X-Turnera-Signature: sha256=<hex>
X-Turnera-Timestamp: 1790928903
X-Turnera-Event: booking.created
X-Turnera-Delivery: 5b0c2c1e-6c1e-4d6e-9f43-0b1c0d2e3f4a

The signature is the HMAC-SHA256, in hexadecimal, of timestamp + "." + body with the webhook secret (the one that starts with whsec_, which is shown only once when it is created or renewed). Verify it before trusting the content, using a constant-time comparison, and reject notifications whose X-Turnera-Timestamp is more than five minutes old: that way nobody can replay an old notification they have copied.

What to reply and retries. Reply with any 2xx in under 10 seconds; the content of the response doesn't matter. Anything else is retried with increasing waits: 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours; if after the last retry there is still no reply, the notification is considered lost (the centre sees it in the log on the screen). Redirects are not followed. Notifications are kept for fourteen days and then deleted. If the appointment is saved several times without any change to what is reported, only one notification is sent.

Order and duplicates. Notifications for the same appointment go out in order, but they are not guaranteed to arrive in order if one is retried, and a notification may arrive more than once. Use deliveryId and the state in data (not the order of arrival) to decide.


6. Rotating the secret

It is rotated from Administration → Integrations → Rotate secret. Both secrets are valid at the same time until the old one is retired: the new one is generated, the ERP is configured at leisure, and once it responds you press Retire the previous one. That way the secret is changed without cutting the integration for even a minute.


7. Rate limit

120 requests per minute per integration. Each response carries:

X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117

Past the limit, 429 with Retry-After: 60. Apart from this general limit, /customers/exists has its own (20 per minute and 2,000 per day, see section 5). An ERP with a badly written retry loop can bring a centre down on a Saturday, so you should respect Retry-After instead of retrying straight away.


8. Errors

Code What happened
400 A bad parameter: a date without a time zone offset, a cursor we didn't issue, to earlier than from. The detail says which.
401 The token is missing, has expired or belongs to another centre. On /token, invalid_client.
403 The scope doesn't cover that resource, or the call comes from an unauthorised address.
409 When booking: the slot is no longer free (BOOKING_SLOT_TAKEN), a piece of equipment the service needs at that time is missing (BOOKING_EQUIPMENT_BUSY), the diary doesn't yet reach that day (BOOKING_TOO_FAR_AHEAD) or the time doesn't meet the minimum notice (BOOKING_MINIMUM_NOTICE).
429 Rate limit. Wait as long as Retry-After says.

All error responses are application/problem+json (RFC 7807).


9. The contract rules

Written down before having clients, because afterwards they get negotiated:

  1. A field or a resource may be added. A client that doesn't know about it ignores it, so the ERP's parsing must not break on a field it didn't expect.
  2. A field may not be removed or renamed, nor its type changed, nor the meaning of an existing one changed. That is what v2 is for.
  3. A field that stops making sense is marked as deprecated in the OpenAPI and is still sent.
  4. v1 is maintained for at least twelve months after v2 is published.
  5. Changes are notified to the centres that have an active integration, and to each integration's contact email. Once v2 exists, v1 responses will carry the Deprecation and Sunset headers (RFC 9745 and RFC 8594) with the retirement date, so the ERP can detect it without reading any email.

A null field always travels as null; it never disappears from the JSON. That is the difference from the endpoints that the web application consumes, and the reason the integration API has its own classes.

The OpenAPI description of this API is published alongside this guide, at devel.es/turnera/api/openapi-integracion.json, so you can import it into Postman or generate a client. A program test checks on every change that this file says the same as the code. Installations do not serve Swagger.


10. What you should know before starting


11. The test environment

To test an integration without touching a centre's data there is a sandbox: a fake centre at https://sandbox.turnera.devel.es/api/v1, with made-up customers, catalogue, paid sales and appointments.