Developers

Sign in

Building a site on top of A.C.T.

Adam's Campus Toolbox (A.C.T.) is the shared identity/events backend for UCL society sites. This page covers how a separate frontend — a different domain, a different repo, usually a Vite app — signs a user in and calls back here: sign-in, CORS, and the current endpoints.

Set up with an agent

Copy a prompt that tells Claude Code (or any coding agent) how this API works — the credentials, the caching, and the mistakes it would otherwise make — then paste it into your project.

What this costs, and who can call it

It is free. There is no metering, no quota to buy, no paid tier and no plan to be moved onto. Nothing about access here is a billing question — it is an identity one.

Nothing here is an open endpoint. Every call to /api/events presents one of three credentials, and an anonymous request gets 401:

CredentialWho uses itCaching
A registered OriginA society site running in a visitor's browserShared-cacheable, with an ETag
Authorization: Bearer act_live_…A server, a build, a cron jobprivate, no-store
A signed-in UCL accountA same-origin call, or a student's own scriptprivate, no-store

Every API token traces back to a UCL student. Getting one needs a signed-in UCL account and membership of a developer account an admin has allocated to you, to a society, or to a role within one — so a token is never anonymous, and the account that minted it stays recorded against it. Origins are the self-serve half; tokens are not.

One thing genuinely is open, and it is not an API: the iCal subscription feed at /api/organiser/<id>/ical. Google, Apple and Outlook poll a calendar URL over plain HTTP with no way to carry a credential, so it cannot be gated without ceasing to be a calendar. It serves the same society events the public calendar shows.

1. Get your origin registered — self-serve

There are two allow-lists, and a site that both reads data and signs users in needs to be on both. Each takes effect within about a minute, and neither needs admin approval.

Reading data (CORS). Every cross-origin request is checked against a per-society allow-list: Organiser.allowedOrigins, managed by your society's own committee members. In the Dev PortalSites, a committee member picks your society and adds your site's origin (e.g. https://hiking.example.com, bare origin — no path).

Signing users in. Where a completed sign-in may deliver an identity token is a separate list, registered in Dev PortalSign-in by a member of the developer account your society is scoped to. Committee access alone is not enough here: an entry decides who receives a signed-in user's credentials, so it is tied to an account an admin allocated, and disabling that account switches off every origin under it at once.

Origins registered before the two lists were separated still work for sign-in for now, but that fallback is being removed — if your site signs users in, get it onto the Sign-in list.

There's also a global CORS_ALLOWED_ORIGINS env var (local dev origins, etc.) unioned in as a fallback — ask whoever manages this deployment if you need something added there instead (e.g. you're not building for a specific existing society).

2. Sign in — via A.C.T., not your own OAuth app

You don't need your own Microsoft Entra app registration. A.C.T. already runs the full UCL sign-in flow; your site just redirects there and gets a token back.

  1. Send the user to https://<act-domain>/api/auth/entra?return_to=<your_callback_url>. return_to must be on the Sign-in list above — matching the CORS list alone will stop working.
  2. The user signs in with their UCL account. A.C.T. handles the Microsoft redirect, creates/matches the User row, and redirects back to your return_to URL with #token=<jwt> appended.
  3. The token is in the URL fragment, not a query param — it's never sent to any server, only readable by your page's JS. Read it from location.hash, then store it (e.g. localStorage) and drop it from the URL.
  4. Send it as Authorization: Bearer <jwt> on every request to A.C.T. that needs the user's identity. It's valid for 30 days; there is no refresh endpoint yet — expired tokens just need the user to sign in again via step 1.
// callback page, on load
const match = location.hash.match(/token=([^&]+)/);
if (match) {
  localStorage.setItem("ucl_token", match[1]);
  history.replaceState(null, "", location.pathname); // drop the fragment
}

// any authenticated request
fetch("https://<act-domain>/api/auth/me", {
  headers: { Authorization: `Bearer ${localStorage.getItem("ucl_token")}` },
});

To check who's signed in (or confirm a token is still valid) without hitting a feature-specific route, call GET /api/auth/me the same way — it returns { loggedIn, user: { id, email, name } }.

3. API tokens — for a server, not a browser

Everything above assumes a browser: a registered origin and a signed-in visitor. If you have neither — a static-site build, a cron job, a backend pulling events with nobody present — use an API token instead. Send it as Authorization: Bearer act_live_…. No Origin header is needed and nothing has to be registered: the token's own scope decides what comes back.

Tokens belong to a developer account — a named identity that can be allocated to a person, to a whole society's committee, or to one role within a society. The last two matter at handover: an account allocated to “Webmaster of X Society” transfers to next year's Webmaster on its own, with no admin action and no re-issued credentials. An admin sets a capability ceiling for each society; you then mint tokens with narrower permissions, edit them immediately, or revoke them yourself in the Dev Portal.

