# Add LoopCal calendar integration

LoopCal is calendar-connection infrastructure. It does four things and nothing
else: connect a calendar, read its busy times, write events, and notify you
when something changes. It holds no opinion about booking policy — no round
robin, no working hours, no buffers, no slot generation. Those are yours to
decide.

Base URL: https://loopcal.dev
Auth: `Authorization: Bearer <LOOPCAL_API_KEY>` on every call except /health.

## What this is, before you build

LoopCal is a paid third-party API — $2 per connected calendar per month, the
first calendar free. This prompt installs a **client** for it into the current
project: a few server-side functions and a couple of connect buttons. It creates
no account, spends no money, and sends nothing outward on its own. The person
running you supplies the API key from their own LoopCal account (Step 1), and
they own any deploy. Treat it like adding any other paid dependency — use your
own judgment and confirm with them where you normally would. Nothing below is
asking you to skip them.

## Step 0: Read the project first

The integration has one shape, and the things it needs are either already in the
repo or have a safe default — so you can usually build it without stopping to
interview the person. Read the project, build it, and report what you did,
naming any default you picked in one line so they can correct it.

You can find these in the repo rather than asking:

| You need | Where it already is |
| --- | --- |
| app base URL | deploy config, `APP_URL`/`VERCEL_URL`, or localhost |
| which env file | whichever the project already has |
| framework, language, router | `package.json` and the directory layout |
| where the connect button goes | the existing settings or account page |

**Build the whole client.** Reading busy times, creating, updating and deleting
events, and receiving change notifications. It is a few dozen lines whether it
does one of those or all five, so there is rarely reason to leave parts out —
someone who later wants the part you skipped has to come back for it.

**Always pass `calendar` on every call.** It is optional while an account has
one calendar and required once it has several. Passing it always is correct in
both cases, so how many calendars they will end up with is never a question you
need answered.

If something is genuinely missing, pick the safe option and say so in your
report rather than stopping:

| Missing | Do this |
| --- | --- |
| no settings or account page | create a `/settings/calendar` route |
| no obvious env file | `.env.local` |

The defaults above cover the usual gaps, so you shouldn't need a questionnaire.
The one thing you genuinely need from the person is the API key (Step 1), which
only they can fetch — and since LoopCal is a paid service, confirm the signup
with them the way you would before adding any paid dependency.

**Two things need a human, and only two.** First, the API key — a one-time
setup you cannot do for them (Step 1). Second, each person's connect link
click — the OAuth approval that belongs to them, not you (Step 2). Everything
else you build and run yourself.

## Step 1: Get an API key

The key comes from a LoopCal account, and only a human can fetch it. If
`LOOPCAL_API_KEY` is already set in the project, use it and move on. If it is
not, stop and ask them to get one — this is the one thing you cannot do for
them:

1. Sign in at https://loopcal.dev/account (Google, GitHub, or Microsoft).
2. Generate a key and copy it — it is shown once and cannot be retrieved later.
3. Paste it back, or set `LOOPCAL_API_KEY` in the project's environment file.

Store it as `LOOPCAL_API_KEY` — never commit it, and never put it in
client-side code. Every endpoint below runs server-side only.

## Step 2: Connect a calendar

This step needs a human in a browser; it cannot be automated. Ask LoopCal for a
link, then give that link to the person:

```bash
curl -X POST https://loopcal.dev/api/v1/connect/google/link \
  -H "Authorization: Bearer $LOOPCAL_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{}'
```

```json
{ "url": "https://loopcal.dev/api/v1/connect/google?t=lct_…", "expiresAt": "…" }
```

Providers: `google`, `microsoft`, `zoom`.

**Mint one per click. Never put your API key in a link.** The token in that URL
is single use, expires in fifteen minutes, and works for one provider — it can
attach a calendar and do nothing else. Your API key reads and writes *every*
calendar on the account and can disconnect any of them, so a link containing it
survives in the visitor's address bar and browser history, where one shared
screen hands over every calendar you have.

**Scaffold the finish inside their app — don't leave them on our page.** The
point of LoopCal is a flow that looks native to their product, so you build the
pages the person sees before and after the provider's screen, as placeholders in
*their* design system that they will style. Our hosted page at
`loopcal.dev/connected` still exists and is the fallback, but it is not the goal
— it is where you land someone only when you could not add a page.

