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.

  1. 1Bed restauranten om en API-nøgle. Den oprettes af dem selv inde i Tabletimer under Indstillinger → Integration og vises kun én gang.
  2. 2Gem den som en hemmelighed i jeres PMS. Send den med som Authorization: Bearer … på hvert kald.
  3. 3Prøv det af — hvis dette svarer, virker forbindelsen:
Første kald
curl "https://app.tabletimer.com/api/v1/metrics/catalog" \
  -H "Authorization: Bearer DIN_API_NØGLE"

Basis-URL

https://app.tabletimer.com

Konventioner

  • Beløb er altid i øre inkl. moms — divider med 100.
  • Datoer er YYYY-MM-DD og betyder serviceaftenen, ikke et klokkeslæt.
  • Tidsstempler er ISO 8601 i UTC. Klokkeslæt i salen er HH:MM i 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.

Fra katalog til widget
// 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 })
  }
}
Hent udvalgte nøgletal
curl "https://app.tabletimer.com/api/v1/metrics?period=30d&metrics=covers,noShowPct" \
  -H "Authorization: Bearer DIN_API_NØGLE"
Svaret
{
  "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.

Dag for dag som flad tabel
curl "https://app.tabletimer.com/api/v1/metrics/daily?period=30d&amounts=dkk" \
  -H "Authorization: Bearer DIN_API_NØGLE"
Svaret
{
  "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øgleNavnTypeEnhed
coversKuverterAntal gæster i perioden. Aflyste bookinger tæller ikke med.Talkuverter
reservationsBookingerAntal bookinger der skulle møde op. Aflyste og uafsluttede hold tæller ikke med.Talantal
avgBookingsPerDayBookinger pr. dagPeriodens bookinger delt med antal dage, lukkedage inklusive. Bookingen tæller på den dag gæsterne kommer.Talantal
noShowPctNo-showAndel af de fremmødeegnede bookinger, hvor gæsten udeblev.Talprocent
guestsUnikke gæsterAntal forskellige gæster bag periodens bookinger.Talantal
newGuestsNye gæsterGæster oprettet i kartoteket i perioden.Talantal
coversSeriesKuverter over tidKuverter pr. dag — eller pr. time når perioden er én enkelt dag.Seriekuverter
bookingTypesBookingtyperKuverter fordelt på à la carte, ophold, walk-in og events.Fordelingkuverter
bookingSourcesBookingkilderKuverter fordelt på online booking, personalet og PMS/API.Fordelingkuverter
weekdaysUgedageKuverter fordelt på ugedag, mandag først.Fordelingkuverter
bookedByHvem tog bookingenPersonalebookinger fordelt på kollega. Kun bookinger med spor i auditsporet tæller med.Fordelingantal
revenue OmsætningKræver kassemoduletTaløre
vat MomsKræver kassemoduletTaløre
bills RegningerKræver kassemoduletTalantal
avgSpendPerCover Forbrug pr. kuvertKræver kassemoduletTaløre
revenueSeries Omsætning over tidKræver kassemoduletSerieøre
topProducts Mest solgte varerKræver kassemoduletFordelingøre
categories VarekategorierKræver kassemoduletFordelingøre
foodVsDrink Mad og drikkeKræver kassemoduletFordelingøre
payments BetalingsmetoderKræver kassemoduletFordelingø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 tider

Uden `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 rum

Bruges 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 periode

Beregnet 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 levere

Byg 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 tabel

Samme 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 bookinger

På 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 booking

Oprettes 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 booking
PATCH/api/v1/reservations/{id}— Ret en booking

Kun 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 booking

Frigiver 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 dem

Til 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ærelseslisten

Hele 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 ophold

Hver 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 ophold
DELETE/api/v1/stays/{ref}— Aflys et ophold

Aflyser 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.

TypeScript
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

StatuscodeBetyder
400invalid_requestFeltet holdt ikke. details[] siger hvilket.
401unauthorizedNøglen mangler, er ukendt, udløbet eller spærret.
403forbiddenNøglen mangler det nødvendige scope (read/write).
404not_foundFindes ikke — eller hører til en anden restaurant.
409conflictDer er ikke plads. Se details.alternativeSlots.
429rate_limitedFor mange kald. Retry-After siger hvor længe.
500internal_errorVores 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ændelseUdløses når
reservation.createdEn booking blev oprettet
reservation.updatedEn booking blev ændret (tid, antal, bord, noter)
reservation.cancelledEn booking blev aflyst
reservation.seatedGæsten satte sig ved bordet
reservation.completedBesøget blev afsluttet
reservation.no_showGæsten udeblev
stay.createdEt ophold blev oprettet
stay.updatedEt ophold blev synkroniseret igen
stay.cancelledEt 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.

Kontrollér underskriften
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.

Hent OpenAPI-beskrivelsen (JSON)
3.1