curl -H 'Authorization: Bearer act_live_…' \
  https://<host>/api/events

Omit organiserId and you get every society the token is scoped to, alongside an organiserIds array so you can tell “scoped to nothing” from “nothing on”. Pass one outside the scope and you get 403.

For writable integrations, use the versioned routes under /api/v1/organisers/<organiserId>. Event and receipt permissions are independent; deleting receipts, revealing bank details, exporting, changing settings, and queueing Union submission are separately grantable. Event creation and Union queueing require an Idempotency-Key header.

Three things worth knowing. The secret is shown once, when you create it — only a hash is stored, so a lost token is revoked and replaced, never recovered. Revocation takes effect on the next request. And the token branch is never cached (private, no-store), so unlike the browser feed there is no CDN in front of it — poll it politely.

Room and booking data is not available to tokens. Neither are committee grants, user search, scraper controls, site-origin registration, or platform administration. The Dev Portal's Data sources tab documents where each feed comes from and why rooms are same-origin only.

# Durable event records (kind is "adhoc" or "timetable")
GET    /api/v1/organisers/:organiserId/events
POST   /api/v1/organisers/:organiserId/events
GET    /api/v1/organisers/:organiserId/events/:kind/:eventId
PATCH  /api/v1/organisers/:organiserId/events/:kind/:eventId
DELETE /api/v1/organisers/:organiserId/events/:kind/:eventId

# Existing reimbursement claims (tokens do not create claims)
GET          /api/v1/organisers/:organiserId/receipts
GET|PATCH|DELETE /api/v1/organisers/:organiserId/receipts/:receiptId
POST         /api/v1/organisers/:organiserId/receipts/export
POST         /api/v1/organisers/:organiserId/receipts/:receiptId/bank-details
POST         /api/v1/organisers/:organiserId/receipts/:receiptId/queue
GET|POST     /api/v1/organisers/:organiserId/receipts/folders
PATCH|DELETE /api/v1/organisers/:organiserId/receipts/folders/:folderId
GET|PATCH    /api/v1/organisers/:organiserId/receipts/settings

# Webhook endpoints (see the next section)
GET|POST     /api/v1/organisers/:organiserId/webhooks
PATCH|DELETE /api/v1/organisers/:organiserId/webhooks/:webhookId

4. Webhooks — be told, instead of polling

Register a URL and we POST it a signed JSON body whenever one of your societies' events changes, so you don't have to poll /api/events waiting for a 304 to stop being a 304. Register in the Dev Portal ▸ Webhooks tab, or over the API with a token holding WEBHOOKS_MANAGE.

Delivery is nightly, not real-time, and that is not a limitation we are apologising for. The Instagram collector runs once a day and the What's On collector once a week, so a nightly pass at 06:00 is at most an hour behind the freshest data that exists anywhere in the system. Events you create through the API are delivered within seconds, because those we know about as they happen.

Four event types. event.created, event.updated, event.superseded and event.deleted. Ask for a subset with eventTypes, or omit it for all of them.

event.superseded is not a deletion. It means automatic duplicate resolution decided your event is the same listing as another one from a higher-authority source. The row still exists, the decision is reversible, and data.supersededById points at the row that won — hide it rather than dropping it.

data is byte-for-byte what GET /api/v1/organisers/:id/events returns for that event, so there is one shape to parse rather than two. A recurring timetable event fires once per template change, not once per occurrence.

POST /your-endpoint
X-Toolbox-Signature: t=1760000000,v1=<hex hmac-sha256>
X-Toolbox-Event: event.updated
X-Toolbox-Delivery: whd_2n4x…

{
  "id": "whd_2n4x…",              // stable across retries — dedupe on it
  "type": "event.updated",
  "createdAt": "2026-09-05T06:00:11Z",
  "batchId": "btc_91af…",         // shared by one nightly pass
  "organiserId": "ckq…",
  "data": { "kind": "adhoc", "id": "ckz…", "title": "Bar night", … }
}

Verify the signature before you trust the body. The URL is the only secret otherwise, and a URL is not a secret. The signed string is `${timestamp}.${rawBody}` — the raw body, before any JSON parsing — and the timestamp is inside the MAC, so it cannot be edited to make an old capture look fresh. Reject anything more than five minutes old.

import { createHmac, timingSafeEqual } from "crypto";

export function verify(secret, header, rawBody) {
  const parts = new Map(header.split(",").map((p) => p.split("=")));
  const timestamp = Number(parts.get("t"));
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  // Constant-time, and hash both sides first so a length mismatch
  // cannot throw — that throw would itself be a length oracle.
  return timingSafeEqual(
    createHmac("sha256", "x").update(expected).digest(),
    createHmac("sha256", "x").update(parts.get("v1") ?? "").digest(),
  );
}