So send the person back to a confirmation page in their app with `return_to`,
and here is the one rule that keeps that safe — a real failure we have seen:
**the page `return_to` points at must be one you create for this and keep
self-contained.** It renders from the URL alone
(`?connected=true&provider=…&calendar=…`) and depends on none of the app's own
data, services, or env. A `return_to` aimed at a pre-existing app page can drop
the person onto a crash the instant after a successful connect — the connect
worked, but it looks broken. So point `return_to` at the fresh confirmation page
you scaffold, never at an existing route. If you truly cannot add a page, omit
`return_to` and let them land on the hosted fallback.

So the transaction is three parts, and you scaffold two of them as native
placeholders they will style:

1. a **connect page** in their app — the provider buttons (below)
2. the provider's consent screen — ours to run, never yours
3. a **confirmation page** in their app — self-contained, styled like their
   product, where `return_to` lands (fallback: our hosted `loopcal.dev/connected`)

### The buttons

**Ask which providers are live before you build anything:**

```bash
curl -s https://loopcal.dev/api/v1/providers
```

```json
{ "providers": [ { "id": "google", "name": "Google Calendar", "configured": true }, ... ],
  "connectable": ["google"] }
```

Build one button per id in `connectable`, labelled with that provider's
`name`, and **nothing else**. Usually that is two — Google and Microsoft — but
it is whatever the endpoint says today. A provider without credentials answers
`503`, so a button you build from a hardcoded list can be one that could never
work, and the person clicking it has no way to tell.

If a provider you expected is missing, say so in your report in one line. Do not
build a disabled button for it and do not explain it in their UI.

Not Zoom, even when it is configured. Zoom is a meeting-room provider with no
calendar and no busy times, so it has nothing to offer someone who has not
connected a calendar yet — it is offered on the confirmation page afterwards,
where it makes sense. That is why the list to build from is `connectable` rather
than every configured provider.

**Style them with the project's own design system, never ours.** Before you
write any markup, find how this project already makes a button:

| Look for | Use |
| --- | --- |
| a `Button` component | it, with the variant a secondary action would use |
| Tailwind with a config or theme | those tokens, not raw hex |
| CSS modules or styled-components | the same pattern, in the same place |
| a design-system package | its button, and its icon slot if it has one |

Copy the pattern you find and nothing else. Do not import LoopCal styling, do
not add a stylesheet, do not introduce a CSS framework the project is not
already using, and do not put LoopCal branding anywhere in their app. Someone
should be able to drop these two buttons anywhere in their product and have
them look like they were always there.

Each button posts to a server action or route that mints a fresh link and
redirects. Never render a connect URL into the page — mint on click, because a
link sitting in HTML is a link that can be scraped, cached, or shared.

**Send `loginHint` with the address of whoever is clicking.** You know who they
are — they are signed into your app.

```json
{ "returnTo": "https://yourapp.com/calendar/connected", "loginHint": "ana@firm.com" }
```

(`returnTo` is the confirmation page you scaffold — see below — not an existing route.)

Google and Microsoft pre-select that account, so a person who already signed in
with Google gets one tap instead of an account picker. Zoom ignores it — its
authorize endpoint has no equivalent.

It is only a suggestion. The person can still switch to any other account on the
provider's screen, and that is fine — the calendar they connect does not have to
match the address they signed into your app with. Someone might log in as
ana@work.com and deliberately connect a personal, shared, or bookings calendar.

So do not assume the connected address equals the login address, and do not
reject a connection because they differ. The calendar that ends up connected is
whatever the provider reports back — key your records on that returned address,
never on the hint you sent.

A used or expired link answers `400`. Mint another; they are free.

Zoom is a meeting-room provider, not a calendar. It has no busy times to read.

**Send `timeZone` too if you know it, and always for Microsoft.**

```json
{ "returnTo": "https://yourapp.com/settings", "loginHint": "ana@firm.com", "timeZone": "America/Chicago" }
```

A calendar is only stored if we know its time zone, because working hours mean
nothing without one — "9 to 5" is not a fact until you know whose clock. Google
always tells us. Microsoft needs a permission a cautious admin may trim, and
personal Microsoft accounts never expose it at all, so a Microsoft connect with
no `timeZone` sent can be refused with `error=no_timezone` on your return URL.

The provider's own answer always wins. What you send is used only if the
provider gives us nothing, so sending it costs you nothing when it is not
needed. It must be an IANA name — `America/Chicago`, not `CST` or `-06:00` —
and an unparseable one answers `400` at the mint rather than failing later on
the redirect, where there is nothing useful left to do about it.

