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.
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.
?to=Sarah&from=Alex&…What follows from that:
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) | |
|---|---|---|
| Privacy | Everything 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 length | Hundreds to thousands of characters; over roughly 18,000 Safari on iPhone will not open it. | Always short, about 40 characters. |
| Upload pictures | No. Pictures need an address of their own. | Yes, right in the builder: one per square, size capped (see limits). The builder shrinks them first. |
| Lifetime | Unlimited, 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 later | Load 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. |
| Account | Never needed. | Not needed. Optional (Google only) for several Valentines and premium. |
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.
GET /api/limits, explained in the API documentation.
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.
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).
Three ways to a finished link, all producing the same kind of link:
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.
| Parameter | What it sets | Example in the link |
|---|---|---|
lang | Language of every built-in text: en (default) or de. | ?lang=de |
to | Recipient’s name, shown big on the intro and on the reveal. | ?to=Sarah |
from | Your name, in the intro footer, the slider question and the reveal signature. | ?to=Sarah&from=Alex |
title | Intro headline, printed after “name,”. Formatting allowed. | ?title=you%20have%20*mail.* |
subtitle | The mock security notice under the headline. Formatting allowed. | ?subtitle=Quick%20check%20before%20the%20good%20part. |
select | Bold first line above the grid. Default “Select all squares with”. | ?select=Find%20every%20square%20with |
prompt | Smaller second line above the grid, always visible: what to find plus any hint. Formatting allowed. | ?prompt=a%20*cute*%20animal. |
help | Shown only when the recipient presses ?. Left out, it describes the challenge you set. | ?help=Only%20our%20holiday%20photos. |
cells | Which squares must be selected: all (default), any, or an exact list like 1,3,5. See squares. | ?cells=1%2C3%2C5 |
img1 … img9 | Picture for each square, as a direct https:// link. Empty squares keep the drawn heart. See pictures. | ?img1=https%3A%2F%2Fupload.wikimedia.org%2F… |
question | Headline above the slider. Formatting allowed. | ?question=Pizza%20tonight%2C%0A*and%20forever*%3F |
scale | Labels under the slider, comma-separated, left to right. Any number works. | ?scale=NO%2CMAYBE%2CYES |
readouts | Phrases shown while the slider moves from 0 % to 100 %, comma-separated. The last one only appears at 100 %. | ?readouts=nope%2Cmaybe%2Cobviously |
reveal | Final headline, printed after “name,”. Formatting allowed. | ?reveal=you%20are%20*stuck%20with%20me.* |
msg | The 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.
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.
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.
Squares are numbered 1–9, left to right and top to bottom. cells and img1…img9 use the same numbers.
cells=all: all nine must be selected. This is the joke: every square “contains a heart”.cells=any: any selection passes.cells=1,3,5: exactly these squares, no more and no fewer. Numbers outside 1–9 are ignored; if none are left, it falls back to all.Before you send a link, solve your own challenge once. prompt and help should match what cells expects.
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.
https://drive.google.com/thumbnail?id=FILE_ID&sz=w1000. The often recommended uc?export=view form looks fine in its own tab but stays blank inside another page, because Google sends it with cross-origin-resource-policy: same-site.i.imgur.com/…), S3, Cloudinary, or Netlify Drop for instant public URLs.http:// (blocked inside an https page).
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=''))"
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
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/.
Every value resolves in this order, and the first non-empty one wins:
URL parameter → config.js → built-in text of the language
config.jsFor 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,
};
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.
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.
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.
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.