DOCUMENTATIONVALENTINE CAPTCHA

How the Valentine Captcha works.

A fake reCAPTCHA that turns into a Valentine: the recipient ticks “I’m not a robot”, selects the right squares, drags a slider all the way to yes, and is told they are your Valentine. This page explains how it is built, lists every setting you can put into a link, and shows how to host your own copy.

Using an AI assistant? Give it https://d-solve.de/projects/valentine-captcha/llms.txt: the complete instructions, every parameter, the storage API and the MCP server in one file.

The one idea

The link is the Valentine.

In link mode, the default, there is no server, no database and no account involved. The page at d-solve.de/projects/valentine-captcha/ is the same file for everyone. Names, wording, pictures and the challenge all travel inside the link, as query parameters, and the page reads them out of its own address bar when it opens. If you would rather have a short link and upload pictures, you can optionally save the Valentine online instead; that is a separate second mode.

Link builderYou edit the preview. Your browser turns the changes into a string.
The linkCarries every setting: ?to=Sarah&from=Alex&…
The Valentine pageReads the link and draws the Valentine. In link mode nothing is fetched or saved.

What follows from that:

Save online: a short link instead of a long one

Link mode has two drawbacks: a Valentine with many words gets a long link, and every picture has to be on the web already. So there is a second mode. In the link builder you can save the Valentine online and get a short address such as https://d-solve.de/valentine/k7m2p9q4r6t8. Looks, behaviour and parameters are the same. You choose per Valentine which mode to use.

Link mode (default)Save online (short link)
PrivacyEverything sits in the link, nothing on a server. Whoever has the link sees the content.The data lives on a d-solve.de server (Cloudflare). Whoever has the link sees the Valentine; search engines do not index it.
Link lengthHundreds to thousands of characters; over roughly 18,000 Safari on iPhone will not open it.Always short, about 40 characters.
Upload picturesNo. Pictures need an address of their own.Yes, right in the builder: one per square, size capped (see limits). The builder shrinks them first.
LifetimeUnlimited, for as long as the page on d-solve.de exists. There is nothing to delete.With a deadline you pick when saving, then deleted automatically. You can reset it at any time.
Edit laterLoad the link in the builder, change it, share the new link. The old link stays valid as it was.The short link stays the same, the content changes. That needs the edit key.
AccountNever needed.Not needed. Optional (Google only) for several Valentines and premium.

How to switch

  1. At the top of the sidebar in the link builder there is a switch: “Only in the link (private, nothing stored)” or “Save online (short link)”. Link mode is preselected, and your browser remembers your choice.
  2. In online mode, pick the retention and click “Save online”. You then get the short link to copy and open, and, once, the edit key.
  3. After that, changes are saved every 30 seconds; there is also “Save now”, “Change retention”, “Delete Valentine” and “New key”.
  4. Upload pictures from the picture editor or the card’s picture list. That works once the Valentine has been saved.
  5. Later, reopen a saved Valentine with “Load existing” using the short link (or id) and key. Without the key you can look at it and use it locally as the start of a new one.

If the service cannot be reached (for example on a copy without the backend), the builder says “Online storage is not available here”. Link mode then works as always.

What is stored, and where

The edit key

The edit key (vc_…) is shown exactly once. The server keeps only its hash, so it cannot be recovered. Copy it into a password manager or a note. The builder can remember it in this browser if you tick “Remember in this browser”. If you lose the key you can no longer change or delete the Valentine; it expires after its retention. Never share the key and never put it into a link.

For developers and AI

The builder talks to an open HTTP interface. If you want to create Valentines from a script or an AI assistant, endpoints, limits, error codes and curl examples are in the API documentation (machine-readable: openapi.json).

Get started

Three ways to a finished link, all producing the same kind of link:

  1. The link builder: click through a live preview and double-click any text or picture to change it in place.
  2. An AI assistant: the builder page has a prompt to copy into ChatGPT, Claude or Gemini. The assistant asks you for names, wording and picture links and hands you the finished link.
  3. By hand: append the parameters below to the address yourself.

All parameters

Every parameter is optional. Leave one out, or leave it empty, and the page uses its built-in text in the chosen language. Each example below is a working link. Open it to see the effect.