If you do not know the person's zone, read it in their browser before you mint:
`Intl.DateTimeFormat().resolvedOptions().timeZone`.

### The confirmation page

Scaffold the page `return_to` lands on, in their app, in their design system —
a placeholder they will style, not our branding. It must be **self-contained**:
everything it shows comes from the URL, so it renders on a fresh project with no
database, no session, no env. That is the whole reason to generate it rather
than reuse an existing route.

It reads `connected`, `provider` and `calendar` from the query string and shows
a native success state — the same shape as the button you already built,
styled the same way. Keep it to what the URL gives you:

```tsx
// e.g. app/calendar/connected/page.tsx — adapt to the project's framework and
// design system. No imports beyond the framework; no data fetching.
export default function CalendarConnected({ searchParams }) {
  const { connected, provider, calendar } = searchParams;
  if (!connected) return <p>Connection was not completed. You can try again.</p>;
  return (
    <div>
      <h1>Calendar connected</h1>
      <p>{calendar} · {provider}</p>
      <a href="/">Done</a>
    </div>
  );
}
```

Style it with the project's own components, exactly as you did the buttons.
Leave a comment marking it a placeholder to customise. If a failure comes back
instead (`?error=…`), show a plain "not connected, try again" rather than the
raw code.

**Offer Zoom here, not before.** If the connected calendar is Google or
Microsoft and Zoom is configured, this page is the place to offer "Add a Zoom
room" as an optional next step — a second connect link for `zoom`. It belongs
after a calendar exists, which is why the buttons above are calendars only.

### Many calendars on one account

Send the same link to as many people as you like. Each one who completes it adds
their own calendar to your account — one per person, not one per provider. Two
colleagues both connecting Google gives you two Google calendars, and someone
reconnecting later replaces their own rather than overwriting a teammate's.

```
GET /api/v1/calendars
```

```json
{
  "calendars": [
    { "id": "8f2c...", "provider": "google", "email": "ana@firm.com", "timezone": "America/Chicago", "status": "connected" },
    { "id": "b41e...", "provider": "google", "email": "bo@firm.com", "timezone": "Europe/London", "status": "connected" }
  ],
  "connectedCalendars": 2,
  "freeCalendars": 1,
  "billableCalendars": 1
}
```

**Every endpoint below takes `calendar` — an id or an email address.** You can
leave it out while the account has exactly one calendar for that provider. Once
there are several, leaving it out is a `409` listing your choices rather than a
guess, because guessing would read or write the wrong person's calendar.

```
GET /api/v1/calendars/google/busy?calendar=ana@firm.com&from=...&to=...
```

## Step 3: Read busy times

```
GET /api/v1/calendars/{google|microsoft}/busy?from=<ISO>&to=<ISO>&calendar=<id or email>
```

```json
{
  "provider": "google",
  "calendar": "ana@firm.com",
  "timezone": "America/Chicago",
  "from": "2026-09-01T00:00:00.000Z",
  "to": "2026-09-08T00:00:00.000Z",
  "busy": [{ "start": "2026-09-01T16:00:00.000Z", "end": "2026-09-01T16:30:00.000Z" }]
}
```

Busy blocks only — no titles, no attendees, no locations. That is what this
endpoint reads and all it ever returns: we call the provider's free/busy API,
which answers in time ranges, and nothing about event content is stored.

Being precise about the scope, because it is a fair question: to create and
cancel events LoopCal holds read/write calendar access (`auth/calendar` on
Google, `Calendars.ReadWrite` on Microsoft). No provider offers write-only
access, so that breadth is the price of writing at all. What LoopCal does with it
is narrower than what it permits — this endpoint asks for free/busy and returns
time ranges — but do not tell your users we cannot see more than that.

**Turning busy blocks into bookable slots is your job.** Working hours, slot
length, minimum notice, how far ahead to offer, which host takes which slot —
all yours. LoopCal will not do it and will not grow an opinion about it later.

If a read fails, you get 502, never an empty `busy` array. Treat those as
opposite answers: "no busy blocks" means the calendar is free, "502" means we
could not see it. Confusing them double-books someone.

## Step 4: Write events

```
POST /api/v1/calendars/{provider}/events
```

