nisebookings docs

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>&lt;nise-booking&gt;</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:

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:

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

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.