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

## Step 0: Read the project, then build it. Do not ask questions.

This integration has one shape, and everything you need to build it is either in
the repo or has a safe default. Read, build the whole thing, and report what you
did. A question costs the person a context switch; a wrong guess you name in one
line of your report costs them a moment.

Read these. Never ask for them:

| You need | Where it already is |
| --- | --- |
| their email | `git config user.email` |
| 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 all of it, every time.** Reading busy times, creating, updating and
deleting events, and receiving change notifications. The client is a few dozen
lines whether it does one of those or all five, so there is nothing to save by
leaving parts out — and someone who later wants the part you skipped has to come
back and ask.

**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 email in git config | use `you@example.com`, flag it in the report |
| no settings or account page | create a `/settings/calendar` route |
| no obvious env file | `.env.local` |

Ask a question only if you cannot proceed at all without the answer. On a normal
repo that does not happen.

There is **exactly one unavoidable human action** in the whole integration:
each person clicks a connect link once, to let LoopCal see their calendar.
OAuth cannot be automated — that approval belongs to them, not to you. Build
everything around it and hand them the button.

## Step 1: Get an API key

```bash
curl -X POST https://loopcal.dev/api/v1/keys \
  -H 'Content-Type: application/json' \
  -d '{"email":"you@example.com"}'
```

Returns `{ "key": "lc_live_...", "prefix": "...", "accountId": "..." }`.
The key is shown once. Store it as `LOOPCAL_API_KEY` in the project's
environment file — 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 '{"returnTo":"https://yourapp.com/settings"}'
```

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

`return_to` is optional, and the flow is complete without it. Leave it out and
the person lands on a hosted **"Calendar connected"** page at
`loopcal.dev/connected` — plain, no LoopCal navigation, nothing to do but close
the window. Failures land there too, worded for someone who pressed a button
rather than someone debugging OAuth.

Pass `return_to` when you would rather have them back inside the app they
started in; it arrives with
`?connected=true&provider=google&calendar=<the address that connected>`.

So the whole transaction is three parts, and you only build the first:

1. two buttons in their app
2. the provider's consent screen — ours to run
3. the **connected** page — ours to host, unless you pass `return_to`

### 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 — we offer it on the connected 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.

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.

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

### 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: 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">
Your turn      <the connect click, 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.

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.