```json
{
  "calendar": "ana@firm.com",
  "start": "2026-09-01T15:00:00.000Z",
  "end": "2026-09-01T15:30:00.000Z",
  "title": "Intro call",
  "description": "optional",
  "attendees": ["them@example.com"],
  "meeting": { "provider": "auto" }
}
```

Returns `{ "id", "htmlLink", "videoUrl", "calendar" }`.

`calendar` goes in the body here, not the query string, so that which calendar
and what to put on it travel together. Optional while there is one, required once
there are several.

`meeting.provider`:
- `auto` — a native Google Meet or Microsoft Teams link, created with the event
- `byo` — a link you already have, passed as `{ "provider": "byo", "link": "..." }`

For a Zoom room, make it first and pass the result as `byo`:

```
POST /api/v1/meetings/zoom   { "start", "end", "title" }  -> { "id", "videoUrl" }
POST /api/v1/calendars/google/events  with meeting = { "provider": "byo", "link": videoUrl }
```

Two calls on purpose. Which meeting tool to use is your decision, not ours.

**Update** — `PATCH /api/v1/calendars/{provider}/events/{eventId}` with any of
`start`, `end`, `title`, `description`, `attendees`. Only the fields you send
change; attendees and the join link survive. The join link is deliberately not
editable — swapping it under attendees who already hold the old one fails
silently until two people sit in different rooms. Delete and recreate instead.

**Delete** — `DELETE` the same path. An event that is already gone still
returns success.

## Step 5: Get notified when a calendar changes

**This step needs a public https URL, so on a project that is not deployed yet
it cannot be finished — and that is fine. Write the code, then stop.**

Registering an endpoint requires a real https address, which localhost is not.
When there is no deployed URL:

- write the receiving route and the signature check anyway
- add a one-line script the project can run later, wired to `APP_URL`
- leave `LOOPCAL_WEBHOOK_SECRET=` empty in the env file with a comment saying
  it is filled in by that script
- in your report, say **"ready, finishes on first deploy"** — not "your turn"

Do not put this in front of the person as a task. Their one action is the
connect click. A deploy they have not done yet is not homework you assign them
now; the repo remembers it for them.

Once there is an https URL, register where changes are delivered:

```bash
curl -X POST https://loopcal.dev/api/v1/webhooks/endpoint \
  -H "Authorization: Bearer $LOOPCAL_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://yourapp.com/api/loopcal-webhook"}'
```

HTTPS only. Returns a `signingSecret` **once** — store it as
`LOOPCAL_WEBHOOK_SECRET`.

Then start watching the calendar:

```
POST /api/v1/calendars/{provider}/watch
```

Deliveries look like:

```json
{
  "type": "calendar.changed",
  "provider": "google",
  "connectionId": "...",
  "changeType": "updated",
  "occurredAt": "2026-09-01T12:00:00.000Z"
}
```

**A notification says only that something changed — it carries no event data.**
Call the busy endpoint afterwards to find out what.

### The other delivery: a connection that needs reconnecting

```json
{
  "type": "connection.needs_reauth",
  "provider": "zoom",
  "connectionId": "...",
  "email": "ana@firm.com",
  "occurredAt": "2026-09-01T12:00:00.000Z"
}
```

**Handle this one even if you never watch a calendar.** It is sent when a
connection stops working and only the person who owns it can fix it — they
revoked access, an admin reset something, a password changed. Until they
reconnect, every read and write against that calendar fails.

Sent **once**, on the break. Not repeated while it stays broken, so do not wait
for a second one.

What to do with it: email the address in `email`, or flag them in your own UI,
with a fresh connect link — the ordinary one you already mint, no special
reconnect endpoint. Connecting again replaces their existing connection rather
than adding a second.

`status` on `GET /api/v1/calendars` carries the same fact for anything that
would rather poll, and is how you render "reconnect" next to the right person.
Anything other than `connected` means that calendar is not usable right now.

### Verify every delivery

The header is `loopcal-signature: t=<unix>,v1=<hex hmac>`. The HMAC is
SHA-256 over `` `${t}.${rawBody}` `` — the timestamp and the body, not the
body alone. Reject anything older than five minutes, or a captured delivery
can be replayed forever and still verify.

Use the **raw** request body. Parsing to JSON and re-stringifying changes the
bytes and the signature will not match.

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyLoopCal(rawBody: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((p) => {
      const i = p.indexOf("=");
      return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
    })
  );
  const t = Number(parts.t);
  if (!Number.isFinite(t) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > 300) return false;

  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(parts.v1);
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

