API-dokumentation
Alt et PMS, et bookingbureau eller et dashboard skal bruge for at læse og skrive i TableTimer — nøgletal den ene vej, bookinger, ophold og beskeder den anden. Nøglen får I af restauranten selv.
Kom i gang
Tre skridt, og jeres PMS kan læse og skrive i Tabletimer.
- 1Bed restauranten om en API-nøgle. Den oprettes af dem selv inde i Tabletimer under Indstillinger → Integration og vises kun én gang.
- 2Gem den som en hemmelighed i jeres PMS. Send den med som
Authorization: Bearer …på hvert kald. - 3Prøv det af — hvis dette svarer, virker forbindelsen:
curl "https://app.tabletimer.com/api/v1/metrics/catalog" \ -H "Authorization: Bearer DIN_API_NØGLE"
Basis-URL
https://app.tabletimer.comKonventioner
- Beløb er altid i øre inkl. moms — divider med 100.
- Datoer er
YYYY-MM-DDog betyder serviceaftenen, ikke et klokkeslæt. - Tidsstempler er ISO 8601 i UTC. Klokkeslæt i salen er
HH:MMi restaurantens egen tid. - Én nøgle hører til én restaurant og dækker alle dens rum.
Nøgletal til jeres dashboard
Opskriften på en widget-vælger, hvor brugeren selv sammensætter sit dashboard.
Byg vælgeren på /metrics/catalog frem for på en hårdkodet liste. Så slipper I for at vedligeholde de samme navne to steder, nye nøgletal dukker op af sig selv, og omsætningstal forsvinder automatisk hos restauranter uden kassemodulet — i stedet for at stå og love noget, der aldrig kan udfyldes.
// 1. Ved opsætning: hent kataloget og lad brugeren vælge sine widgets.
const { metrics } = await tabletimer('/api/v1/metrics/catalog')
// -> [{ key: 'covers', label: 'Kuverter', kind: 'scalar', unit: 'covers', ... }, ...]
// Gem brugerens valg, fx ['covers', 'revenue', 'noShowPct'].
// 2. Ved hver visning: hent kun de valgte.
const chosen = ['covers', 'revenue', 'noShowPct']
const { metrics: data } = await tabletimer(
`/api/v1/metrics?period=30d&metrics=${chosen.join(',')}`
)
// 3. Tegn hver widget ud fra 'kind' og 'unit' — uden at kende det enkelte tal.
for (const m of data) {
if (m.kind === 'scalar') {
const value = m.unit === 'currency' ? m.value / 100 : m.value // øre -> kroner
render({ title: m.label, value, unit: m.unit, change: m.changePct })
}
}curl "https://app.tabletimer.com/api/v1/metrics?period=30d&metrics=covers,noShowPct" \ -H "Authorization: Bearer DIN_API_NØGLE"
{
"period": {
"from": "2026-07-15", "to": "2026-08-13", "days": 30,
"comparedTo": { "from": "2026-06-15", "to": "2026-07-14" }
},
"metrics": [
{
"key": "covers", "label": "Kuverter", "kind": "scalar", "unit": "covers",
"value": 842, "previousValue": 751, "changePct": 12
},
{
"key": "noShowPct", "label": "No-show", "kind": "scalar", "unit": "percent",
"value": 3.44, "previousValue": 5.1, "changePct": -33
}
]
}Skal tallene ind i et BI-værktøj?
Så brug /metrics/daily i stedet. Den svarer med rækker og kolonner — én række pr. dag — hvilket er formen Databox, Looker Studio, Power BI og Grafana kan mappe uden hjælp. Tomme dage er med som nuller, så en lukkedag tegnes som et hul i grafen frem for at blive sprunget over. Sæt amounts=dkk når værktøjet ikke selv kan dividere med 100.
curl "https://app.tabletimer.com/api/v1/metrics/daily?period=30d&amounts=dkk" \ -H "Authorization: Bearer DIN_API_NØGLE"
{
"period": { "from": "2026-07-15", "to": "2026-08-13", "days": 30 },
"columns": [
{ "key": "date", "label": "Dato", "unit": "text" },
{ "key": "covers", "label": "Kuverter", "unit": "covers" },
{ "key": "revenue", "label": "Omsætning", "unit": "currency_dkk" }
],
"rows": [
{ "date": "2026-07-15", "weekday": "Onsdag", "covers": 43, "revenue": 21450.00 },
{ "date": "2026-07-16", "weekday": "Torsdag", "covers": 0, "revenue": 0 }
]
}Nøgletal I kan hente (11)
9 kræver kassemodulet| Nøgle | Navn | Type | Enhed |
|---|---|---|---|
covers | KuverterAntal gæster i perioden. Aflyste bookinger tæller ikke med. | Tal | kuverter |
reservations | BookingerAntal bookinger der skulle møde op. Aflyste og uafsluttede hold tæller ikke med. | Tal | antal |
avgBookingsPerDay | Bookinger pr. dagPeriodens bookinger delt med antal dage, lukkedage inklusive. Bookingen tæller på den dag gæsterne kommer. | Tal | antal |
noShowPct | No-showAndel af de fremmødeegnede bookinger, hvor gæsten udeblev. | Tal | procent |
guests | Unikke gæsterAntal forskellige gæster bag periodens bookinger. | Tal | antal |
newGuests | Nye gæsterGæster oprettet i kartoteket i perioden. | Tal | antal |
coversSeries | Kuverter over tidKuverter pr. dag — eller pr. time når perioden er én enkelt dag. | Serie | kuverter |
bookingTypes | BookingtyperKuverter fordelt på à la carte, ophold, walk-in og events. | Fordeling | kuverter |
bookingSources | BookingkilderKuverter fordelt på online booking, personalet og PMS/API. | Fordeling | kuverter |
weekdays | UgedageKuverter fordelt på ugedag, mandag først. | Fordeling | kuverter |
bookedBy | Hvem tog bookingenPersonalebookinger fordelt på kollega. Kun bookinger med spor i auditsporet tæller med. | Fordeling | antal |
revenue | OmsætningKræver kassemodulet | Tal | øre |
vat | MomsKræver kassemodulet | Tal | øre |
bills | RegningerKræver kassemodulet | Tal | antal |
avgSpendPerCover | Forbrug pr. kuvertKræver kassemodulet | Tal | øre |
revenueSeries | Omsætning over tidKræver kassemodulet | Serie | øre |
topProducts | Mest solgte varerKræver kassemodulet | Fordeling | øre |
categories | VarekategorierKræver kassemodulet | Fordeling | øre |
foodVsDrink | Mad og drikkeKræver kassemodulet | Fordeling | øre |
payments | BetalingsmetoderKræver kassemodulet | Fordeling | øre |
Perioder: today, 7d, 30d, month, prev_month — eller et frit spænd med from og to (højst 366 dage). Tallene er de samme som Tabletimers egen analyse-side viser.
Endpoints
Alle kald kræver en gyldig API-nøgle.
Opslag
GET/api/v1/availability— Ledige tiderUden `area` svares for hele huset — summen af de rum der er åbne den dag. Med `area` kun dét rum.
GET/api/v1/areas— Restaurantens rumBruges til at sende `area` med på en booking, og til at forstå `areaId` på det der hentes tilbage.
Nøgletal
GET/api/v1/metrics— Nøgletal for en periodeBeregnet med præcis samme kode som Tabletimers egen analyse-side, så de to skærme ikke ender med hver sit tal for samme aften. Vælg periode med `period` eller med `from`+`to`. Vælg tal med `metrics`; udelades den, svares med alt restauranten kan levere.
GET/api/v1/metrics/catalog— Hvilke nøgletal kan denne restaurant levereByg jeres widget-vælger på denne — så slipper I for at vedligeholde listen to steder, og omsætningstal falder automatisk fra hos restauranter uden kassemodulet.
GET/api/v1/metrics/daily— Nøgletal dag for dag som en flad tabelSamme tal som `/metrics`, men i rækker og kolonner — formen et BI-værktøj (Databox, Looker Studio, Power BI, Grafana) kan mappe uden hjælp. Tomme dage er med som nuller, så en lukkedag tegnes som et hul i grafen frem for at blive sprunget over. Beløb er i ØRE med mindre `amounts=dkk`.
Bookinger
GET/api/v1/reservations— Find bookingerPå tværs af alle restaurantens rum. `updatedSince` er den nyttige til at holde sig ajour: den henter kun det der er ændret siden sidste kørsel.
POST/api/v1/reservations— Opret en bookingOprettes og bekræftes i ét kald. Send `idempotencyKey`, hvis kaldet kan blive gentaget — samme nøgle giver samme booking i stedet for to.
GET/api/v1/reservations/{id}— Hent én bookingPATCH/api/v1/reservations/{id}— Ret en bookingKun de sendte felter røres; `null` rydder et felt. Kapaciteten regnes IKKE igennem — tjek `/api/v1/availability` først, hvis den skal respekteres. `status` kan kun sættes til CONFIRMED, CANCELLED eller NO_SHOW. At gæsten satte sig (SEATED) eller er færdig (COMPLETED) hører til i salen, hvor markeringen også starter bordets timer.
DELETE/api/v1/reservations/{id}— Aflys en bookingFrigiver pladsen. Rækken slettes ikke — restauranten skal kunne se, at der VAR en booking. Idempotent: en allerede aflyst booking svarer 200 med samme krop.
Ophold
GET/api/v1/rooms— Værelserne som Tabletimer kender demTil at kontrollere at listen står som forventet, og til at læse `id` tilbage. `?includeInactive=true` tager også de værelser med, der er slået fra.
PUT/api/v1/rooms— Send hele værelseslistenHele hotellets værelsesliste i ÉT kald. Værelser der ikke er med, slås fra — de slettes ikke, for gamle ophold og regninger peger på dem. Sendes et værelse igen senere, vågner den samme række. Kaldet er idempotent: send den samme liste ti gange, og der sker det samme som første gang. Værelsesnumre sammenlignes normaliseret, så "12" og "Vær. 12" er samme værelse — står de begge i listen, afvises kaldet frem for at gætte. Kaldet er valgfrit. Sender I kun ophold, opretter vi selv værelset ud fra `roomNumber` — så bliver listen bare ikke mere fuldstændig end de gæster, der har været her.
POST/api/v1/stays— Opret eller synkronisér et opholdHver dag bliver til én reservation uden fast tid — gæsten vælger selv tidspunktet via `bookingUrl` i svaret. Læg det link i jeres bekræftelsesmail. Kaldet er idempotent på `roomBookingRef`: send det samme ophold igen, og dagene opdateres i stedet for at blive oprettet forfra. Dage der ikke længere er med, aflyses. Udelades `roomNumber`, rører vi ikke ved et nummer personalet selv har skrevet.
GET/api/v1/stays/{ref}— Læs et opholdDELETE/api/v1/stays/{ref}— Aflys et opholdAflyser alle opholdets dage og frigiver den reserverede plads.
En lille klient
Kopiér den ind i jeres PMS — den håndterer nøgle, fejlformat og rate limit ét sted.
const BASE = 'https://app.tabletimer.com'
const KEY = process.env.TABLETIMER_API_KEY!
export async function tabletimer(path: string, init: RequestInit = {}) {
const res = await fetch(BASE + path, {
...init,
headers: {
Authorization: `Bearer ${KEY}`,
'Content-Type': 'application/json',
...init.headers,
},
})
if (res.status === 429) {
// Vent det svaret beder om — og prøv så igen.
const wait = Number(res.headers.get('Retry-After') ?? 5)
await new Promise((r) => setTimeout(r, wait * 1000))
return tabletimer(path, init)
}
const body = await res.json()
if (!res.ok) {
// Forgren på 'code' — 'error' er en tekst til mennesker og kan omformuleres.
throw Object.assign(new Error(body.error), { code: body.code, status: res.status })
}
return body
}Fejl og grænser
| Status | code | Betyder |
|---|---|---|
| 400 | invalid_request | Feltet holdt ikke. details[] siger hvilket. |
| 401 | unauthorized | Nøglen mangler, er ukendt, udløbet eller spærret. |
| 403 | forbidden | Nøglen mangler det nødvendige scope (read/write). |
| 404 | not_found | Findes ikke — eller hører til en anden restaurant. |
| 409 | conflict | Der er ikke plads. Se details.alternativeSlots. |
| 429 | rate_limited | For mange kald. Retry-After siger hvor længe. |
| 500 | internal_error | Vores fejl. Prøv igen, og sig til hvis den bliver ved. |
Alle fejl har formen { "error": "…", "code": "…" }. Forgren på code — teksten i error er til mennesker og kan blive omformuleret.
Rate limit: 120 kald i minuttet pr. nøgle. Hvert svar bærer X-RateLimit-Remaining.
Paginering: lister tager limit (standard 50, højst 200) og offset, og svarer med et page-objekt med total og hasMore.
Webhooks
Så I opdager det, når værten flytter et selskab i salen — uden at spørge i ring.
| Hændelse | Udløses når |
|---|---|
reservation.created | En booking blev oprettet |
reservation.updated | En booking blev ændret (tid, antal, bord, noter) |
reservation.cancelled | En booking blev aflyst |
reservation.seated | Gæsten satte sig ved bordet |
reservation.completed | Besøget blev afsluttet |
reservation.no_show | Gæsten udeblev |
stay.created | Et ophold blev oprettet |
stay.updated | Et ophold blev synkroniseret igen |
stay.cancelled | Et ophold blev aflyst |
Beskeden er en POST med { id, event, createdAt, data }. id er unikt pr. levering — brug det til at afvise dubletter. data er den samme reservation, som endpointsne svarer med.
Svar med 2xx hurtigt. Et 5xx eller en timeout giver ét gentagelsesforsøg; et 4xx gør ikke, for beskeden bliver ikke bedre af at blive sendt igen.
Der er ingen leveringsgaranti.
Beskeder sendes med kort timeout og ét gentagelsesforsøg — der er ingen kø bag. Det er nok til at holde de to systemer i takt til daglig, men et PMS der SKAL være korrekt, bør stadig køre en GET /api/v1/reservations?updatedSince=… med jævne mellemrum og rette op. Byg ikke et regnskab på, at hver eneste besked kom frem.
import { createHmac, timingSafeEqual } from 'crypto'
/**
* Kald denne med den RÅ body — ikke det JSON.parse har lavet om på.
* Ét mellemrum til forskel, og underskriften passer ikke længere.
*/
export function verifyTabletimer(rawBody: string, header: string, secret: string): boolean {
const parts = new Map(header.split(',').map((p) => p.split('=') as [string, string]))
const t = Number(parts.get('t'))
const received = parts.get('v1')
if (!Number.isFinite(t) || !received) return false
// Afvis gamle beskeder, så en opsnappet besked ikke kan gensendes i morgen.
if (Math.abs(Date.now() / 1000 - t) > 300) return false
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(received, 'hex')
return a.length === b.length && timingSafeEqual(a, b)
}Underskriften står i headeren Tabletimer-Signature på formen t=<unix>,v1=<hex>. Hændelsens navn står desuden i Tabletimer-Event. Webhooks opsættes under Indstillinger → Integration.
Maskinlæsbar beskrivelse
Importér i Postman eller Insomnia, eller generér en klient ud fra den.