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
- The domain of their Turnera.
- The credentials, through a channel other than everyday email.
- 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.
- The token lasts half an hour and carries the
client_id, the branch and the scopes inside it. - There is no refresh token: when it expires you request a new one, which is one line in the ERP. The sensible approach is to
request it at the start of each synchronisation and renew it on a
401. - Any problem —an identifier that doesn't exist, a secret that doesn't match, an unauthorised address, a switched-off integration— gets the same response:
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
limit: 100 by default, maximum 500.nextCursor: nullis the signal that nothing is left. There is no total number of elements to count.
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"
}
]
}
amountalready comes with the discount applied; that is how the payment stores it.discountPercentageis informational.totalis the sum of the lines.- A refund is just another sale, with the lines and the total negative and
refundOfpointing to theidof the sale it undoes. The original sale does not change: to know what was really sold in the end, add the two together. - The line's VAT is the one the catalogue has today, not the one it had on the day of the sale. Until accounting phase 2 is available, this is data for reconciliation and not yet an invoice ledger.
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. |
- The reference makes retrying safe. It is the booking's identifier in your system and must be unique for each booking. If the response never reaches you, repeat the same request with the same reference: you will receive the appointment that already exists, never a second one.
- The customer. If the centre already has a record with that email, the appointment goes to that record and the record is not touched. If not, a new record is created with the data you send.
- It is marked. The appointment has the integration's name and the reference written in its comments, so that reception knows where it comes from, and which integration it came from is stored so each channel can be measured.
- The notification to the customer is sent by Turnera, just as in the portal, if the customer has an email.
- Cancelling or changing an appointment from outside is not yet possible: it is done from the centre.
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:
- 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.
- A field may not be removed or renamed, nor its type changed, nor the meaning of an existing one
changed. That is what
v2is for. - A field that stops making sense is marked as deprecated in the OpenAPI and is still sent.
v1is maintained for at least twelve months afterv2is published.- Changes are notified to the centres that have an active integration, and to each integration's contact email.
Once
v2exists,v1responses will carry theDeprecationandSunsetheaders (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
- A voided sale. Today voiding undoes the sale; with accounting phase 2 it will be rectified. The
ERP finds out through
updatedSincein both cases, but what it sees is different: today the sale stops appearing, tomorrow a credit note will appear. - Amounts are decimal strings, not floating-point numbers. Read them as
decimal. If you read them asdouble, a35.00ends up as34.999999. - The accounting draft is a draft. It does not number or issue anything. Invoicing with VeriFactu is phase 2 and is not available yet.
- Data protection. Taking the centre's customer records out to its ERP makes the provider of that ERP another data processor of the centre. The centre must have it in its record of processing activities; the screen for creating an integration reminds them of this.
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.
- Credentials are requested from DEVEL, one per integrator, with the permissions they need. They are created without IP
restriction (
any), so you can work from a laptop that changes network. - It resets every night: whatever has been created (bookings, above all) is deleted and the sample data goes back to its initial state, with the appointments of the current week.
- Credentials survive the reset: the
client_idand the secret remain valid. - Everything in this guide applies equally; only the domain changes. When moving to the real centre, the centre creates new credentials and authorises the server's IP.