ParameterWhat it setsExample in the link
langLanguage of every built-in text: en (default) or de.?lang=de
toRecipient’s name, shown big on the intro and on the reveal.?to=Sarah
fromYour name, in the intro footer, the slider question and the reveal signature.?to=Sarah&from=Alex
titleIntro headline, printed after “name,”. Formatting allowed.?title=you%20have%20*mail.*
subtitleThe mock security notice under the headline. Formatting allowed.?subtitle=Quick%20check%20before%20the%20good%20part.
selectBold first line above the grid. Default “Select all squares with”.?select=Find%20every%20square%20with
promptSmaller second line above the grid, always visible: what to find plus any hint. Formatting allowed.?prompt=a%20*cute*%20animal.
helpShown only when the recipient presses ?. Left out, it describes the challenge you set.?help=Only%20our%20holiday%20photos.
cellsWhich squares must be selected: all (default), any, or an exact list like 1,3,5. See squares.?cells=1%2C3%2C5
img1 … img9Picture for each square, as a direct https:// link. Empty squares keep the drawn heart. See pictures.?img1=https%3A%2F%2Fupload.wikimedia.org%2F…
questionHeadline above the slider. Formatting allowed.?question=Pizza%20tonight%2C%0A*and%20forever*%3F
scaleLabels under the slider, comma-separated, left to right. Any number works.?scale=NO%2CMAYBE%2CYES
readoutsPhrases shown while the slider moves from 0 % to 100 %, comma-separated. The last one only appears at 100 %.?readouts=nope%2Cmaybe%2Cobviously
revealFinal headline, printed after “name,”. Formatting allowed.?reveal=you%20are%20*stuck%20with%20me.*
msgThe last line under the final headline.?msg=Dinner%20is%20at%208.

Join several parameters with &: ?lang=de&to=Sarah&from=Alex&cells=1%2C3%2C5.

Formatting

In title, subtitle, prompt, question and reveal, wrap a phrase in *asterisks* to set it in the italic accent colour, and write \n (or an encoded line break, %0A) for a new line. All other fields are shown exactly as written. HTML is never interpreted, so nobody can smuggle markup into a Valentine.

Encoding

Values must be URL-encoded: space %20, comma %2C, & %26, = %3D, ? %3F, # %23, line break %0A. Umlauts and emoji become UTF-8 escapes (ü → %C3%BC). A picture link is a value too, so its own ://, /, & and = must be encoded. In JavaScript, encodeURIComponent() does all of it. The link builder and the AI prompt take care of this for you.

scale and readouts are split at commas, so their entries can’t contain one. Older links that set prompt and hint separately still work; the two are joined into one line. name, by and message are accepted as aliases for to, from and msg.

Which squares are which

Squares are numbered 1–9, left to right and top to bottom. cells and img1…img9 use the same numbers.

Before you send a link, solve your own challenge once. prompt and help should match what cells expects.

Pictures in the grid

A photo on your phone has no address, so it has to be somewhere public first. The one rule: the link must point directly at the image file, start with https:// and work without a login. Test it in a private window. If you see the bare picture and nothing else, it works.

Pictures inside the link

A picture can also travel in the link as a data:image/webp;base64,… URI. Nothing is uploaded, but each picture adds thousands of characters. Links over roughly 18 000 characters do not open in Safari on iPhone at all: the recipient sees a white page. Use it for one small picture at most, shrunk to about 400×400 first:

# shrink to a 400×400 square, then print a URL-encoded data: URI
magick photo.jpg -resize 400x400^ -gravity center -extent 400x400 -quality 70 square.webp
python3 -c "import base64, urllib.parse; \
  d = base64.b64encode(open('square.webp','rb').read()).decode(); \
  print(urllib.parse.quote('data:image/webp;base64,' + d, safe=''))"

Every parameter in one link

German, both names, custom wording on every screen, three squares that count and three placeholder pictures:

https://d-solve.de/projects/valentine-captcha/?lang=de&to=Sarah&from=Alex&title=du%20hast%20*einen%20Gru%C3%9F*%20bekommen.&subtitle=Bitte%20kurz%20best%C3%A4tigen%2C%20dass%20du%20echt%20bist.&select=Finde%20alle%20Felder%20mit&prompt=einer%20gemeinsamen%20Erinnerung.&help=Nur%20die%20obere%20Reihe.&cells=1%2C2%2C3&img1=https%3A%2F%2Fdummyimage.com%2F600x400%2F000%2Ffff%26text%3Dbild-1&img2=https%3A%2F%2Fdummyimage.com%2F600x400%2F000%2Ffff%26text%3Dbild-2&img3=https%3A%2F%2Fdummyimage.com%2F600x400%2F000%2Ffff%26text%3Dbild-3&question=Auf%20einer%20Skala%20von%20*0%20bis%20ja*%2C%0Awillst%20du%20mein%20Valentinsschatz%20sein%3F&scale=NEIN%2CHMM%2CJA&readouts=absolut%20nicht%2Cna%20gut%2Cja%20tausendmal&reveal=du%20bist%20mein%20*Valentinsschatz.*&msg=F%C3%BCr%20immer%20deine%3F