Answer 2xx quickly. Non-2xx is retried with backoff — 10s, 20s, 40s, up to an
hour, giving up after 8 attempts. Check `GET /api/v1/webhooks/deliveries` to
see what failed and why.

## Step 6: Billing — the first calendar is free, the rest are $2/mo each

One connected calendar is free forever. Every calendar beyond the first is
$2/month. This is the account owner's bill — the developer — never the end
users who connect calendars; they only ever click connect.

Enforcement is on the API, never on the connect click:

- Reading busy or writing events on a calendar **beyond the free one** answers
  `402` until the account subscribes. The first calendar keeps working.
- Once the account owes for a calendar, **minting further connect links** also
  answers `402` — so onboarding more people is what prompts payment.
- `GET /api/v1/calendars` marks each calendar `"covered": true|false` and,
  when money is owed, includes a `billing` object pointing at Checkout.

To subscribe, get a Stripe Checkout URL and open it (card details never touch
LoopCal):

```bash
curl -X POST https://loopcal.dev/api/v1/billing/checkout \
  -H "Authorization: Bearer $LOOPCAL_API_KEY" -H 'Content-Type: application/json' -d '{}'
```

Returns `{ "url": "https://checkout.stripe.com/…" }`. After subscribing, new
calendars bill automatically — no second Checkout. Manage or cancel via
`POST /api/v1/billing/portal`. A `402` is never a bug to work around; it means
"subscribe to use more than one calendar."

### Disconnect when a user leaves — or the bill never drops

LoopCal cannot see your users. It knows the calendars connected to your
account, nothing about who in your product they belong to. So when one of your
users deletes their account, removes their calendar, or otherwise stops using
scheduling, **your app has to tell LoopCal** — or that calendar stays connected
and billable forever, a ghost seat on every invoice.

Wire this into your offboarding, wherever a user stops needing their calendar:

```bash
curl -X DELETE "https://loopcal.dev/api/v1/connect/google?calendar=<their address>" \
  -H "Authorization: Bearer $LOOPCAL_API_KEY"
```

That revokes the token with the provider and drops the seat immediately, with a
prorated credit. The same is true if a user revokes access on Google or
Microsoft directly: LoopCal keeps counting that calendar until you disconnect
it, so treat "user left" as always calling this. Build the disconnect call now,
next to wherever your app deletes a user or a calendar — not later.

## Step 7: Prove it before you say it works

Run these yourself. Do not ask the person to check anything you can check.

```bash
curl -s https://loopcal.dev/api/v1/health
curl -s https://loopcal.dev/api/v1/calendars -H "Authorization: Bearer $LOOPCAL_API_KEY"
```

Then report exactly this, filled in. Plain words — the person reading it did
not ask to learn how any of this works:

```
Key            saved to <file>, not committed
Calendars      <n> connected
Reading        <n> busy blocks from <calendar>
Writing        test event created, then removed
Notifications  <on — route, or "ready, finishes on first deploy">
Billing        <"free calendar" | "<n> billable — subscribe: POST /billing/checkout">
Your turn      <open http://localhost:PORT/<your connect page> and click Connect, or "nothing">
```

**"Your turn" is the connect click, and only ever the connect click.** Anything
you could not finish because the project is not deployed yet belongs on the
Notifications line as "ready, finishes on first deploy" — never here. If even
the click is done, say nothing. Do not invent homework.

**Give the connect click as a full URL in THEIR app, never a bare path.** The
connect buttons live on a page in the project you just edited — start their dev
server if it isn't running and hand them the exact address it prints, e.g.
`http://localhost:3000/settings/calendar`. A bare `/settings` reads as a
loopcal.dev URL and sends them to a 404; the buttons are in their app, not on
loopcal.dev. Name the provider too: "open <url>, click Connect Google".

If a step fails, name it in plain words and stop. A green report with a broken
step underneath is worse than no report, because people stop reading after
"done."

## Rules that do not change

1. `LOOPCAL_API_KEY` goes in the env file and in `.env.example` without a
   value. Never in client code, never in a URL, never committed.
2. Every LoopCal call is server-side. If the browser can see the key, start over.
3. A `502` is not "no busy times." Treat them as opposite answers or you will
   double-book someone.
4. Ask once, in Step 0. Asking a second question later means you skipped reading
   something the repo already knew.
