Live Keepsakes
A reveal that happens on two screens at once: you hold up a code, they scan it, and your message bursts onto their phone — while your screen celebrates the moment it is opened. Unlike our downloadable keepsakes, a live keepsake is hosted: it lives in our database and ends after 30 days. Opens are anonymous — you see when, never who.
Checking your session…
Account features are not configured in this build of the site, so Live Keepsakes is unavailable. The Studio editor still works — design and download keepsakes without an account.
Sign in to create a live keepsake
Live keepsakes are hosted for you for 30 days, so they need the same free account that stores studio projects. Composing static keepsakes never requires one.
Tap to reveal the code
Waiting for the first scan…
The code opens — scanning works on any phone camera; no app and
no account needed on their side.
What a live keepsake is, mechanically
A live keepsake is one hosted row and two scripts that read it. The sender's screen shows a
code; the code encodes a link to the recipient page at /r/ with the keepsake's
id in the k query parameter — for a fictional id,
https://keepsakeqrcodes.com/r/?k=0f3c1e2a-7b4d-4e8f-9a6b-2c5d8e1f4a7b. Every id has the same canonical shape (36
characters), so every reveal link has the same length (69 characters) and every
live code is the same size: encoded at error-correction level H — the highest of the levels the
encoder offers (L, M, Q, H, in that order), and the level the studio's artistic rules require for
every styled keepsake — the link above is a version
8 symbol, 49 modules a side, or 57 once the 4-module quiet zone on each side
is counted. Nothing about the message is in the code.
The recipient's script reads the id from the link, asks the database for the public view of that row, and plays what comes back. The sender's script subscribes to the same row's events and reacts when the recipient opens it or sends love back. Both screens are driven by small state machines that are total functions: they never move backwards, and an event they do not expect leaves them where they are. Every computed figure in the sections below is produced by the engines at build time on fictional inputs, so the explanation cannot drift from the behavior.
The sender card: photo → dissolving → qr
The card has three stages — photo, dissolving and qr — and
two events: a tap, and the end of the dissolve animation. Walking the engine's rule on a fictional
session:
-
The card starts in
photo. A tap moves it todissolving. -
In
dissolving, the end of the dissolve moves it toqr. That stage is terminal: the code stays up. -
A tap while the dissolve is running leaves the card in
dissolving; a tap on the code leaves it inqr; a dissolve-ended signal that arrives before any tap leaves it inphoto. -
With reduced motion, a tap from
photogoes straight toqr. The dissolving stage is never entered.
Why the card starts on the photo — or on the accent-colored panel when no photo is chosen — rather than on the code: the tap is the sender's cue, and the engine makes it one-way. Once the code is showing it cannot be covered again; the controls under the card either copy the same link the code opens, start a new live keepsake, or end this one.
The recipient screen: loading → revealing → open, or gone, or error
The recipient machine has five stages — loading, revealing, open, gone and error — and four events: the row loaded, the
row is missing, the request failed, and the reveal animation finished. Walked on the same fictional
session:
-
From
loading, a successful load moves torevealing; when the animation finishes,revealingmoves toopen. -
From
loadingwith reduced motion, a successful load moves directly toopen— the message appears at once. -
From
loading, a missing row moves togone. The engine's definition of missing is "does not exist or has expired". -
From
loading, a failed request moves toerror— a network or parse failure, which the engine marks as retryable, unlike missing. -
A missing signal arriving mid-reveal leaves the screen in
revealing: once a reveal has started playing, nothing arriving late can take it away.
open, gone and error are terminal. At build time every one
of the 4 events was applied to each of the 3
terminal stages — 12 combinations — and the stage was unchanged every time.
How to read the two endings. gone is a verdict about the row: there is no row to
read, because it never existed, has already expired, or was ended early by its sender. error is a verdict about the attempt: the request did not complete, or what came
back could not be parsed. A reload can turn an error into a reveal; it cannot turn
a gone into one.
Reduced motion
Both machines take reduced motion as an input rather than patching it on afterwards. On a
device that asks for reduced motion, the sender's tap moves photo → qr directly, the recipient's load moves loading → open directly, and every burst's particle count collapses: reveal
0, open 0, love back 0 — 0 particles across all three. The painter
never starts its animation loop, because a burst of zero particles has nothing to draw.
What is removed is movement, not content. The headline, the message, the accent color and the love-back control arrive exactly as they would otherwise. A recipient screen that opens without animation is therefore not a broken reveal, and a sender screen whose status line updates without a burst is not a missed open.
Burst sizes, and what a burst is
There are three burst kinds, each with a fixed particle count: reveal (120 particles) plays on the recipient's screen as the message appears; open (45) plays on the sender's screen when the recipient opens the keepsake; love back (90) plays on the sender's screen when they send love back. The hierarchy is part of the design: the recipient's moment is the biggest, and on the sender's screen a love back bursts bigger than an open. A fictional sender session with one open and one love back draws 135 particles in total.
A burst is a list of particles spawned around an origin point, each with a position, a velocity, a remaining life, a size, a rotation, a spin, a shape and a color. The spawner takes its random source as an argument, so the build can run it with a scripted one: a hearts burst of the open size, spawned here with a fixed sequence, contains exactly 45 particles. The keepsake's accent color joins the shape's own palette at spawn time, so every burst carries the reveal's color.
Each occasion draws one shape, and each shape has its own motion vocabulary. The engine's own description of it, from the header of its physics table, is that "balloons and hearts are buoyant and drift upward, confetti tumbles down under gravity, sparkles and stars radiate and fade in place". The gravity column is the engine's constant — downward acceleration in px/s², negative meaning buoyant — and the lifetime range decides how long a particle lasts:
| Occasion | Headline on their screen | Shape | Gravity (px/s²) | Lifetime (ms) | Motion |
|---|---|---|---|---|---|
| A love note | Someone loves you | hearts | -35 | 1800–3200 | buoyant — drifts upward |
| A birthday surprise | Happy birthday! | balloons | -60 | 2200–3800 | buoyant — drifts upward |
| Congratulations | Congratulations! | confetti | 260 | 1600–2800 | tumbles down under gravity |
| A proposal | A question for you… | sparkles | 0 | 900–1800 | radiates and fades in place |
| For a friend | A friend is thinking of you | stars | 40 | 1400–2600 | radiates and fades in place |
Every frame advances each particle by the elapsed time and drops the dead: expired lifetimes, and anything that has fallen far below the canvas. Stepping the scripted hearts burst by one fictional 16 ms frame leaves 45 of 45 particles alive (the shortest hearts life is 1800 ms); stepping it by 3201 ms — one millisecond past the longest hearts life — leaves 0. Opacity is full for most of a particle's life and fades over its last 600 ms: at 300 ms remaining a particle is drawn at 0.5 opacity.
The draft and its limits
The composer's draft has three fields — the occasion, the message and the private label — and is checked against the database's own constraints before a row is created. The message can be at most 500 characters, counted as characters of the text as typed, so spaces and line breaks count. The private label can be at most 120 characters. The occasion must be one of the 5 the engine knows: A love note, A birthday surprise, Congratulations, A proposal, For a friend.
Validation returns every error at once, so a form can mark all offending fields in a single pass. Fictional drafts, with the engine's own messages:
- A message of spaces only: "Write the message your keepsake reveals." — whitespace is trimmed for the emptiness check, but not from the character count.
- A message of 501 characters: "The message can be at most 500 characters."
- A private label of 121 characters: "The private label can be at most 120 characters."
- An occasion the engine does not know ("anniversary"): "Choose one of the occasions."
- All three wrong at once: 3 errors, on the fields occasion, message, title.
- A label of exactly 120 characters and a message of exactly 500: 0 errors. The limits are inclusive.
The accent color is not part of the draft check. It is stored beside the message as a small style value and parsed on the way back out, as the next section shows.
What the code encodes, and what a scan reads back
The recipient page recovers the id from the k parameter and accepts only the canonical
id shape. Given ?k=0F3C1E2A-7B4D-4E8F-9A6B-2C5D8E1F4A7B it recovers 0f3c1e2a-7b4d-4e8f-9a6b-2c5d8e1f4a7b, lowercased; given ?k=not-a-reveal it recovers nothing (null), and the page shows its not-found
state instead of querying the database with garbage.
The anonymous read path returns only the occasion, the message and the style, and the parser is written as an allowlist: it copies exactly those fields and structurally cannot pass on a sender's account id, the private label, or any column a future migration might add. A fictional hosted row with 7 fields (id, user_id, title, occasion, message, style, expires_at) parses to a public view with 3: occasion, message, style. The private label is absent in that view. This is what "opens are anonymous — you see when, never who" rests on from the other direction: the recipient is shown nothing about the sender beyond what the sender wrote.
The style parse is total. A valid #rrggbb accent such as #7c3aed is kept; a malformed one (gold, say) falls back to the
occasion's own accent — #a16207 for a proposal — so a corrupt or foreign style
value can never wedge the reveal. A row whose message is empty is rejected as malformed (the parser returns null)
rather than played with a blank where the message should be.
Reading the sender screen
The verification badge. The code on the card goes through the same decode verification as every keepsake the studio produces: the drawn symbol is rasterized at 3 sizes (800, 1200, 1600 px) and decoded in your browser, and it is verified only when at least 2 of those rasters decode to an exact byte match against the reveal link. More than one raster is used because a single fixed size can fail through sampling resonance — a render that fails at one size can decode cleanly at the sizes either side of it — so one miss cannot flip the verdict on its own. The badge is a statement about the symbol: that the exact code drawn on your screen decodes to the exact link it should. It is not a statement about any camera, screen or room, which the studio does not model.
The status line. The sender's summary is computed from the event list. With no events it reads "Not opened yet"; after one open it reads "Opened once · last 9:02 PM"; after two opens and one love back it reads "Opened 2 times · last 9:14 PM · 1 love back". The time is the wall-clock time of the latest open in your own time zone (the fictional times here are shown in UTC). There are exactly two event kinds, open and love back; a fictional event row of kind "view" is rejected. Each event carries only its kind and its timestamp, which is the mechanical content of "opens are anonymous": there is no field for who.
The end date. The owner-side record the sender screen tracks carries one expiry
instant, parsed from the row's expires_at value — for the fictional row above, 2027-07-21T21:00:00.000Z. The engine's own note on that field is that after this instant the reveal link goes dark: a
recipient arriving later gets the missing verdict and the gone screen. A row whose expires_at does not parse is rejected
as malformed rather than shown with a guessed date. The page's existing statement stands as written:
hosted for 30 days, then it ends.
Where this stops
- Delivery is not modeled. An open event means the row was read by the recipient page; the engines record nothing about whether, or how, a person read the message.
- Hosted means hosted. A downloadable keepsake is a file that is independent of this site; a live keepsake is a row that ends. There is no file to keep afterwards, and the studio does not pretend otherwise.
- The photo stays on the sender's device. It is the card the tap dissolves, held in this browser tab as a local object and never part of the hosted row — the allowlisted public view above has no field it could travel in.
- Not a scan counter for printed codes. The methodology says static codes cannot report scans and that the studio adds no tracking to them. That statement is about static keepsakes. A live keepsake records open and love-back events because the sender's screen reacting is the product; the two are different objects, and the events say when, never who or where.
Common misreadings
- "The code contains my message." It contains the reveal link and nothing else; the message lives in the hosted row and is fetched when the link is opened.
- "A quiet recipient screen means the reveal failed." With reduced motion the recipient
lands on
opendirectly and every burst is 0 particles. The message is there; the movement is not. - "gone means something broke." It means there is no row: never created, already expired,
or ended by the sender. The broken-request stage is
error. - "The recipient can see my label." The public view has 3 fields — occasion, message, style — and the label is not one of them.
- "The counter is analytics." It is a list of open and love-back events for one hosted row during its hosted life, each carrying a kind and a time. Nothing in the engines records a person, a place or a device.
Questions while composing
Why does my screen show the photo (or a plain color panel) instead of the code?
That is the sender card's first stage. The card starts on your photo — or on the accent panel when you chose no photo — and a tap moves it to "dissolving", then to "qr" when the dissolve ends. On a device that asks for reduced motion the tap goes straight to "qr". Tap the card; the code is underneath.
What does the verification badge on the sender screen mean?
The code on your card is rasterized at 3 sizes (800, 1200, 1600 px) and decoded in your browser. It is verified only when at least 2 of those rasters decode to an exact byte match against the reveal link. The badge is a statement about the drawn symbol, not about any particular camera or room.
Their screen said the keepsake is gone. Is that an error?
No. "gone" is the recipient machine's verdict when the row is missing — the engine defines missing as "does not exist or has expired". A failed request is a different stage, "error", which a reload can change; "gone" does not change on reload, because there is no row to read.
Does the message limit count spaces and line breaks?
Yes. The limit is 500 characters of the message as typed, so spaces and line breaks count. Only the emptiness check trims: a message of spaces alone fails with "Write the message your keepsake reveals.", while a message of exactly 500 characters passes.
Can the recipient see my private label, or who I am?
No, by construction rather than by filtering. The anonymous read path returns only the occasion, the message and the style, and the parser copies exactly those 3 fields. A fictional hosted row with 7 fields, including the private label and the sender's account id, parses to a public view that contains only occasion, message, style.
Nothing burst on my screen when it was opened. Why?
Two possibilities, both by design. On a device that asks for reduced motion every burst collapses to 0 particles and the painter never starts, so the status line updates without animation. Otherwise an open draws 45 particles — the smallest of the three bursts, by the engine's hierarchy — and a love back draws 90.
What does "Opened 2 times · last 9:14 PM · 1 love back" mean?
It is the sender's status line computed from the event list: the number of open events, the wall-clock time of the latest one in your own time zone, and the love-back tally when there is one. With no events it reads "Not opened yet"; with a single open it reads "Opened once · last 9:02 PM" (the fictional times here are shown in UTC).
Does changing the accent color change the code?
No. The code encodes only the reveal link — https://keepsakeqrcodes.com/r/?k=0f3c1e2a-7b4d-4e8f-9a6b-2c5d8e1f4a7b for a fictional id — and never the message, the color or the label. The accent is stored as a style value beside the message; a valid #rrggbb value such as #7c3aed is kept, and anything malformed falls back to the occasion's own accent (#a16207 for a proposal), so a corrupt style can never stop a reveal from playing.
Composition and verification happen in your browser; the hosted part of a live keepsake is the one row both machines read. For how the studio's codes are produced and what it deliberately does not model, see the methodology page.