Before you send it

Host your own copy

The project is a folder of static files: no build step, no backend, no dependencies, and no request to any third party while it runs (fonts are self-hosted). Copy the folder to any static host (Cloudflare Pages, Netlify Drop, GitHub Pages, Vercel, nginx, Caddy, Apache) and it works. The link builder detects where it is hosted and builds links for that copy.

The optional online mode is the one part with a backend: a Cloudflare Worker with a database and picture storage. It lives in the repository under workers/valentine-captcha-api; setup, limits (config.json) and tests are in its README.md. Without it the builder hides the online mode. To switch it off even though a worker exists, set window.VALENTINE_CLOUD = { apiBase: "" } in config.js.

valentine-captcha/
├── index.html          the Valentine page
├── app.js              reads the link, draws the four screens (plain DOM)
├── styles.css          all styles, including @font-face
├── config.js           optional fixed settings for one copy
├── fonts/              Instrument Serif + JetBrains Mono, SIL OFL
├── favicon.svg, og-image.png
├── demo.js, demo/      the example as one query string, and its short address
├── docs/               this page
├── api/                API documentation for the online mode (page for people and AI)
├── llms.txt            complete instructions for AI agents: parameters, API, MCP
└── customize/          the link builder: index.html, editor.js, cloud.js

Serve .js as text/javascript and .woff2 as font/woff2; nearly every server does by default. For a local test run python3 -m http.server 8000 in the folder and open http://localhost:8000/customize/.

Where a value comes from

Every value resolves in this order, and the first non-empty one wins:

URL parameter   →   config.js   →   built-in text of the language

Fixed settings in config.js

For one recipient with a short address, fill in config.js instead of using parameters. Lists are real arrays there and need no encoding:

window.VALENTINE_CONFIG = {
  language: "de",
  to: "Sarah",
  from: "Alex",
  message: "Dinner is at 8.",
  images: ["/photos/us.jpg", null, null, null, null, null, null, null, null],
  selectAllWith: "", challengePrompt: "our cat.", correctCells: [1, 4, 9],
  title: "", subtitle: "", help: "", question: "", reveal: "",
  scale: ["NO", "MAYBE", "YES"], readouts: null,
};

Many recipients, pretty URLs

Keep everything static and render only config.js per address from your own backend, for example with Express:

app.use("/v/:slug", express.static("valentine-captcha"));
app.get("/v/:slug/config.js", async (req, res) => {
  const v = await db.valentine.findBySlug(req.params.slug);
  if (!v) return res.status(404).end();
  res.type("text/javascript").send(
    "window.VALENTINE_CONFIG = " + JSON.stringify({ to: v.to, from: v.from, images: v.images }) + ";"
  );
});

Only this route gives you per-recipient share previews; the static page can’t personalise them, because crawlers don’t run JavaScript.

Another language

All built-in texts live in the STRINGS object in app.js. Copy the en block, translate it, let the new code through in resolveLang(), and add a button for it in customize/index.html and loadFromLink() in customize/editor.js. The fonts only cover the Latin alphabet.

Safety

Every value from the link is escaped before it reaches the page, and *asterisks* and line breaks are turned into markup only after escaping. Anyone who edits app.js should keep that order. A test link that must render literally: ?to=%3Cb%3ESarah%3C%2Fb%3E.

Credits

Pictures in the example, all from Wikimedia Commons: kitten by André Karwath (CC BY-SA 2.5), blobfish by Rachel Caauwe (CC BY-SA 3.0), red panda by Mathias Appel (CC0), naked mole-rat by Trisha M. Shears (public domain), quokka by May Wong (CC BY 2.0), aye-aye by Frank Vassen (CC BY 2.0), marabou stork by Charles J. Sharp (CC BY-SA 4.0), fennec fox by Anass Errihani (CC BY-SA 4.0), corgi puppy by Huoadg5888 (CC0).

Typefaces: Instrument Serif and JetBrains Mono, both under the SIL Open Font License 1.1.