Instead of one very long link, a Valentine can get a short one:
https://d-solve.de/valentine/<id>. The data then lives on the server and is deleted automatically
after a period you choose. This page describes the HTTP interface behind it, for people with curl
and for AI agents. The link builder uses the same interface.
AI agents: the complete instructions are at https://d-solve.de/projects/valentine-captcha/llms.txt. Machine-readable: https://d-solve.de/valentine/api/openapi.json (OpenAPI 3) and the MCP server https://d-solve.de/valentine/mcp.
Without storage the whole Valentine travels in the link (see the documentation). That is private and needs no server, but links get long and pictures have to live somewhere else. With the storage API you put the same parameters on the server as JSON and get a short address back.
{"to":"Sarah",…}https://d-solve.de/valentine/<id>. It looks and behaves exactly like the link version; the parameters are the same.https://d-solve.de/valentine/img/<file> and can be used directly as the value of img1 … img9.noindex). But anyone who knows the link can read what is in it, names and picture links included, also through GET /sites/<id>. Do not put in anything the recipient should not see.vc_…, 40 random characters) that is shown exactly once. Only its SHA-256 hash is stored. If you lose the key you can no longer change or delete the Valentine; it simply expires after its period. The key does not belong in a link or in the data.POST /sites/<id>/rotate-key issues a new one; the old one stops working immediately.*, but no credentials cross origins. Writes authenticated by the session cookie alone only count when the Origin header is in ALLOWED_ORIGINS (https://d-solve.de); otherwise the request is treated as not signed in. Requests with an edit key (Authorization: Bearer vc_…) need no session and stay open for AI agents.
The limits are cost caps. Their values live in exactly one place, workers/valentine-captcha-api/config.json,
and the service returns them live at
GET /api/limits
(with today's usage under today). No caller can exceed them. An agent fetches them before
creating anything. Daily quotas reset per UTC day.
| Limit | Field in /api/limits | Response when exceeded |
|---|---|---|
| Valentines in total (all users) | limits.maxSites | 503 capacity |
| Valentines per account (free / premium) | accounts.freeMaxSitesPerAccount, premium.maxSitesPerAccount | 409 account-limit |
| Pictures per Valentine | limits.maxImagesPerSite | 409 image-limit |
| Size per picture | limits.maxImageBytes | 413 |
| Picture storage in total | limits.maxTotalImageBytes | 503 capacity |
| Data per Valentine (bytes, entries, characters per value) | limits.maxDataBytes, limits.maxKeys, limits.maxValueChars | 400 / 413 |
| Retention without account (choices, default, maximum) | retention.options, retention.defaultDays, retention.maxDays | 400 |
| Retention with premium | premium.retentionOptions, premium.maxRetentionDays | 400 |
| New Valentines per sender and day | limits.maxCreatesPerIpPerDay | 429 rate |
| Picture uploads per sender and day | limits.maxUploadsPerIpPerDay | 429 rate |
| New Valentines per day, all senders together | limits.maxCreatesPerDay, usage: today.creates | 503 daily-cap |
| Picture uploads per day, all senders together | limits.maxUploadsPerDay, usage: today.uploads | 503 daily-cap |
| Changes per day, all senders together | limits.maxUpdatesPerDay, usage: today.updates | 503 daily-cap |
| Picture views from storage per day (cache hits don't count) | limits.maxImageReadsPerDay, usage: today.imageReads | 503 daily-cap |
Picture import via {url}: timeout | features.urlImport, limits.urlImportTimeoutMs | 422 / 502 |
| Session after sign-in (days, sliding) | accounts.sessionDays | – |
| Premium subscription (Stripe price) | premium.available, premium.stripePriceId | – |
The operator may also limit requests per sender and minute; that answers with 429 and
"code":"rate" too. Which functions are on is listed in features
(cloud, accounts, premium, bulk, urlImport).
API base: https://d-solve.de/valentine/api. All responses are JSON, timestamps are Unix seconds. Errors look like
{"error":"…","code":"…"}; code is only present for some. Requests with a body send
content-type: application/json. Auth means Authorization: Bearer <key>; the account endpoints use the session cookie instead.
OPTIONS (CORS preflight) answers 204.
data may only contain the Valentine parameters:
lang, to, from, title, subtitle, select,
prompt, help, cells, img1 … img9,
question, scale, readouts, reveal, msg
(reference), plus the old names hint, name, by and message.
Anything else is rejected with 400, so typos do not vanish silently. Checked on the way in:
lang is en or de, cells is all, any or a list of squares 1–9,
and every img… is a public http(s) picture address.
| Method | Path | Auth | Purpose | Response |
|---|---|---|---|---|
GET | / | – | Overview with links | 200 {service, docs, openapi, limits, hint} |
GET | /health | – | Liveness | 200 {ok, service, time} |
GET | /limits | – | Features, limits, retention, premium, login providers, addresses | 200 {service, project, features, limits, retention, premium, accounts, urls} |
GET | /openapi.json | – | OpenAPI 3 description | 200 OpenAPI document |
| Valentines | ||||
POST | /sites | none (or session cookie) | Create a Valentine. Body {data, retentionDays} | 201 the Valentine plus editKey (only here), apiUrl, note |
GET | /sites/:id | – | Read it, as public as the link itself | 200 {id, url, subpages, data, images, imageCount, retentionDays, createdAt, updatedAt, expiresAt, owned}; 404, 410 expired |
PUT (also PATCH) | /sites/:id | key or owner | Replace data (completely, no merging) and/or reset retentionDays | 200 the Valentine as above, without images |
DELETE | /sites/:id | key or owner | Delete it with its pictures | 200 {ok, deleted} |
POST | /sites/:id/verify | key or owner | Check the key | 200 {ok:true, via:"key"|"owner"}; 401, 403 |
POST | /sites/:id/rotate-key | key or owner | Issue a new key; the old one becomes invalid | 200 {id, editKey, note} |
| Pictures | ||||
GET | /sites/:id/images | – | The Valentine’s pictures | 200 {items:[{id, url, contentType, size, createdAt}]} |
POST | /sites/:id/images | key or owner | Upload a picture: raw bytes with content-type: image/*, or multipart/form-data with a file field, or JSON {"url":"https://…"} (the server fetches it) | 201 {id, url, contentType, size, imageCount, maxImages}; 409 image-limit, 413, 415 |
DELETE | /sites/:id/images/:imageId | key or owner | Delete a picture | 200 {ok, deleted} |
Accounts, premium (/auth, /me, /billing only if features.accounts is on, otherwise 404) | ||||
GET | /auth/providers | – | Configured login providers | 200 {providers:["google"]} |
GET | /auth/login?provider=…&return=… | – | Start a login | 302 to the provider |
GET | /auth/callback/:provider | – | The provider’s redirect, for the provider only. Sets the cookie __Secure-vc_session | 302 to return#/auth/ok |
GET | /auth/me | session cookie | Signed-in user, premium status, account limits | 200 {id, name, provider, premium:{active, until, available, manageable}, limits:{maxSites, retentionOptions, maxRetentionDays, bulk}} |
POST | /auth/logout | session cookie | End this session (revoked in D1, cookie cleared) | 200 {ok} |
POST | /auth/logout-all | session cookie | End all sessions of this account | 200 {ok} |
GET | /me/sites | session cookie | Your own Valentines | 200 {items:[{id, url, title, imageCount, updatedAt, expiresAt, expired}]} |
POST | /sites/:id/claim | session cookie | Attach an existing Valentine to the account. Body {editKey} | 200 the Valentine; 403 wrong key, 409 other account or account-limit |
POST | /billing/checkout | session cookie | Start the Stripe subscription for premium (Checkout in subscription mode). Body {return}; success goes to <return>#/konto/premium-ok, cancel to <return>#/konto | 200 {url} |
POST | /billing/portal | session cookie | Open the Stripe customer portal (cancel, change payment details). Body {return} | 200 {url} |
POST | /billing/webhook | Stripe signature | Payment confirmation, for Stripe only | 200 {received} |
POST | /sites/bulk | session cookie, premium | Create several at once. Body {retentionDays, items:[{data}]}. Only when features.bulk is on. | 201 {items, count}; 402 not premium |
| Address | Content |
|---|---|
https://d-solve.de/valentine/<id> | The Valentine (HTML) |
https://d-solve.de/valentine/img/<file> | An uploaded picture (file name from the url in the upload response) |
An expired or deleted Valentine shows a short notice page (404). https://d-solve.de/valentine/ without an id leads to the link builder.
data consists of exactly the link parameters (all parameters): a flat JSON object,
name → text, not URL-encoded (no %20). In title, subtitle, prompt,
question and reveal, *asterisks* and line breaks (\n in JSON) work as in the link.
Empty values are not stored; data: addresses are rejected (upload the picture instead). The examples assume:
API=https://d-solve.de/valentine/api
curl -sS "$API/limits"
curl -sS -X POST "$API/sites" \
-H "content-type: application/json" \
-d '{
"data": {
"lang": "en",
"to": "Sarah",
"from": "Alex",
"prompt": "a *cute* animal.",
"cells": "1,3,5",
"question": "On a scale of *0 to yes*,\nwill you be my valentine?",
"scale": "NO,MAYBE,YES",
"msg": "Dinner is at 8."
},
"retentionDays": 30
}'
{
"id": "k7m2p9q4r6t8",
"url": "https://d-solve.de/valentine/k7m2p9q4r6t8",
"subpages": {},
"data": { "lang": "en", "to": "Sarah", … },
"images": [], "imageCount": 0,
"retentionDays": 30,
"createdAt": 1790000000, "updatedAt": 1790000000, "expiresAt": 1792592000,
"owned": false,
"editKey": "vc_…40 characters…",
"apiUrl": "https://d-solve.de/valentine/api/sites/k7m2p9q4r6t8",
"note": "…"
}
Save the editKey now. It is never shown again. For the next steps:
ID=k7m2p9q4r6t8
KEY=vc_…
curl -sS "$API/sites/$ID"
Public, no key. Returns data, images and expiresAt. The key is never included.
curl -sS -X PUT "$API/sites/$ID" \
-H "authorization: Bearer $KEY" \
-H "content-type: application/json" \
-d '{"data": { "lang": "en", "to": "Sarah", "from": "Alex", "msg": "Dinner is at 9." }}'
data replaces everything stored so far. To change one value, read the Valentine first (step 4), edit the object and send it back in full.
Three ways, all with the key; number and size are capped (limits.maxImagesPerSite, limits.maxImageBytes); JPEG, PNG, WebP, GIF and AVIF are accepted.
# a) raw bytes
curl -sS -X POST "$API/sites/$ID/images" \
-H "authorization: Bearer $KEY" -H "content-type: image/jpeg" \
--data-binary @kitten.jpg
# b) form with a "file" field
curl -sS -X POST "$API/sites/$ID/images" \
-H "authorization: Bearer $KEY" -F "[email protected]"
# c) the server fetches a public picture itself (for AI agents)
curl -sS -X POST "$API/sites/$ID/images" \
-H "authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"url":"https://upload.wikimedia.org/wikipedia/commons/…/kitten.jpg"}'
{ "id": "…", "url": "https://d-solve.de/valentine/img/….jpg", "contentType": "image/jpeg", "size": 183422, "imageCount": 1, "maxImages": 9 }
data
The returned url is an ordinary picture link. Put it in any img1 … img9. The Valentine only
changes once you write the data:
curl -sS -X PUT "$API/sites/$ID" \
-H "authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"data": { …all values so far…, "img1": "https://d-solve.de/valentine/img/….jpg" }}'
Delete a picture: curl -sS -X DELETE "$API/sites/$ID/images/<imageId>" -H "authorization: Bearer $KEY". The picture id is in the upload response and in GET /sites/:id/images.
curl -sS "$API/sites/$ID" | grep -o '"expiresAt":[0-9]*' # Unix seconds, date -d @1792592000
curl -sS -X PUT "$API/sites/$ID" \
-H "authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"retentionDays": 90}'
retentionDays must be one of the allowed options (step 1) and restarts the period from now. data and retentionDays can be combined in one request.
curl -sS -X POST "$API/sites/$ID/verify" -H "authorization: Bearer $KEY"
curl -sS -X POST "$API/sites/$ID/rotate-key" -H "authorization: Bearer $KEY" # {"editKey":"vc_…"}; the old key stops working at once
curl -sS -X DELETE "$API/sites/$ID" -H "authorization: Bearer $KEY"
Deletes the Valentine and all its pictures for good. The short link then shows a notice page.
| Status | code | Meaning and what to do |
|---|---|---|
400 | – | Invalid request: not JSON, data empty or not an object, a parameter that does not exist, a value that is not text or too long, a data: address, a retentionDays that is not allowed, an invalid url for the picture import. The message in error names the spot. |
401 | – | Authorization: Bearer … missing, or the session (cookie) is no longer valid, or a cookie-authenticated write came from an origin outside ALLOWED_ORIGINS. |
402 | premium | Premium-only function (/sites/bulk). |
403 | – | Wrong key, or no access to this Valentine. Also: a return address that is not allowed. |
404 | – | No such Valentine or picture (any more), or the function is switched off (online storage, accounts, picture import, premium). |
409 | image-limit | The Valentine already has the maximum number of pictures. Delete one first. |
409 | account-limit | The account already owns the allowed number of Valentines (free 1, premium 25). |
409 | – | claim: the Valentine already belongs to another account. |
410 | – | The Valentine has expired and is being deleted. Create a new one. |
413 | – | Too large: data over 16 KiB, a request over 512 KiB, or a picture over the limit. |
415 | – | Not a supported picture (JPEG, PNG, WebP, GIF, AVIF only). |
422 / 504 | import-denied, import-not-found, import-not-image, import-timeout | Picture import via {"url":…} failed. The message names the reason and gives a tip for a direct image address. Google Drive share links are rewritten automatically. |
429 | rate | Daily limit for new Valentines or uploads reached, or too many requests in a short time. Try again later or use the link mode. |
503 | capacity | Valentine or picture storage is full. Use the link mode. |
503 | daily-cap: the service's daily quota is used up (cost cap). Try again tomorrow or use the long link without storage. | |
502, 500 | – | The picture could not be fetched from the url, a provider or Stripe is unreachable, or a server error. |
If you can make HTTP requests, you can create the Valentine yourself. If you cannot, give the user the finished
curl command or the long link. Everything about the content of the Valentine is in
llms.txt; this section is only about storing it. MCP clients use the MCP server instead of raw HTTP.
GET /api/limits. If features.cloud is false or the request fails, stay with the long link. Let the user pick a period from retention.options (default retention.defaultDays).POST /api/sites with {data, retentionDays}. Keep id, url and editKey from the response.POST /api/sites/<id>/images with {"url":"https://…"} (server import). Set the returned url as the value of img1 … img9 in data, then PUT /api/sites/<id> with the complete data.GET /api/sites/<id>, then open the short link or hand it to the user.expiresAt).data, into a request URL or to third parties. It belongs in the Authorization header.PUT with retentionDays.{url} import for pictures when you cannot send files. Only use addresses you actually found and checked.PUT with data replaces everything. GET first, edit, then PUT.429 or 503 fall back to the link mode and deliver the long link; tell the user briefly why.409 image-limit use fewer pictures or delete an old one; do not retry.
For chat clients that cannot make HTTP calls themselves (Claude, ChatGPT and other MCP clients) the same API is available as a remote
MCP server (Model Context Protocol): https://d-solve.de/valentine/mcp, transport Streamable HTTP, JSON-RPC 2.0 over
POST, no authentication, no sessions. Add it as a connector or custom integration; the server’s instructions point to
llms.txt.
Every tool is exactly one of the endpoints above, through the same handler, with the same validation, limits and error codes. The edit key is a
tool argument (editKey) and becomes the Authorization header internally.
| Tool | Arguments | Endpoint |
|---|---|---|
valentine_limits | – | GET /limits |
valentine_create | data, retentionDays | POST /sites |
valentine_get | id | GET /sites/:id |
valentine_update | id, editKey, data, retentionDays | PUT /sites/:id |
valentine_delete | id, editKey | DELETE /sites/:id |
valentine_add_image | id, editKey, url | POST /sites/:id/images (URL import) |
valentine_delete_image | id, editKey, imageId | DELETE /sites/:id/images/:imageId |
valentine_rotate_key | id, editKey | POST /sites/:id/rotate-key |
Results are JSON, as text and as structuredContent. An API error becomes a tool result with isError: true and the fields
status, error and code. Try it with curl:
curl -sS -X POST https://d-solve.de/valentine/mcp -H "content-type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Accounts, premium and bulk creation are not exposed as tools; agents work with the edit key. GET /valentine/mcp answers
405 with a short description, and the OpenAPI document names the server under x-mcp.
An account is optional and only needed for premium or several Valentines. There are no passwords, only sign-in with Google.
GET /api/auth/providers lists the available providers (currently Google only, via accounts.providers). GET /api/auth/login?provider=google&return=<address> redirects to Google (OAuth 2.1 Authorization Code with PKCE; the worker is the confidential client, no token reaches the browser). The return address must be on an allowed origin (https://d-solve.de). After the callback the worker sets the HttpOnly cookie __Secure-vc_session (Secure, SameSite=Lax, Path=/valentine; 30 days sliding, 90 at most) and redirects to <return>#/auth/ok. The session is stored in D1 only as a SHA-256 hash. End it with POST /api/auth/logout (this one) or POST /api/auth/logout-all (all).GET /api/auth/me shows name, premium status and this account’s limits. GET /api/me/sites lists your own Valentines.POST /api/sites/<id>/claim and {"editKey":"…"}; the key stays valid as well. How many Valentines an account may hold and which periods apply is in /api/auth/me under limits.POST /api/billing/checkout with {"return":"…"} returns a Stripe Checkout address for a subscription (price from premium.stripePriceId, monthly or yearly). Afterwards the browser goes to <return>#/konto/premium-ok, on cancel to <return>#/konto. Premium lasts until the end of the paid period plus 3 days grace and ends when the subscription is cancelled. To cancel or change payment details, POST /api/billing/portal with {"return":"…"} returns the address of the Stripe customer portal. Activation arrives through the Stripe webhook, not through the return address. Only offered when premium.available in /api/limits is true.POST /api/sites/bulk creates up to 50 Valentines at once for premium accounts (for a whole class or team), as long as features.bulk is on, which it is for the Valentine service.workers/valentine-captcha-api/ in the repository.