Astro
Two ways in, both fine: the widget (fastest, no booking code of your own) or your own UI against the public API. This page is the Astro-specific part — the widget's behaviour is the widget reference, the API is the public API.
Nothing about nisebookings is Astro-specific, and that is the point: the booking form is a web component that talks to the API directly, so a fully static Astro site is enough. Server routes and islands are optional, for when you want services rendered server-side or a webhook in your own project.
Everything below is one runnable app in examples/astro/, verified with
Astro 7 and @astrojs/node 11 against a sandbox. All three pages point at
https://app.nisebookings.com.
1. A key
The public key goes in the browser — it is public by design (it reads the catalogue and creates bookings, nothing else). Copy it from Instellingen → Insluiten, or get a 24-hour test account:
curl -s -X POST https://app.nisebookings.com/api/sandbox # → { publicKey, apiKey, … }
# .env
NISE_KEY=<the public key>
NISE_API=https://app.nisebookings.com
NISE_WEBHOOK_SECRET=whsec_… # only for the webhook route
Never put a management key (nk_…) in the browser or in a PUBLIC_* variable: that key can change
your whole account.
2. The widget on a static page
Two lines in any .astro file, and the page can stay output: 'static':
---
// Pattern 1: the widget on a static page. This page is prerendered — there is no server code in it.
// The component talks to the API from the browser with the public key, so `output: 'static'` is enough.
import Layout from '../layouts/Layout.astro';
import { env, KEY } from '../lib/nise';
const APP = (env('NISE_API') || 'https://app.nisebookings.com').replace(/\/+$/, '');
---
<Layout title="nisebookings — Astro example">
<h1>Bookings in an Astro site</h1>
<p class="muted">
This page is static HTML. The form below is the <code><nise-booking></code> web component: one
script tag, one element, the colours from two CSS variables.
</p>
{!KEY && <p class="flash flash--error">Set <code>NISE_KEY</code> in <code>.env</code> first (see README).</p>}
<script src={`${APP}/widget.js`} defer></script>
<nise-booking data-key={KEY}></nise-booking>
<style>
/* The widget lives in a shadow DOM and inherits the page font. Theme it with custom properties. */
nise-booking {
--nb-accent: #E8276B;
--nb-radius: 4px;
}
</style>
<p class="muted">
Next: <a href="/book">the same booking flow in your own UI</a>, rendered server-side.
</p>
</Layout>
Notes:
- Astro scopes
<style>to the component that writes it; this page styles the widget on the element itself, which is where the CSS custom properties belong anyway. - The key is read at build time on a prerendered page, so a key change means a rebuild. Fetch it at runtime (or render the page on the server) if you rotate keys.
- Add the site's origin in Toegestane websites (Instellingen → Insluiten); otherwise the API answers
403 origin_not_allowedand the widget shows an error. The sandbox accepts any origin.
3. Your own UI, server-rendered
export const prerender = false turns the page into a server route: the frontmatter fetches the
catalogue and the slots while the request is open, and the form posts back to the same page. Every
available time is its own submit button, so there is no client JavaScript at all — and it books fine by
keyboard.
---
// Pattern 2: your own UI, server-side. `prerender = false` makes this page SSR: Astro fetches the
// catalogue and the slots during the request, handles the form post, and books through the public API.
// Every slot is its own submit button, so the whole flow works with the browser's own form post —
// no client JavaScript (and it books by keyboard and screen reader too).
export const prerender = false;
import { randomUUID } from 'node:crypto';
import Layout from '../layouts/Layout.astro';
import { book, catalogue, localDate, slots, type Service, type Slot } from '../lib/nise';
const { tenant, services } = await catalogue().then((r) => r.body ?? { tenant: { name: '', timeZone: 'UTC', currency: 'EUR' }, services: [] });
const bookable = (services ?? []).filter((s: Service) => s.type === 'simple' || s.type === 'group');
const chosenId = Astro.url.searchParams.get('service') ?? bookable[0]?.id ?? '';
const chosen = bookable.find((s: Service) => s.id === chosenId);
/** The next free times, newest fetch each time — never cached, a slot you saw may be gone. */
async function loadSlots(serviceId: string): Promise<Slot[]> {
const { body } = await slots(serviceId, localDate(tenant.timeZone));
return (body?.days ?? [])
.flatMap((d) => d.slots.map((s) => ({ ...s, date: d.date })) as Slot[])
.filter((s) => s.state !== 'waitlist')
.slice(0, 40);
}
let available: Slot[] = chosen && Astro.request.method === 'GET' ? await loadSlots(chosen.id) : [];
// The form post: book it, or explain why not — 409 means someone was faster, so re-render the slots.
let result: { kind: 'ok' | 'error'; text: string } | null = null;
if (Astro.request.method === 'POST') {
const fd = await Astro.request.formData();
const [start, staff] = String(fd.get('slot') ?? '').split('|'); // the button carries "start|staffId"
// The key came with the rendered form, so a double-click or a back-button resubmit of this same form
// replays the first answer instead of booking twice. A key made here would be new on every request.
const idempotencyKey = String(fd.get('idem') ?? '') || randomUUID();
const { status, body } = await book({
item: { service: chosen?.id ?? '', staff, start, persons: 1 },
name: String(fd.get('name') ?? ''),
email: String(fd.get('email') ?? ''),
notes: 'Astro example (own UI)',
}, idempotencyKey);
if (status === 201) {
result = { kind: 'ok', text: `Booked: ${body?.serviceTitle ?? chosen?.title} — ${body?.start ?? start} (status ${body?.status}).` };
if (chosen) available = await loadSlots(chosen.id); // the time just booked is gone from this list now
} else if (status === 409) {
result = { kind: 'error', text: 'That time was just taken. Pick another one below.' };
if (chosen) available = await loadSlots(chosen.id); // re-render: the list moved under you
} else {
result = { kind: 'error', text: `Could not book (HTTP ${status}): ${body?.message ?? body?.error ?? 'unknown error'}` };
}
}
const money = (n: number) => new Intl.NumberFormat('nl-NL', { style: 'currency', currency: tenant.currency || 'EUR' }).format(n);
// The list spans 14 days, so every button names its day (noon UTC keeps the calendar date in any zone).
const day = (date: string) => new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }).format(new Date(`${date}T12:00:00Z`));
const formKey = randomUUID(); // one Idempotency-Key per rendered form
---
<Layout title="Own UI — nisebookings Astro example">
<h1>Your own booking UI</h1>
<p class="muted">
Server-rendered by this Astro page: the catalogue and the times come from the public API in the
frontmatter, and the form posts back to this same page. No client JavaScript.
</p>
{result && <p class:list={['flash', result.kind === 'ok' ? 'flash--ok' : 'flash--error']}>{result.text}</p>}
<h2>Services</h2>
<ul class="cards">
{bookable.map((s: Service) => (
<li class="card">
<h2><a href={`/book?service=${s.id}`}>{s.title}</a></h2>
<p class="muted">{s.duration} min · {money(s.price)}</p>
</li>
))}
</ul>
{chosen && (
<>
<h2>{chosen.title} — pick a time</h2>
{available.length === 0
? <p class="flash">No free times in the next 14 days. Try another service or your own account.</p>
: (
<form method="post" class="stack">
<input type="hidden" name="service" value={chosen.id} />
<input type="hidden" name="idem" value={formKey} />
<label>Name <input name="name" required autocomplete="name" /></label>
<label>E-mail <input name="email" type="email" required autocomplete="email" /></label>
<p class="muted">Click a time to book it (your time zone: {tenant.timeZone}).</p>
<ul class="slots">
{available.map((s) => (
<li>
<button class="button" type="submit" name="slot" value={`${s.start}|${s.staffId}`}>
{day(s.date)} {s.time}{s.staffName ? ` · ${s.staffName}` : ''}
</button>
</li>
))}
</ul>
</form>
)}
</>
)}
</Layout>
What this shows:
- Prices and slots come from the server.
GET /servicesandGET /slotsare the same calls the widget makes; the server recomputes the price on booking, so what you display cannot be trusted for the money — the API is. Idempotency-Keyon the POST. One key per rendered form, sent along in a hidden field: a double-click, a retry or a back-button resubmit of that form returns the first response instead of booking twice. (A key made while handling the POST would be new on every request and protect nothing.)- 409 means someone was faster. Re-fetch the slots and let the customer pick again — never retry
with the same
start. - The time zone.
slots?from=wants a date in the business's time zone; thetimefield is already formatted for the business, thestartis UTC — sendstartback exactly as you received it.
If you would rather not render on the server, the same calls work from the browser against the public API (CORS is allowed for the origins in the account); the widget's own flow is the simplest example.
4. Webhooks in a server route
React to bookings, cancellations, tickets and payments where your code already lives:
// Pattern 3: a webhook endpoint as an Astro server route. Register this URL in Instellingen →
// Webhooks (it must be publicly reachable), copy the whsec_… secret into .env, and every booking,
// cancellation, ticket or payment lands here.
//
// Astro gives you the raw body (`request.text()`), which is what the signature is computed over —
// re-serialising parsed JSON would break it.
import type { APIRoute } from 'astro';
import { SECRET, verify } from '../../lib/nise';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const raw = await request.text();
const headers = Object.fromEntries(request.headers); // lowercase keys, as the verifier expects
if (!SECRET || !verify(SECRET, headers, raw, 300)) {
return new Response(null, { status: 401 }); // reject before trusting anything
}
const event = JSON.parse(raw) as { id: string; type: string; data?: { id?: string } };
// Acknowledge first, work after: a slow handler is a failed delivery. Dedupe on event.id — a
// delivery can be retried after a timeout even though you answered.
console.log('nisebookings webhook', event.type, event.data?.id ?? '', `(event ${event.id})`);
return new Response(null, { status: 204 });
};
The verifier itself is the one from the webhooks page, copied into
src/lib/nise.ts so this example has no dependencies beyond Astro. Point the endpoint at a public URL
(register it in Instellingen → Webhooks), answer 2xx within 10 seconds, and dedupe on event.id:
a delivery can be retried after a timeout even though you answered.
5. Pitfalls specific to Astro
- Scoped styles.
<style>in a layout does not reach the page slotted into it. Useis:globalfor layout-level styles, or style the element in the page. import.meta.envvsprocess.env.astro devloads.envinto Vite'simport.meta.env; the built Node server only sees real environment variables. Read both, assrc/lib/nise.tsdoes, or run the server withnode --env-file=.env dist/server/entry.mjs.- Prerendered pages bake in the public key at build time (it is public, so that is fine — just rebuild when it changes).
- View transitions are untested with the widget. On client-side navigation the element is re-created; the script re-upgrades it, but we have not verified every flow. Test that path before you rely on it, or leave view transitions off on the booking page.
- Do not wrap the widget in a
<form>— it has its own submit flow, and a nested form is invalid HTML.
6. Run the example
git clone --depth 1 https://github.com/spoetnik/nisebookings.git # or copy examples/astro
cd examples/astro && cp .env.example .env # paste the key
npm install && npm run dev # http://localhost:4321
Next steps: notifications and webhooks for reacting to bookings in your backend, the widget reference for every attribute and token, and the sandbox if you want to test without touching a real account.