APIVALENTINE CAPTCHA

Save a Valentine online: the storage API.

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.

The idea

A short link instead of a long one.

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.

DataThe same parameters as in the link, as JSON: {"to":"Sarah",…}
POST /sitesThe server stores them and returns the short link and an edit key.
Short linkWhen it is opened, the server puts the data into the unchanged page.

Security model

Hard limits

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.

LimitField in /api/limitsResponse when exceeded
Valentines in total (all users)limits.maxSites503 capacity
Valentines per account (free / premium)accounts.freeMaxSitesPerAccount, premium.maxSitesPerAccount409 account-limit
Pictures per Valentinelimits.maxImagesPerSite409 image-limit
Size per picturelimits.maxImageBytes413
Picture storage in totallimits.maxTotalImageBytes503 capacity
Data per Valentine (bytes, entries, characters per value)limits.maxDataBytes, limits.maxKeys, limits.maxValueChars400 / 413
Retention without account (choices, default, maximum)retention.options, retention.defaultDays, retention.maxDays400
Retention with premiumpremium.retentionOptions, premium.maxRetentionDays400
New Valentines per sender and daylimits.maxCreatesPerIpPerDay429 rate
Picture uploads per sender and daylimits.maxUploadsPerIpPerDay429 rate
New Valentines per day, all senders togetherlimits.maxCreatesPerDay, usage: today.creates503 daily-cap
Picture uploads per day, all senders togetherlimits.maxUploadsPerDay, usage: today.uploads503 daily-cap
Changes per day, all senders togetherlimits.maxUpdatesPerDay, usage: today.updates503 daily-cap
Picture views from storage per day (cache hits don't count)limits.maxImageReadsPerDay, usage: today.imageReads503 daily-cap
Picture import via {url}: timeoutfeatures.urlImport, limits.urlImportTimeoutMs422 / 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).

Endpoints

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.

MethodPathAuthPurposeResponse
GET/–Overview with links200 {service, docs, openapi, limits, hint}
GET/health–Liveness200 {ok, service, time}
GET/limits–Features, limits, retention, premium, login providers, addresses200 {service, project, features, limits, retention, premium, accounts, urls}
GET/openapi.json–OpenAPI 3 description200 OpenAPI document
Valentines
POST/sitesnone (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 itself200 {id, url, subpages, data, images, imageCount, retentionDays, createdAt, updatedAt, expiresAt, owned}; 404, 410 expired
PUT (also PATCH)/sites/:idkey or ownerReplace data (completely, no merging) and/or reset retentionDays200 the Valentine as above, without images
DELETE/sites/:idkey or ownerDelete it with its pictures200 {ok, deleted}
POST/sites/:id/verifykey or ownerCheck the key200 {ok:true, via:"key"|"owner"}; 401, 403
POST/sites/:id/rotate-keykey or ownerIssue a new key; the old one becomes invalid200 {id, editKey, note}
Pictures
GET/sites/:id/images–The Valentine’s pictures200 {items:[{id, url, contentType, size, createdAt}]}
POST/sites/:id/imageskey or ownerUpload 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/:imageIdkey or ownerDelete a picture200 {ok, deleted}
Accounts, premium (/auth, /me, /billing only if features.accounts is on, otherwise 404)
GET/auth/providers–Configured login providers200 {providers:["google"]}
GET/auth/login?provider=…&return=…–Start a login302 to the provider
GET/auth/callback/:provider–The provider’s redirect, for the provider only. Sets the cookie __Secure-vc_session302 to return#/auth/ok
GET/auth/mesession cookieSigned-in user, premium status, account limits200 {id, name, provider, premium:{active, until, available, manageable}, limits:{maxSites, retentionOptions, maxRetentionDays, bulk}}
POST/auth/logoutsession cookieEnd this session (revoked in D1, cookie cleared)200 {ok}
POST/auth/logout-allsession cookieEnd all sessions of this account200 {ok}
GET/me/sitessession cookieYour own Valentines200 {items:[{id, url, title, imageCount, updatedAt, expiresAt, expired}]}
POST/sites/:id/claimsession cookieAttach an existing Valentine to the account. Body {editKey}200 the Valentine; 403 wrong key, 409 other account or account-limit
POST/billing/checkoutsession cookieStart the Stripe subscription for premium (Checkout in subscription mode). Body {return}; success goes to <return>#/konto/premium-ok, cancel to <return>#/konto200 {url}
POST/billing/portalsession cookieOpen the Stripe customer portal (cancel, change payment details). Body {return}200 {url}
POST/billing/webhookStripe signaturePayment confirmation, for Stripe only200 {received}
POST/sites/bulksession cookie, premiumCreate several at once. Body {retentionDays, items:[{data}]}. Only when features.bulk is on.201 {items, count}; 402 not premium

The pages themselves

AddressContent
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.

Walkthrough with curl

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

1. Look up limits and retention (optional)

curl -sS "$API/limits"

2. Create a Valentine

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
  }'

3. The response: short link and key

{
  "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_…

4. Read

curl -sS "$API/sites/$ID"

Public, no key. Returns data, images and expiresAt. The key is never included.

5. Change

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.

6. Upload a picture

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 }

7. Use the picture URL in 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.

8. Check and change the retention

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.

9. Verify and rotate the key

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

10. Delete

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.

Errors

StatuscodeMeaning 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.
402premiumPremium-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).
409image-limitThe Valentine already has the maximum number of pictures. Delete one first.
409account-limitThe 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 / 504import-denied, import-not-found, import-not-image, import-timeoutPicture 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.
429rateDaily limit for new Valentines or uploads reached, or too many requests in a short time. Try again later or use the link mode.
503capacityValentine or picture storage is full. Use the link mode.
503daily-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.

For AI agents

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.

Recipe

  1. Ask: short link with storage (data on the server, deleted after the period) or long link without storage (private, nothing stored)? Do not decide silently for the user.
  2. Read the limits: 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).
  3. Build the data: the same parameters as in the link, as JSON, not URL-encoded. Leave uploaded pictures out for now, or keep public picture links.
  4. Create: POST /api/sites with {data, retentionDays}. Keep id, url and editKey from the response.
  5. Pictures: for each one, 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.
  6. Check: GET /api/sites/<id>, then open the short link or hand it to the user.
  7. Tell the user: the short link, the edit key and the expiry as a date (from expiresAt).

Rules

MCP server

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.

ToolArgumentsEndpoint
valentine_limits–GET /limits
valentine_createdata, retentionDaysPOST /sites
valentine_getidGET /sites/:id
valentine_updateid, editKey, data, retentionDaysPUT /sites/:id
valentine_deleteid, editKeyDELETE /sites/:id
valentine_add_imageid, editKey, urlPOST /sites/:id/images (URL import)
valentine_delete_imageid, editKey, imageIdDELETE /sites/:id/images/:imageId
valentine_rotate_keyid, editKeyPOST /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.

Accounts, premium and Stripe

An account is optional and only needed for premium or several Valentines. There are no passwords, only sign-in with Google.

More