Lookout is a screen recording timelapse service. It has two distinct API surfaces:
- Internal API — server-to-server, protected by API key. Used by your trusted backend to create/manage sessions.
- Client API — browser-facing, authenticated by session token. Used by the user's browser to record and upload screenshots.
┌─────────────────────┐ ┌───────────────────────┐
│ Your Backend │ │ Lookout Server │
│ (trusted server) │────────>│ (internal API) │
│ │ POST │ │
│ Creates sessions, │ /api/ │ Creates session, │
│ manages lifecycle │ internal│ returns token │
└─────────┬───────────┘ └───────────────────────┘
│ │
│ Passes token to browser │
│ (URL param, redirect, etc.) │
v │
┌─────────────────────┐ ┌───────────────────────┐
│ User's Browser │ │ Lookout Server │
│ (untrusted client) │────────>│ (client API) │
│ │ token │ │
│ Screen capture, │ based │ Presigned URLs, │
│ upload screenshots │ │ timing validation │
└─────────┬───────────┘ └───────────────────────┘
│
│ Direct upload via presigned URL
v
┌─────────────────────┐
│ Cloudflare R2 │
│ (screenshot store) │
└─────────────────────┘
Your trusted backend is the only entity that can create sessions. All internal
API calls require the X-API-Key header.
curl -X POST https://lookout.hackclub.com/api/internal/sessions \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key" \
-d '{"metadata": {"userId": "user_123", "projectId": "proj_456"}}'Response:
{
"token": "5b70dd22...64-char-hex-string",
"sessionId": "137c9b2f-3e74-4c25-a295-b41bd4d2c5d1",
"sessionUrl": "https://lookout.hackclub.com/session?token=5b70dd22..."
}token— the session credential. Give this to the user's browser, and store it on your server associated with the user so you can look up the session later.sessionId— the server-side ID.sessionUrl— a convenience URL you can redirect the user to. It serves a recorder Lookout hosts, where the user picks the desktop app, this browser, or a camera; it also takes?app=your-programand?edit=false(drops "Edit & save" from the stop dialog).metadata— any JSON you want to associate with the session (user info, project, etc.)clips— setfalseto opt this session OUT of clips and back to 1 JPEG/min. Defaulttrue(~6 frames/min video → 6× smoother timelapses); immutable after creation.redirectUrl— optional redirect hook: an http(s) URL the recording client sends the user to once their timelapse finishes compiling. Immutable after creation.
Pass redirectUrl when creating a session to send the user somewhere when
their timelapse is done — e.g. back to your submission form:
curl -X POST https://lookout.hackclub.com/api/internal/sessions \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key" \
-d '{"metadata": {"userId": "user_123"}, "redirectUrl": "https://yourprogram.example/submit?step=timelapse-done"}'How it behaves:
- The URL must be
http(s)(max 2048 chars) — anything else is rejected with a 400 at creation time. - The desktop app opens the URL in the user's default browser the moment it
sees the session flip to
completewhile the user is watching the compile (i.e. right after they stop recording). It fires at most once per session, and does not fire when someone later re-opens an already-completed session from their gallery. - The URL is surfaced to clients on
GET /api/sessions/:tokenandGET /api/sessions/:token/statusasredirectUrl, so custom clients can implement the same behavior. - Older desktop clients ignore the field — treat the redirect as a convenience, not a guaranteed callback. For server-side certainty, poll session status instead.
Sessions record clips by default: instead of one JPEG per minute, the
recording client uploads one ~60s video file per minute containing ~6 frames
captured 10s apart. The compiled timelapse has the same length but is 6×
smoother, with motion from the very first second. Pass "clips": false at
creation to opt out.
What this means for your program:
- Nothing in your integration changes. A clip is still one capture unit
per minute —
trackedSeconds,screenshotCount,/timings(still one timestamp per minute → Hackatime forwarding unchanged),videoUrl, and every response shape are identical between clips and non-clips sessions. - Clients negotiate automatically. The hosted web recorder and React SDK (≥0.4) detect the flag on the session and record clips; older clients and the desktop app keep uploading JPEGs to the same session, which stays fully valid (formats can even mix within one session).
- Network: a clip is capped at 8 MB/min server-side. At 6 frames/min a typical screen measures ~1.1 MB/min and a deliberately incompressible one ~1.4 MB/min — under half of what the same content cost at 15 frames/min, since bandwidth scales with the frame count and the per-frame quality budget is held constant.
- Frame quality: clip frames are bitrate-capped rather than encoded independently, but the budget is sized per frame to hold q0.85-JPEG-class detail at 1080p even on busy screens, and it is rescaled whenever the cadence changes — so frames stay legible at any frame rate. For review purposes you get 6× more moments per minute.
- The flag is per session, so you can disable it for a fraction of new sessions and compare, or turn it off entirely for a program that needs the legacy payload.
curl https://lookout.hackclub.com/api/internal/sessions/SESSION_ID \
-H "X-API-Key: your-api-key"Response:
{
"session": {
"id": "137c9b2f-3e74-4c25-a295-b41bd4d2c5d1",
"token": "5b70dd22...64-char-hex-string",
"name": "My timelapse",
"metadata": {"userId": "user_123", "projectId": "proj_456"},
"status": "active",
"startedAt": "2024-01-01T12:00:00.000Z",
"totalActiveSeconds": 300,
"videoUrl": null,
"videoWebmUrl": null,
"thumbnailUrl": null,
"createdAt": "2024-01-01T11:50:00.000Z"
},
"trackedSeconds": 123,
"screenshotCount": 45
}trackedSeconds— tamper-proof tracked time. Sessions created post-0.2.1 use credit mode: each capture that arrives within ±30s of the streak-anchored expected mark credits 60s; out-of-window captures reset the streak. Pre-0.2.1 sessions remain on bucket mode (distinct confirmed minute buckets × 60). Mode is sticky per session — clients that sendcapturedAtflip the session to credit on first upload.screenshotCount— number of confirmed screenshots
curl -X POST https://lookout.hackclub.com/api/internal/sessions/SESSION_ID/stop \
-H "X-API-Key: your-api-key"curl -X POST https://lookout.hackclub.com/api/internal/sessions/SESSION_ID/recompile \
-H "X-API-Key: your-api-key"If you're using React, the @lookout/react SDK handles
all of this for you with a drop-in <LookoutRecorder> component or the useLookout() hook.
The browser receives the token and uses it for all operations. The client is untrusted — all timing and time tracking is validated server-side.
1. Get token from URL: /session?token=abc123
2. GET /api/sessions/:token → check session status
3. User clicks "Start Recording"
4. Call navigator.mediaDevices.getDisplayMedia() to share screen
5. Capture loop — each iteration is one full pipeline, awaited end-to-end.
The cadence (~60s between captures in steady state) emerges from the
server's nextExpectedAt, NOT a fixed client setInterval.
a. Stamp capturedAt = client clock at the moment you grab the frame
b. Capture canvas screenshot (JPEG, max 1080p)
c. GET /api/sessions/:token/upload-url?capturedAt=<iso8601>
→ { uploadUrl, screenshotId, nextExpectedAt, trackingMode }
(First call activates the session: pending → active)
(Presence of capturedAt on the FIRST upload sticks the session to
credit mode; absence sticks it to bucket mode. Mode is permanent.)
d. PUT blob to uploadUrl (presigned R2 URL)
e. POST /api/sessions/:token/screenshots { screenshotId, width, height, fileSize }
→ { confirmed, trackedSeconds, nextExpectedAt }
Display trackedSeconds (server-authoritative) — do NOT compute
display time from uploads.completed.
f. Schedule the next iteration:
delay = max(0, parse(nextExpectedAt) - Date.now())
Then setTimeout(loop, delay). Never fire sooner than this — bursts
cause streak resets in credit mode.
6. User clicks "Pause" → POST /api/sessions/:token/pause
7. User clicks "Resume" → POST /api/sessions/:token/resume → restart loop
8. User clicks "Stop" → POST /api/sessions/:token/stop → token becomes read-only
9. Poll GET /api/sessions/:token/status for compilation progress
10. GET /api/sessions/:token/video → presigned URL for the timelapse MP4
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/sessions/:token |
Session status (for recovery after refresh) |
| GET | /api/sessions/:token/upload-url |
Get presigned PUT URL. Pass ?capturedAt=<iso8601> to opt the session into credit mode. Activates session on first call. Rate limited: 10/min per session. |
| POST | /api/sessions/:token/screenshots |
Confirm upload. Body: { screenshotId, width, height, fileSize, final? }. Returns { confirmed, trackedSeconds, nextExpectedAt }. Server verifies R2 object exists. Rate limited: 20/min per token. Set final: true only on the one capture a client flushes right before pausing/stopping: credit-mode servers then credit the exact seconds elapsed since the last credited mark (clamped to 0–60) instead of the all-or-nothing streak rule, so a pause at 03:15 resumes at 03:15. Flush before the pause POST — a paused session rejects confirms. |
| POST | /api/sessions/:token/pause |
Pause session |
| POST | /api/sessions/:token/resume |
Resume session |
| POST | /api/sessions/:token/stop |
Stop session, trigger compilation. Accepts a stop at any duration — but under a credited minute there's no capture unit to compile, so the official clients warn in the stop confirmation before going through with it. |
| GET | /api/sessions/:token/status |
Poll compilation status |
| GET | /api/sessions/:token/video |
Get presigned video URL |
The client should handle network failures gracefully:
- Run the upload pipeline serially per capture — take screenshot, await
GET /upload-url, await R2 PUT, awaitPOST /screenshots, then schedule the next capture from the confirm response'snextExpectedAt. This is the pattern the desktop Rust loop and the v0.2.4+ React SDK both use. Fire-and-forget queueing produces stale-ref bursts and is no longer recommended. - Retry each leg — presigned URL request, R2 PUT, confirmation POST — up to 3 times with exponential backoff (2s, 4s, 8s). Treat 409 (session paused/stopped) as terminal, not retriable.
- Send
capturedAton every upload-url request (ISO-8601, UTC) — this opts the session into credit-mode tracking. Without it, the session stays on legacy bucket mode for life. StampcapturedAtat the moment the frame is grabbed, not when the request is sent — uploads can be delayed by network without losing credit accuracy. - Schedule the next capture from
nextExpectedAt— every confirm response carries the server's authoritative target for the next capture. Computedelay = max(0, parse(nextExpectedAt) - now). If the delay is 0 (server fell behind), fire immediately to catch up — but never fire sooner than this, or you'll cause streak resets. - Idempotent confirmation — confirming an already-confirmed screenshot is a no-op, so retries on the confirm leg are safe.
- Display the server's
trackedSeconds, not a derived estimate — do not compute display time fromuploads.completed * intervalSecondsor similar. In credit mode, not every successful upload credits a minute (out-of-window captures return 200 butcredited_seconds = 0). Display estimates derived from upload count over-count in those cases; previously this inflated displays by exactly 2× when total round-trip hit ~90s.
The server only updates trackedSeconds once per credited capture (~once a minute). For a smoothly-ticking UI, interpolate locally between server updates — but cap the interpolation at one capture interval so the display can never overshoot the next credit. This is the same shape useSessionTimer ships in the React SDK and the Rust tray ticker uses on desktop.
const INTERVAL_S = 60; // SCREENSHOT_INTERVAL_MS / 1000 — the cap
let baseSeconds = 0; // last server-credited value
let lastSyncMs = Date.now(); // when we received it
// Call this from each confirm response and from the periodic
// GET /api/sessions/:token status poll.
function onServerTrackedSeconds(serverTracked: number) {
// Ratchet forward — never let a stale-read response (e.g. an
// idempotent retry returning a cached older value) drag the timer back.
if (serverTracked > baseSeconds) {
baseSeconds = serverTracked;
lastSyncMs = Date.now();
}
}
function getDisplaySeconds(): number {
const elapsedS = Math.floor((Date.now() - lastSyncMs) / 1000);
// Cap at one interval. If captures stall, the display freezes at
// base + 60 instead of running unbounded. When the next credit
// lands it equals the frozen value — no visible jump.
return baseSeconds + Math.min(INTERVAL_S, elapsedS);
}
// Tick the UI once per second while recording.
const tickId = setInterval(() => {
ui.timer.textContent = formatTime(getDisplaySeconds());
}, 1000);
// On pause/stop/compile: stop ticking and snap to the server value.
// Worst-case drop the user sees is one interval, never the full session.
function onSessionInactive() {
clearInterval(tickId);
ui.timer.textContent = formatTime(baseSeconds);
}Why the cap matters: without it, the display runs at wall-clock rate forever and reveals the true (smaller) trackedSeconds only when the user clicks Stop. Users have reported this as "timer ran to 20 min, then dropped to 5 min on compile." With the 60s cap, the maximum visible drop is one capture interval.
On stop: read trackedSeconds from the /stop response and assign it to baseSeconds so the final display matches the server's committed value exactly.
On page load, read the token from the URL and call GET /api/sessions/:token:
pending→ show "Start Recording" buttonactive→ prompt user to re-share screen (the session is still going)paused→ show "Resume" buttonstopped/compiling→ show progress indicator, poll statuscomplete→ show video playerfailed→ show error message
The totalActiveSeconds and trackedSeconds fields let you restore the timer display.
// Request screen share (max 1080p, low framerate to save CPU)
const stream = await navigator.mediaDevices.getDisplayMedia({
video: { width: { max: 1920 }, height: { max: 1080 }, frameRate: { ideal: 1 } },
audio: false,
});
// Listen for user stopping share via browser UI
stream.getVideoTracks()[0].addEventListener('ended', onShareStopped);
// Create hidden video element
const video = document.createElement('video');
video.srcObject = stream;
video.muted = true;
await video.play();
// Capture a screenshot
function captureScreenshot(): Promise<Blob> {
const canvas = document.createElement('canvas');
const scale = Math.min(1920 / video.videoWidth, 1080 / video.videoHeight, 1);
canvas.width = Math.round(video.videoWidth * scale);
canvas.height = Math.round(video.videoHeight * scale);
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
return new Promise(resolve => {
canvas.toBlob(resolve, 'image/jpeg', 0.85);
});
}Once a session is complete (or at any point), use the token you stored in Part 1 to fetch session details.
curl https://lookout.hackclub.com/api/sessions/TOKENResponse:
{
"status": "complete",
"trackedSeconds": 3540,
"screenshotCount": 59,
"startedAt": "2024-01-01T12:00:00.000Z",
"totalActiveSeconds": 3600,
"createdAt": "2024-01-01T11:50:00.000Z",
"thumbnailUrl": "https://...",
"videoUrl": "https://...",
"videoWebmUrl": "https://...",
"clientInfo": "Lookout Web (Fallout)/0.2.6 (macOS 14.3; Chrome 120.0)",
"metadata": {"userId": "user_123", "projectId": "proj_456"}
}Key fields for your backend:
trackedSeconds— tamper-proof tracked time. Use this for time verification. Credit-mode sessions (default for clients ≥0.2.1) credit 60s per capture that lands within ±30s of the server-anchored expected mark; bucket-mode sessions use distinct minute-bucket count × 60.screenshotCount— number of confirmed screenshotsvideoUrl— presigned URL to the compiled MP4 timelapsevideoWebmUrl— legacy URL retained for pre-0.2.0 clients; points at a static "please update" video (WebM encoding was dropped in 0.2.0)thumbnailUrl— presigned URL for the session thumbnailclientInfo— client telemetry string (which Lookout client/version/OS/browser recorded the session);nullif none recordedmetadata— the metadata you attached when creating the session
Note: To fetch multiple sessions at once, use POST /api/sessions/batch with a {"tokens": ["token1", "token2", ...]} body (max 100).
GET /api/sessions/:token/timings returns the capture timestamps of every confirmed screenshot in a timelapse — i.e. when the session was recorded. It's token-authenticated like the other client endpoints, so the same token you stored in Part 1 works.
curl https://lookout.hackclub.com/api/sessions/TOKEN/timingsResponse:
{
"status": "complete",
"count": 59,
"first": "2024-01-01T12:00:00.000Z",
"last": "2024-01-01T12:59:00.000Z",
"clientInfo": "Lookout Web (Fallout)/0.2.6 (macOS 14.3; Chrome 120.0)",
"timestamps": [
"2024-01-01T12:00:00.000Z",
"2024-01-01T12:01:00.000Z",
"2024-01-01T12:02:00.000Z"
]
}timestamps— ISO-8601, ascending. One entry per confirmed screenshot (~60s apart in steady state).first/last— convenience accessors (first/last element of the array);nullfor a session with no screenshots.count— number of timestamps (= confirmed screenshot count). Not a count of minutes — more than one capture can land in the same minute (retries, resume, jitter), socountcan exceed the number of distinct minutes. UsetrackedSecondsfor tracked time.clientInfo— client telemetry string from the first screenshot;nullif none recorded.
last − first is not the recorded duration. Sessions can be paused and resumed, leaving gaps between consecutive timestamps, so that span is wall-clock elapsed time and overstates actual capture time. For tamper-proof tracked time use trackedSeconds from GET /api/sessions/:token.
Availability: timestamps are available for timelapses recorded from ~2026-05-26 onward. Older timelapses did not have timestamps collected and return count: 0 with an empty timestamps array (even though the session is complete with a playable video).
Timestamp precision: for current recordings these are true capture times — the moment each frame was grabbed. Older legacy clients report a server-side receive time instead, which trails the true capture by upload latency.
The timestamps array is what you forward to Hackatime as heartbeats. Your program should:
- Fetch
GET /api/sessions/:token/timingsfor the session. - Parse the
timestampsarray and map each ISO-8601 string to a Hackatime heartbeat (time= epoch seconds for that timestamp). - Set the editor to
Lookouton every heartbeat, so the recorded time is attributed to that editor in Hackatime. - Forward the heartbeats to Hackatime.
Because captures are ~60s apart, the heartbeats reconstruct the session's active intervals, and Hackatime's own gap handling collapses pauses — so you don't need to special-case the paused gaps yourself. Send each timelapse's heartbeats once (e.g. after the session is complete) to avoid duplicates.
const LOOKOUT = "https://lookout.hackclub.com";
// Hackatime is Wakatime-compatible; this is its bulk-heartbeat endpoint.
const HACKATIME = "https://hackatime.hackclub.com/api/hackatime/v1";
async function forwardTimelapseToHackatime(token: string, hackatimeApiKey: string) {
// 1. Pull the capture timestamps for this timelapse.
const res = await fetch(`${LOOKOUT}/api/sessions/${token}/timings`);
if (!res.ok) throw new Error(`timings request failed: ${res.status}`);
const { timestamps } = (await res.json()) as { timestamps: string[] };
if (timestamps.length === 0) return; // nothing recorded yet
// 2. Map each capture to a Hackatime heartbeat.
const heartbeats = timestamps.map((iso) => ({
type: "file",
entity: "timelapse", // what shows up as the "file" in Hackatime
category: "coding",
editor: "Lookout", // attribute the time to the Lookout editor
time: Date.parse(iso) / 1000, // epoch SECONDS (float), not millis
}));
// 3. Bulk-forward to Hackatime. Use the user's Hackatime API key.
const post = await fetch(`${HACKATIME}/users/current/heartbeats.bulk`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${hackatimeApiKey}`,
},
body: JSON.stringify(heartbeats),
});
if (!post.ok) throw new Error(`hackatime push failed: ${post.status}`);
}Notes:
timemust be epoch seconds (a float), not milliseconds —Date.parse(iso)returns millis, so divide by 1000.- The
editorfield is what drives the "Lookout" attribution; keepentity/projectstable per user or project so the time lands in one bucket. - Run this once per session (after
complete). If you must re-run, Hackatime de-dupes identical heartbeats bytime+entity, but don't rely on it — track which sessions you've already forwarded.
Note: The original screenshot images are only retained for 7 days after a session stops, after which the JPEGs are deleted from storage. The capture timestamps (and the compiled video and thumbnail) are kept.
When a user stops a recording, the official clients offer three choices:
keep recording, save as recorded, or review and cut first. If they
choose to edit, they mark wall-clock stretches to remove and Lookout drops
those minutes from the video, the /timings heartbeats, and
trackedSeconds — all from one stored list of {start, end} intervals.
You get this for free. It ships inside the recorder, so any program
that redirects users to the hosted recorder, or embeds
<LookoutRecorder>, already has it: the stop button opens the choice
dialog, and picking "Edit & save" opens the editor as a modal over your
page. No code change, no new version to adopt, nothing to call. Both
dialogs render into document.body, so they aren't constrained by the
width of the container you put the recorder in.
The exception is a program driving the headless useLookout() hook with
its own recording UI. That UI owns its own stop button, so it opts in by
passing actions.stop({ edit: true }) and rendering <TimelapseEditor>
(see the SDK reference).
What this means for your program:
- Nothing in your integration changes, and nothing you read ever changes
underneath you. Editing happens before the session reaches
complete: a session being edited staysstopped, and only flips tocompleteonce the user's cuts are baked in. So the first time you see a finished session, its video,trackedSeconds, and/timingsare final. There is no post-publication editing. - The lifecycle you observe is unchanged.
stopped → compiling → complete(orstopped → complete), exactly as before — an edit just means the session sits instoppeda little longer. The redirect hook still fires when the session completes, which is now also the moment the edits are in. - An abandoned edit can't strand a timelapse. The hold is a lease the
open editor renews, not a fixed deadline: editing takes as long as it
takes, and once nothing is renewing it (window closed, app quit) the
session publishes as recorded within about two minutes. It can delay
publication, never cancel it. If you poll, treat a slightly longer
stoppedexactly as you always have. - Cuts only ever shrink the numbers. A user cannot gain time by
editing — removing footage removes its credit. The pre-edit value is
available as
uncutTrackedSecondsand the intervals ascutsonGET /api/sessions/:token;?includeCut=trueon/timingsreturns the removed timestamps, if you want to audit or display them. - Cut footage is deleted immediately once the edited timelapse publishes — the point of a cut is usually "I didn't mean to record that."
- Opting out of the review step: add
?edit=falseto the hosted recorder URL, or pass<LookoutRecorder editing={false} />in the React SDK. Stopping is then a single click, as before. - Matching your brand: SDK embedders can pass
<LookoutProvider accentColor="#16a34a">to replace Lookout's blue on primary buttons, focus rings, and progress. See the SDK reference.
Every recording client reports a free-form client info string on each upload-url request (query param clientInfo). It's like an HTTP User-Agent but with Lookout-specific info — for telemetry and debugging. The server stores it opaquely (never parses it) and surfaces the session's first recorded value as clientInfo on GET /api/sessions/:token, the timings endpoint, and the internal admin endpoint.
Format (User-Agent–like): Lookout <Type> [(<EmbeddedApp>)]/<version> (<OS> <version>[; <Browser> <version>])
Lookout Desktop/0.2.6 (macOS 14.3)
Lookout Web (Fallout)/0.2.6 (macOS 14.3; Chrome 120.0)
Lookout Sdk (Stardance)/0.2.6 (Windows 10; Firefox 121.0)
How each client populates it:
- Desktop — type
Desktop, app version + OS detected natively. No browser/embedded-app. - Web (
@lookout/web) — typeWeb, version + browser/OS auto-detected. The embedded host program comes from the?app=URL param on the recorder link (e.g.…/session?token=…&app=Fallout), or theVITE_LOOKOUT_EMBEDDED_APPbuild env var. - React SDK (
@lookout/react) — typeSdk, version + browser/OS auto-detected. Pass the host program via theappNameprop on<LookoutProvider appName="Fallout">.
It's best-effort: a client omits anything it can't detect, the server truncates over 1024 chars, and a malformed value never fails an upload. clientInfo is null for sessions recorded before this existed or where no client sent one.
A timelapse recorded through Lookout usually has a home on your site — the
published page, the submission it belongs to. Set viewUrl and the desktop app
offers it as an Open in Program ↗ action on the session view, next to the
timelapse's name. Without it there's no way back to your site except finding it
by hand.
Unlike redirectUrl and panelUrl,
this one is mutable — the page you want to link to usually doesn't exist
when the session is created:
# After the user publishes, point at the real thing:
curl -X POST https://lookout.hackclub.com/api/internal/sessions/SESSION_ID/view-url \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key" \
-d '{"viewUrl": "https://yourprogram.example/timelapses/8f3a2c"}'Pass {"viewUrl": null} to clear it. You can also set it at creation if you
already know the URL. http(s), max 2048 chars.
It's returned on GET /api/sessions/:token as viewUrl, and it opens in the
user's real browser — this is your whole site, not the single-purpose form a
panel renders in-app. The two pair up nicely: a panel collects the details,
then view-url gives the user a way back to what it produced.
The button's label uses your program's display name from the registry, so set that (via the Lookout admin) if you want "Open in Lapse" rather than "Open in lapse".
Optional, and independent of everything else here. A program with no
panelUrlkeeps using the redirect hook exactly as before.
If your program needs information when a timelapse finishes — a title, a visibility choice, which project to credit the time to — the redirect hook sends the user to your site in a browser tab to collect it. A panel puts that same page in a sheet inside the desktop app instead, so there's no app switch. Your page, your design, your validation; Lookout supplies a rectangle.
As soon as the recording is saved — not when the video is ready. The sheet comes up over the compile progress, so the user answers your questions while the timelapse builds instead of watching a progress bar first. For a session the user chose to edit, "saved" means the moment they publish from the editor.
This has one consequence you must design for: when your panel loads, the
video usually does not exist yet. GET /api/sessions/:token will report
compiling (sometimes stopped) and videoUrl: null. So don't preview the
video in a panel, and don't block your form on it — take the answers, save
them, and let your own backend poll for complete afterwards if it needs the
file. trackedSeconds, /timings and the session's name are all available
immediately.
The redirect hook still fires on complete as always — but only for sessions
without a panel, since a panel is the same handoff done in-app and doing both
would send the user to a browser tab they already dealt with.
Pass panelUrl when you create the session, alongside (not instead of)
redirectUrl:
curl -X POST https://lookout.hackclub.com/api/internal/sessions \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key" \
-d '{
"metadata": {"userId": "user_123"},
"panelUrl": "https://yourprogram.example/publish/8f3a2c9e1b...",
"redirectUrl": "https://yourprogram.example/timelapses/42"
}'The URL is the credential. Make it unguessable and specific to this one
session — a random token in the path, the way sessionUrl works. Two reasons:
- A framed page is a third-party context, so your cookies will not reach it. WebKit (macOS/Linux) and WebView2 both partition or block third-party cookies, so a cookie-authenticated panel just renders a login screen. Authenticate off the URL instead.
- It means panels need no credential of their own, no pairing, and no handshake.
panelUrl must be https, except on localhost/127.0.0.1 so you can
develop your panel against a local server. Max 2048 chars, immutable after
creation.
Sending it to a Lookout that predates panels is safe: unknown fields on this
endpoint are dropped, not rejected, so the session is created without a panel
and the redirect hook covers the flow as before. You do not need to gate the
field on a version check. It's returned on GET /api/sessions/:token and
GET /api/sessions/:token/status, both token-authenticated.
The panel drives the sheet over postMessage. Send to window.parent:
// You have something worth looking at. The app's spinner comes off.
parent.postMessage({ type: "lookout:ready" }, "*");
// Grow/shrink the sheet to fit your content. No upper bound beyond the app window.
parent.postMessage({ type: "lookout:resize", height: document.body.scrollHeight }, "*");
// You're finished. The sheet closes and never re-offers itself.
parent.postMessage({ type: "lookout:done" }, "*");
// The user backed out inside your UI. Same as them closing the sheet.
parent.postMessage({ type: "lookout:cancel" }, "*");Send ready when your panel is usable, not when it loads — after the fetch
that fills your form, not on mount. Until it arrives the app covers the frame
with its own spinner, so load firing over an empty skeleton shows nobody a
skeleton. If you never send it the cover lifts on a timeout (a few seconds)
and whatever you have is what the user sees.
Your frame is live behind that cover, not hidden, so timers, animation frames and network requests all run normally while it is up.
Send resize whenever your content height changes — a multi-step form should
send it on every step, and the sheet springs between sizes. A panel that never
sends it just gets the 220px minimum.
Measure with Math.ceil(document.body.getBoundingClientRect().height), inside
requestAnimationFrame so layout has settled. The two obvious alternatives are
both wrong:
document.documentElement.scrollHeightis floored at the frame's current height, because<html>fills it. Your sheet would grow and never shrink — step 2 of a form could never be shorter than step 1.document.body.scrollHeightomits the last child's bottom margin, leaving the frame a few pixels short of its content and adding a scrollbar you didn't ask for.
There is no height cap: the sheet is as tall as you ask for. The app window is the only bound, and if you exceed it the sheet scrolls — which works, but a panel taller than someone's window is worth paging instead, the way the app's own flows do.
Anything else is ignored, and messages are accepted only from panelUrl's
exact origin.
- The frame is sandboxed (
allow-scripts allow-forms allow-same-origin allow-popups). Notably absent isallow-top-navigation: a panel cannot navigate the app window. Usetarget="_blank"(orlookout:doneplus yourredirectUrl) if you need to send someone to a real browser tab. - No access to Lookout internals. A cross-origin frame has its own JS realm and the desktop app's IPC is main-frame only, so there is nothing of Lookout's to reach from inside a panel.
- Your panel is attributed. The sheet shows your program name and the panel's origin above the frame, so it's never ambiguous whose UI it is.
- There is always a way out. If your panel fails to load, errors, or
hasn't loaded within 12 seconds, the app offers
redirectUrl(orpanelUrl) in the real browser instead. SetredirectUrltoo — it's the fallback. - Dismissal is not loss. If the user closes the sheet without your page
sending
lookout:done, the ask persists as a card on the session's page ("Program needs a few details") that reopens the panel.
lookout:done retires the card on that device, in that moment. It is not
enough on its own, because the user can just as easily finish on your website —
they hit the browser fallback, or come back to it tomorrow, or use a second
machine. The app can see inside neither your sheet nor your site, so it would
keep showing "needs a few details" for something already done.
So whenever you consider a session's panel satisfied — from the panel, from your own web UI, from a background job, doesn't matter — tell the server:
curl -X POST https://lookout.hackclub.com/api/internal/sessions/SESSION_ID/panel-resolved \
-H "X-API-Key: your-api-key"Takes no body. If your HTTP client always sets Content-Type: application/json
— most do — send {} rather than nothing: an empty body under a JSON content
type is rejected by the framework before the route is reached. The internal API
now treats that case as {} anyway, but a client sending {} works against
every version.
Idempotent, and a no-op success on a session that has no panel — so you can
call it unconditionally wherever you mark a timelapse published, without
branching. Once set, GET /api/sessions/:token reports
"panelResolved": true and every client stops offering the panel.
Two more things worth doing:
- Make your panel idempotent. The card reopens the same
panelUrl. If the session is already published, don't show the form again — postlookout:doneimmediately and let the sheet close. - Send
lookout:doneas well as calling the endpoint. The message is instant and local; the endpoint is authoritative and covers every other device. They're complements, not alternatives.
The app appends lookout_theme=light or lookout_theme=dark to your
panel URL's query when it loads the frame. Read it and colour accordingly — the
app follows the OS theme, so a dark-only panel is a dark slab on a light sheet.
It arrives in the URL rather than by message on purpose: a postMessage can
only reach you after the frame has loaded, by which point you have already
painted, and correcting it afterwards is the flash. Query params are in force
before your first byte of CSS.
Only the query is touched, so your origin — and everything keyed off it — is
unchanged. Existing params on your panelUrl are preserved.
Paint no background, from the very first paint. Set
html, body { background: transparent } in a stylesheet, not from script — a
useEffect (or anything else that runs after mount) is one paint too late, and
that paint is the flash. Watch for a framework default here: a global
html, body { background: … } rule, a min-height: 100vh (which floors your
height report so the sheet can only ever grow) or a display: flex on body
will all need overriding for this route. Let the sheet be the surface — your content then sits directly on the app's own
panel material instead of reading as a rectangle pasted into it. The frame is
transparent by default, so this is just about not filling it yourself.
The sheet supplies the outer padding-free surface, the rounded top corners, the grabber and the title row, so skip your own page chrome: no full-page background, no card wrapper around everything, no duplicate heading with your program's name. Style your controls to sit on a dark or light surface — the app follows the OS theme, so avoid hard-coding a background colour you then depend on for contrast.
- Never ask for credentials in a panel. No password fields, no "sign in to continue". The panel renders inside Lookout's window, so a login form there is indistinguishable from Lookout asking — that's a phishing shape, and panels that do it will be pulled from the registry. Authenticate via the session-scoped URL.
- Don't rely on cookies or persistent storage in the frame; assume both are partitioned or absent.
- Degrade gracefully. The same URL will sometimes be opened in a real browser tab (the fallback paths above), so it must work standalone too.
Lookout already asks the user to name their timelapse when they stop
recording, and stores it — GET /api/sessions/:token returns name. If your
panel opens with a "give your timelapse a title" field, it's asking for
something they typed a minute earlier. Read the session's name and prefill
or skip it.
Optional. Every program works in the desktop app's + menu with nothing but a
newSessionUrl— the app opens your site in the browser, you create a session and redirect tolookout://session/?token=…. Implement this section only if you want to remove that browser hop for repeat sessions.
The browser hop exists because only your backend can create sessions and only your website knows which user is asking. Device pairing keeps both facts true while paying the hop once per device instead of once per timelapse: the first start opens a consent page on your site (where the user is already logged in); every start after that is a single authenticated POST from the desktop app to your backend.
Lookout itself gains no user model from this. The device credential is minted by you, stored by the desktop app, presented only to you, and revocable by you. Lookout's only involvement is carrying two extra URLs in its public program registry and answering the app's verification GET at the end.
Two endpoints on your backend, registered in the Lookout program registry
as pairUrl and startUrl (both must be https; both must be set together
— ask the Lookout admin to set them on your program entry):
GET {pairUrl}?challenge=…&state=…&device=… consent page (browser)
POST {pairUrl} {code, verifier} code → device token exchange
DELETE {pairUrl} Authorization: Bearer <tok> revoke this device
POST {startUrl} Authorization: Bearer <tok> mint a session, return its token
-
The user picks your program in the desktop + menu. The app generates a random
verifier, and opens the OS browser at:{pairUrl}?challenge=<b64url(sha256(verifier))>&state=<nonce>&device=<label> -
Your consent page authenticates the user with whatever you already have (your session cookies — that's the whole point), shows one line of consent ("Link {device} to your account? It will be able to start Lookout sessions as you."), and on accept:
- stores
{user, challenge, code, expiresAt}wherecodeis a fresh single-use random string with a short TTL (≤ 5 minutes), - redirects to
lookout://pair?code=<code>&state=<state>— echostateback exactly; the app drops callbacks whose state matches nothing. Hardcode this redirect target. Do not accept a redirect URL as a request parameter, or your consent page becomes an open redirector.
- stores
-
The desktop app exchanges the code:
POST {pairUrl} Content-Type: application/json {"code": "<code>", "verifier": "<verifier>"}Verify the code is unexpired and unused, check
b64url(sha256(verifier)) == challenge, burn the code, and respond:{"deviceToken": "<opaque credential, ≤ 4096 chars>"}The PKCE check means a leaked/intercepted
code(the deep link travels through OS plumbing any app could register) is unredeemable without the verifier, which never left the desktop app. -
Show the device in the user's account settings on your site, with a revoke button. Treat the token like a password: store a hash, not the value.
POST {startUrl}
Authorization: Bearer <deviceToken>
Resolve the token to its user, create a Lookout session exactly the way your web flow does (your internal API key, your metadata, your redirectUrl), store the session token against the user as usual, and respond:
{"sessionToken": "<the 64-hex Lookout session token>"}Return 401 if the device token is revoked/expired/unknown — the app then
drops the credential and re-runs the consent flow. Any other failure makes
the app fall back to opening your newSessionUrl in the browser, so a broken
startUrl degrades to the old flow rather than a dead end.
The desktop app then verifies the token against Lookout before recording
(GET /api/sessions/:token must report program = your registry name and a
recordable status), so handing it a token from some other program or a
finished session doesn't work.
- Scope the credential to exactly one capability: "create a Lookout session for this user". It must not authorize anything else on your site.
- Make it revocable from your own device list.
DELETE {pairUrl}with the bearer token must also revoke (the app calls it on Settings → Linked Programs → Unlink, best-effort). - Rate limit
startUrlper device token as you see fit; the app calls it once per user gesture. - Expire pairing codes fast and make them single-use. Expiring device tokens
is fine too — the app re-pairs on
401at the cost of one browser hop.
import { createHash, randomBytes } from "node:crypto";
const b64url = (b: Buffer) => b.toString("base64url");
const sha256 = (s: string) => createHash("sha256").update(s).digest();
// GET /lookout/pair — consent page (behind your normal login)
app.get("/lookout/pair", requireLogin, (req, res) => {
const { challenge, state, device } = req.query;
res.render("lookout-consent", { challenge, state, device });
});
// The consent form's accept handler
app.post("/lookout/pair/accept", requireLogin, async (req, res) => {
const { challenge, state } = req.body;
const code = b64url(randomBytes(24));
await db.pairingCodes.insert({
code, challenge, userId: req.user.id,
device: req.body.device, expiresAt: minutesFromNow(5),
});
res.redirect(`lookout://pair?code=${code}&state=${encodeURIComponent(state)}`);
});
// POST /lookout/pair — code → device-token exchange (no cookies; the app calls this)
app.post("/lookout/pair", async (req, res) => {
const { code, verifier } = req.body;
const row = await db.pairingCodes.takeUnexpired(code); // atomically burn it
if (!row || b64url(sha256(verifier)) !== row.challenge)
return res.status(400).json({ error: "invalid code" });
const deviceToken = b64url(randomBytes(32));
await db.devices.insert({
tokenHash: sha256(deviceToken), userId: row.userId,
label: row.device, createdAt: new Date(),
});
res.json({ deviceToken });
});
// DELETE /lookout/pair — revoke (also expose this in your account settings UI)
app.delete("/lookout/pair", async (req, res) => {
await db.devices.deleteByTokenHash(sha256(bearerToken(req)));
res.status(204).end();
});
// POST /lookout/start — mint a session for a paired device
app.post("/lookout/start", async (req, res) => {
const device = await db.devices.findByTokenHash(sha256(bearerToken(req)));
if (!device) return res.status(401).json({ error: "unknown device" });
// Exactly your existing web flow, minus the browser:
const session = await lookout.createSession({
metadata: { userId: device.userId },
});
await db.sessions.insert({ userId: device.userId, token: session.token });
res.json({ sessionToken: session.token });
});| What | Trusted? | Why |
|---|---|---|
| Session creation | Yes — server-to-server with API key | Only your backend can create sessions |
| Capture timestamps | No — server records its own timestamp when GET /upload-url is called |
Client can't fake when a screenshot was taken |
| Upload verification | No — server calls HeadObject on R2 to verify the file exists |
Client can't claim uploads it didn't make |
| Time tracking | No — credit-mode sessions credit 60s per capture landing within ±30s of the server's streak anchor; bucket-mode is distinct minute buckets × 60. Mode is sticky per session and decided by the first upload. | Server-side anchor + window math; clients can't fake credits |
| Pause/resume | Partially trusted | Server auto-pauses after 10 min without uploads, auto-stops after 24 h |
| Rate limiting | Server-enforced | Max 10 upload-url + 20 confirm requests per minute per session, max 720 confirmed screenshots, max 4320 total upload-url requests per session |
This guide covers the integration flow and client responsibilities. For the complete HTTP API — every endpoint, request/response shape, query param, error code, and rate limit — see the server package's reference, which is the source of truth:
Now that you've seen the full flow: whatever you build or embed — the desktop app, the web recorder, the React SDK, or your own client — it must follow these. They are not optional polish; skipping them is the difference between "it works" and silent, unexplained failures that are miserable to debug.
-
Never fail silently — surface every error and warning. If getting an upload URL, the R2 PUT, or the confirm fails, either show it to the user or log it with enough detail to diagnose: the HTTP status, the endpoint, and the response body. The single worst outcome is a user watching a recording that isn't actually capturing while nothing reports an error. A stalled capture loop must be a loud, visible problem — not a quiet one people only discover when the timelapse comes out empty. The session token is a secret credential — it grants full control of the session, so never log it in full or expose it in user-facing errors; log a session ID or a truncated/redacted token instead.
-
Use credit mode — do not use bucket mode. Send
capturedAt(ISO-8601, stamped at the instant the frame is grabbed) on everyupload-urlrequest. Its presence on a session's first upload sticks it into credit mode for life; its absence drops the session into legacy bucket mode, which miscounts time whenever two captures land in the same minute (jitter, retries, late uploads). Bucket mode exists only for compatibility with old shipped binaries — new clients must not rely on it. See Tracking Modes. -
Retry every network leg, with reasonable backoff. Each of the three legs (get upload URL, R2 PUT, confirm) should retry on transient failure with exponential backoff (e.g. 2s → 4s → 8s, ~3 attempts). Treat
409(session paused/stopped) as terminal, not retriable. Confirmation is idempotent, so retrying it is safe. Don't hammer on failure, and don't give up after one try. See Upload resilience. -
Use the batch API when reading multiple sessions. For gallery/dashboard views, fetch with a single
POST /api/sessions/batch(up to 100 tokens) instead of N separateGET /api/sessions/:tokencalls — fewer round trips and one shared rate-limit bucket. -
Keep the client clock accurate.
capturedAtmust be within ±5 minutes of server time and strictly monotonic across confirmed captures (a failed upload's stamp may be reused by its retry), or the server rejects it with a400(captured_at_future,captured_at_too_old,captured_at_not_monotonic, …). A skewed device clock silently breaks credit-mode tracking. Everyupload-url/confirm response carriesserverTime— use it to detect skew, and schedule the next capture fromnextExpectedAt(never a fixedsetInterval). -
Honor
429and theRetry-Afterheader. Endpoints are rate-limited (upload-url 10/min, confirm 20/min, etc.) and a throttled response setsRetry-After: <seconds>. Back off for exactly that long rather than retrying blindly — blind retries dig you deeper into the limit. See Rate Limiting. -
Expect the server to pause/stop sessions on its own. A session with no uploads is auto-paused after 10 minutes and auto-stopped after 24 hours. So "captures stopped" can be the server's doing, not a bug in your client — poll
GET /api/sessions/:tokenand reconcile when the session changed state underneath you (see Session recovery).
- Sessions have a hard ceiling. Max 720 confirmed screenshots (~12 h at 60 s) and 4320 upload-url requests per session; screenshots must be
image/jpeg≤ 2 MB. Past these, requests return429/400— long recorders should expect the session to end. - Embed the permanent media URLs, never the presigned R2 URLs. Use
GET /api/media/:sessionId/video.mp4and…/thumbnail.jpg(stable, safe in<img>/<video>). Presigned R2 URLs expire (2 min upload, 1 h media). See Permanent Media Redirects. - CORS is allowlisted to
*.hackclub.com,localhost:*, andtauri://. A web client served from any other origin will be blocked — host apps embedding the recorder need to be on an allowed origin. - Report
clientInfotelemetry so a broken integration isn't blind to debug. See Client telemetry.