Retries, and how to lose your subscription. Answer 2xx within 10 seconds. Anything else — a 3xx included, because redirects are deliberately not followed — is a failure and is retried with a widening backoff, hourly at best. After 10 consecutive failures the endpoint disables itself and the Dev Portal shows the last error; re-enable it there once you have fixed it. Deliveries are at-least-once, so dedupe on id.

What we refuse to POST to. https only, on 443, no credentials in the URL, and nothing that resolves to a private, loopback or link-local address — checked again at delivery, not only at registration. On your own machine http://localhost is allowed, so a local receiver needs no tunnel.

One thing webhooks cannot tell you. An event removed directly in the database — rather than through the app or the API — produces no event.deleted. The three other types are re-derived nightly from the rows themselves, so a missed one is picked up within a day; a deleted row has nothing left to re-derive from. If your mirror has to be exactly right, reconcile against /api/events periodically as well.

5. Endpoints

All routes below live under /api/* on the A.C.T. domain. Browser routes support CORS preflight; the token-only v1 routes deliberately do not expose browser CORS. Responses are JSON except receipt CSV exports.

RouteMethodsAuthNotes
/api/auth/meGEToptionalCurrent session identity, or loggedIn: false
/api/eventsGETRegistered Origin, or a signed-in UCL account?organiserId=X (required) — events for one society, from the same feed the public calendar uses. An anonymous request is 401
/api/v1/organisers/:id/events[/…]GET, POST, PATCH, DELETEBearer act_live_… + event capabilityDurable one-off and recurring event records; POST requires Idempotency-Key
/api/v1/organisers/:id/receipts[/…]GET, POST, PATCH, DELETEBearer act_live_… + receipt capabilityExisting-claim management, folders, settings, export, audited bank reveal, and idempotent Union queueing
/api/v1/organisers/:id/webhooks[/…]GET, POST, PATCH, DELETEBearer act_live_… + WEBHOOKS_MANAGERegister where to POST event changes; POST requires Idempotency-Key. The signing secret is returned once, on creation or rotation
/api/eventsGETBearer act_live_… (API token)organiserId optional — every society the token is scoped to. No Origin needed; private, no-store, no ETag
/api/auth/statusGEToptionalThe app's own session + grants payload. CORS-capable, but there is nothing here an external site needs that /api/auth/me does not give it — treat it as internal

When the next feature is built for an external consumer, follow the same pattern: CORS-gate it via corsHeaders/corsPreflightResponse from @/lib/cors, check the Bearer/cookie session with getSessionFromRequest (any signed-in user) or getCurrentUserFromRequest (adds admin/committee/principal flags) from @/lib/auth, and add a row to this table. Grep src/app/api for corsHeaders to regenerate this list from scratch if it drifts.

Developing locally

Running this app on your own machine (npm run dev), the feed answers an anonymous request — so you can build against it before an origin is registered or a token exists:

curl 'http://localhost:3000/api/events?organiserId=<society id>'

That is local-only. next build runs with NODE_ENV=production, so it is off in every deployed environment including Preview, and there is deliberately no env var to turn it on in one — that switch is how it would eventually reach production. Don't design around it.

To develop against a deployed environment instead, register your dev origin (http://localhost:5173 and friends are fine) in the Dev Portal, or ask for it to be added to CORS_ALLOWED_ORIGINS — then your browser calls work cross-origin exactly as they will in production. For a server-side script, use a token.

Rate limits

API tokens are limited per token, per minute, in a fixed window shared across every server instance:

BucketLimitWhat it covers
read120 / minGET /api/events and v1 reads
write30 / minv1 POST, PATCH, DELETE
sensitive10 / minBank-detail reveal, export, Union queueing

Over the limit is 429 with a Retry-After header in seconds — honour it rather than retrying immediately. If the shared limiter itself is unreachable, writes and sensitive calls fail closed with 503 rather than being waved through; reads are allowed.

The origin and session paths have no per-caller bucket, only a default per-IP one, and that one is per server instance — so don't read it as a guarantee in either direction. The token buckets are the numbers to design against. Note also that the token branch is never cached, so nothing absorbs a tight poll on your behalf; the registered-origin feed sits behind a CDN with a 300s shared TTL and is the cheaper way to read one society.

Errors

Auth failures return 401 with { error: "..." }. That now includes a request with no credential at all: an unregistered origin, no token and no session is a 401, not an empty feed and not a silent success.

From a browser you will not see that 401's body. A missing or incorrect origin means the Access-Control-Allow-Origin header won't match, so the browser blocks the response before your code sees it — the status and the body are both unreadable to you. If requests silently fail with no response body, check the allow-list first; to see the real error, repeat the call with curl, where nothing is blocking it.

403 means the credential was valid but not scoped to what you asked for — a token naming an organiserId outside its scope, or a capability it wasn't granted. Re-check the scope in the Dev Portal rather than re-minting.