Statt eines langen Links bekommt die Bewerbungsseite auf Wunsch einen kurzen:
https://d-solve.de/apartment-search/<id>. Die Daten liegen dann auf dem Server und werden nach
einer von dir gewählten Frist automatisch gelöscht. Diese Seite beschreibt die HTTP-Schnittstelle dahinter, für
Menschen mit curl und für KI-Agenten. Der Baukasten nutzt dieselbe Schnittstelle.
KI-Agenten: Die vollständige Anleitung steht unter https://d-solve.de/projects/apartment-search/llms.txt. Maschinenlesbar: https://d-solve.de/apartment-search/api/openapi.json (OpenAPI 3) und der MCP-Server https://d-solve.de/apartment-search/mcp.
Ohne Speicherung steckt die ganze Seite im Link (siehe Dokumentation). Das ist privat und braucht keinen Server, aber die Links werden lang, Fotos müssen woanders liegen und der QR-Code wird dicht. Mit der Speicher-API legst du dieselben Parameter als JSON auf dem Server ab und bekommst eine kurze Adresse zurück.
{"loc":"Mainz",…}https://d-solve.de/apartment-search/<id>. Dazu der Aushang unter /<id>/aushang/ und die Mietunterlagen unter /<id>/mietunterlagen/. Die Seite sieht aus wie die Link-Variante; ihr Aufbau und alle Parameter sind gleich.https://d-solve.de/apartment-search/img/<datei> zurück und sind direkt als Wert für photo, p1photo usw. nutzbar.# der Adresse und erreichen den Server nicht.noindex). Was auf der Seite steht, ist aber für jeden lesbar, der den Link kennt, auch E-Mail und Telefonnummer, und auch über GET /sites/<id>. Schreibe nichts hinein, was nicht jeder Empfänger sehen soll.as_…, 40 zufällige Zeichen) und zeigt ihn genau einmal. Gespeichert wird nur sein SHA-256-Hash. Wer den Schlüssel verliert, kann die Seite nicht mehr ändern oder löschen; sie läuft dann nach der Frist ab. Der Schlüssel gehört weder in einen Link noch in die Daten der Seite.POST /sites/<id>/rotate-key erzeugt einen neuen; der alte gilt sofort nicht mehr.*, es gehen aber keine Anmeldedaten ursprungsübergreifend mit. Schreibzugriffe, die nur das Session-Cookie beglaubigt, zählen nur, wenn der Origin-Header in ALLOWED_ORIGINS steht (https://d-solve.de); sonst gilt die Anfrage als nicht angemeldet. Anfragen mit Bearbeitungsschlüssel (Authorization: Bearer as_…) brauchen keine Session und stehen KI-Agenten weiter offen.
Die Grenzen sind Kostenbremsen. Die Werte stehen an genau einer Stelle, in
workers/apartment-search-api/config.json, und der Dienst liefert sie live unter
GET /api/limits
(dort unter today auch der Verbrauch des Tages). Kein Aufrufer kann sie überschreiten.
Ein Agent ruft sie vor dem Anlegen ab. Tageskontingente gelten je UTC-Tag.
| Grenze | Feld in /api/limits | Antwort bei Überschreitung |
|---|---|---|
| Seiten insgesamt (alle Nutzer) | limits.maxSites | 503 capacity |
| Seiten je Konto (kostenlos / Premium) | accounts.freeMaxSitesPerAccount, premium.maxSitesPerAccount | 409 account-limit |
| Fotos je Seite | limits.maxImagesPerSite | 409 image-limit |
| Größe je Foto | limits.maxImageBytes | 413 |
| Bildspeicher insgesamt | limits.maxTotalImageBytes | 503 capacity |
| Daten je Seite (Bytes, Einträge, Zeichen je Wert) | limits.maxDataBytes, limits.maxKeys, limits.maxValueChars | 400 / 413 |
| Speicherfrist ohne Konto (Auswahl, Standard, Höchstwert) | retention.options, retention.defaultDays, retention.maxDays | 400 |
| Speicherfrist mit Premium | premium.retentionOptions, premium.maxRetentionDays | 400 |
| Neue Seiten je Absender und Tag | limits.maxCreatesPerIpPerDay | 429 rate |
| Foto-Uploads je Absender und Tag | limits.maxUploadsPerIpPerDay | 429 rate |
| Neue Seiten je Tag, alle zusammen | limits.maxCreatesPerDay, Verbrauch: today.creates | 503 daily-cap |
| Foto-Uploads je Tag, alle zusammen | limits.maxUploadsPerDay, Verbrauch: today.uploads | 503 daily-cap |
| Änderungen je Tag, alle zusammen | limits.maxUpdatesPerDay, Verbrauch: today.updates | 503 daily-cap |
| Bildabrufe aus dem Speicher je Tag (Cache-Treffer zählen nicht) | limits.maxImageReadsPerDay, Verbrauch: today.imageReads | 503 daily-cap |
Bild-Import per {url}: Zeitlimit | features.urlImport, limits.urlImportTimeoutMs | 422 / 502 |
| Sitzung nach Anmeldung (Tage, gleitend) | accounts.sessionDays | – |
| Premium-Abo (Stripe-Preis) | premium.available, premium.stripePriceId | – |
Außerdem begrenzt der Betreiber optional die Anfragen je Absender und Minute. Auch das meldet sich mit
429 und "code":"rate". Ob Funktionen aktiv sind, steht in features
(cloud, accounts, premium, bulk, urlImport).
API-Basis: https://d-solve.de/apartment-search/api. Alle Antworten sind JSON mit
content-type: application/json, Zeitangaben sind Unix-Sekunden. Fehler haben die Form
{"error":"…","code":"…"}; code gibt es nur bei einigen. Anfragen mit Inhalt senden
content-type: application/json. Auth heißt Authorization: Bearer <Schlüssel>; die Konto-Endpunkte nutzen stattdessen das Session-Cookie.
OPTIONS (CORS-Vorabfrage) antwortet mit 204.
| Methode | Pfad | Auth | Zweck | Antwort |
|---|---|---|---|---|
| Allgemein | ||||
GET | / | – | Übersicht mit Links | 200 {service, docs, openapi, limits, hint} |
GET | /health | – | Lebenszeichen | 200 {ok, service, time} |
GET | /limits | – | Funktionen, Grenzen, Fristen, Premium, Login-Anbieter, Adressen | 200 {service, project, features, limits, retention, premium, accounts, urls} |
GET | /openapi.json | – | OpenAPI-3-Beschreibung | 200 OpenAPI-Dokument |
| Seiten | ||||
POST | /sites | keine (oder Session-Cookie) | Seite anlegen. Body {data, retentionDays} | 201 Seite plus editKey (nur hier), apiUrl, note |
GET | /sites/:id | – | Seite lesen, wie der Link selbst öffentlich | 200 {id, url, subpages, data, images, imageCount, retentionDays, createdAt, updatedAt, expiresAt, owned}; 404, 410 abgelaufen |
PUT (auch PATCH) | /sites/:id | Schlüssel oder Besitzer | data ersetzen (komplett, kein Zusammenführen) und/oder retentionDays neu setzen | 200 Seite wie oben, ohne images |
DELETE | /sites/:id | Schlüssel oder Besitzer | Seite samt Fotos löschen | 200 {ok, deleted} |
POST | /sites/:id/verify | Schlüssel oder Besitzer | Schlüssel prüfen | 200 {ok:true, via:"key"|"owner"}; 401, 403 |
POST | /sites/:id/rotate-key | Schlüssel oder Besitzer | Neuen Schlüssel erzeugen; der alte wird ungültig | 200 {id, editKey, note} |
| Fotos | ||||
GET | /sites/:id/images | – | Fotos der Seite | 200 {items:[{id, url, contentType, size, createdAt}]} |
POST | /sites/:id/images | Schlüssel oder Besitzer | Foto hochladen: Rohbytes mit content-type: image/*, oder multipart/form-data mit Feld file, oder JSON {"url":"https://…"} (der Server lädt das Bild) | 201 {id, url, contentType, size, imageCount, maxImages}; 409 image-limit, 413, 415 |
DELETE | /sites/:id/images/:imageId | Schlüssel oder Besitzer | Foto löschen | 200 {ok, deleted} |
Konto, Premium (/auth, /me, /billing nur wenn features.accounts an ist, sonst 404) | ||||
GET | /auth/providers | – | Konfigurierte Anbieter | 200 {providers:["google"]} |
GET | /auth/login?provider=…&return=… | – | Anmeldung starten | 302 zum Anbieter |
GET | /auth/callback/:anbieter | – | Rücksprung des Anbieters, nur für den Anbieter. Setzt das Cookie __Secure-as_session | 302 zu return#/auth/ok |
GET | /auth/me | Session-Cookie | Angemeldeter Nutzer, Premium-Status, Kontolimits | 200 {id, name, provider, premium:{active, until, available, manageable}, limits:{maxSites, retentionOptions, maxRetentionDays, bulk}} |
POST | /auth/logout | Session-Cookie | Diese Sitzung beenden (in D1 widerrufen, Cookie löschen) | 200 {ok} |
POST | /auth/logout-all | Session-Cookie | Alle Sitzungen dieses Kontos beenden | 200 {ok} |
GET | /me/sites | Session-Cookie | Eigene Seiten | 200 {items:[{id, url, title, imageCount, updatedAt, expiresAt, expired}]} |
POST | /sites/:id/claim | Session-Cookie | Vorhandene Seite dem Konto zuordnen. Body {editKey} | 200 Seite; 403 Schlüssel falsch, 409 anderes Konto oder account-limit |
POST | /billing/checkout | Session-Cookie | Stripe-Abo für Premium starten (Checkout im Modus subscription). Body {return}; Erfolg führt zu <return>#/konto/premium-ok, Abbruch zu <return>#/konto | 200 {url} |
POST | /billing/portal | Session-Cookie | Stripe-Kundenportal öffnen (kündigen, Zahlungsdaten ändern). Body {return} | 200 {url} |
POST | /billing/webhook | Stripe-Signatur | Zahlungsbestätigung, nur für Stripe | 200 {received} |
POST | /sites/bulk | Session-Cookie, Premium | Mehrere Seiten auf einmal. Body {retentionDays, items:[{data}]}. Nur wenn features.bulk an ist. | 201 {items, count}; 402 kein Premium |
| Adresse | Inhalt |
|---|---|
https://d-solve.de/apartment-search/<id> | Die Bewerbungsseite (HTML) |
https://d-solve.de/apartment-search/<id>/aushang/ | Aushang mit denselben Daten |
https://d-solve.de/apartment-search/<id>/mietunterlagen/ | Selbstauskunft und Unterlagen mit denselben Daten |
https://d-solve.de/apartment-search/img/<datei> | Hochgeladenes Foto (Dateiname aus der url der Upload-Antwort) |
Eine abgelaufene oder gelöschte Seite zeigt eine kurze Hinweisseite (404). https://d-solve.de/apartment-search/ ohne ID führt zum Baukasten.
Der data-Teil besteht aus genau den Link-Parametern der Seite
(alle Parameter): ein flaches JSON-Objekt Name → Text, unkodiert
(kein %20). Mehrsprachige Texte haben das Suffix im Namen (headline.de). Leere Werte werden nicht
gespeichert, data:-Adressen werden abgelehnt (Bilder hochladen). Für die Beispiele:
API=https://d-solve.de/apartment-search/api
curl -sS "$API/limits"
curl -sS -X POST "$API/sites" \
-H "content-type: application/json" \
-d '{
"data": {
"langs": "de",
"loc": "Mainz",
"names": "Familie Beispiel",
"headline": "Wir suchen ein Zuhause in Mainz",
"subline": "Zwei Erwachsene und ein Kind suchen eine 3-Zimmer-Wohnung.",
"email": "[email protected]",
"phone": "-"
},
"retentionDays": 30
}'
{
"id": "k7m2p9q4r6t8",
"url": "https://d-solve.de/apartment-search/k7m2p9q4r6t8",
"subpages": {
"aushang": "https://d-solve.de/apartment-search/k7m2p9q4r6t8/aushang/",
"mietunterlagen": "https://d-solve.de/apartment-search/k7m2p9q4r6t8/mietunterlagen/"
},
"data": { "langs": "de", "loc": "Mainz", … },
"images": [], "imageCount": 0,
"retentionDays": 30,
"createdAt": 1790000000, "updatedAt": 1790000000, "expiresAt": 1792592000,
"owned": false,
"editKey": "as_…40 Zeichen…",
"apiUrl": "https://d-solve.de/apartment-search/api/sites/k7m2p9q4r6t8",
"note": "Der Bearbeitungsschlüssel wird nur dieses eine Mal angezeigt. …"
}
Den editKey jetzt sichern. Er kommt nie wieder. Für die nächsten Schritte:
ID=k7m2p9q4r6t8
KEY=as_…
curl -sS "$API/sites/$ID"
Öffentlich, ohne Schlüssel. Liefert data, images, expiresAt. Der Schlüssel ist nie enthalten.
curl -sS -X PUT "$API/sites/$ID" \
-H "authorization: Bearer $KEY" \
-H "content-type: application/json" \
-d '{"data": { "langs": "de", "loc": "Mainz", "headline": "Neue Überschrift", "email": "[email protected]" }}'
data ersetzt den gesamten bisherigen Inhalt. Wer nur einen Wert ändern will, liest die Seite zuerst (Schritt 4), ändert das Objekt und schickt es vollständig zurück.
Drei Wege, alle mit dem Schlüssel; Anzahl und Größe begrenzt (limits.maxImagesPerSite, limits.maxImageBytes); erlaubt sind JPEG, PNG, WebP, GIF und AVIF.
# a) Rohbytes
curl -sS -X POST "$API/sites/$ID/images" \
-H "authorization: Bearer $KEY" -H "content-type: image/jpeg" \
--data-binary @wohnzimmer.jpg
# b) Formular mit Feld "file"
curl -sS -X POST "$API/sites/$ID/images" \
-H "authorization: Bearer $KEY" -F "[email protected]"
# c) Der Server lädt ein öffentliches Bild selbst (für KI-Agenten)
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/…/bild.jpg"}'
{ "id": "…", "url": "https://d-solve.de/apartment-search/img/…jpg", "contentType": "image/jpeg", "size": 183422, "imageCount": 1, "maxImages": 20 }
data verwenden
Die zurückgegebene url ist ein normaler Bild-Link und geht in jeden Foto-Parameter
(photo, p1photo, p2photo, …; mehrere pro Kachel mit | getrennt).
Die Seite ändert sich erst, wenn du die Daten schreibst:
curl -sS -X PUT "$API/sites/$ID" \
-H "authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"data": { …alle bisherigen Werte…, "photo": "https://d-solve.de/apartment-search/img/….jpg" }}'
Fotos löschen: curl -sS -X DELETE "$API/sites/$ID/images/<imageId>" -H "authorization: Bearer $KEY". Die Bild-ID steht in der Upload-Antwort und in GET /sites/:id/images.
curl -sS "$API/sites/$ID" | grep -o '"expiresAt":[0-9]*' # Unix-Sekunden, date -d @1792592000
curl -sS -X PUT "$API/sites/$ID" \
-H "authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"retentionDays": 90}'
retentionDays muss eine der erlaubten Optionen sein (Schritt 1) und setzt die Frist neu ab jetzt. data und retentionDays lassen sich in einer Anfrage kombinieren.
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":"as_…"}; der alte ist sofort ungültig
curl -sS -X DELETE "$API/sites/$ID" -H "authorization: Bearer $KEY"
Löscht die Seite und alle ihre Fotos endgültig. Der Kurzlink zeigt danach eine Hinweisseite.
| Status | code | Bedeutung und was zu tun ist |
|---|---|---|
400 | – | Anfrage ungültig: kein JSON, data leer oder kein Objekt, unzulässiger Parametername, Wert kein Text oder zu lang, data:-Adresse, retentionDays nicht erlaubt, url beim Bild-Import ungültig. Die Meldung in error nennt die Stelle. |
401 | – | Authorization: Bearer … fehlt, oder die Session (Cookie) ist nicht (mehr) gültig, oder ein Cookie-Schreibzugriff kam von einem Ursprung außerhalb von ALLOWED_ORIGINS. |
402 | premium | Funktion nur mit Premium (/sites/bulk). |
403 | – | Schlüssel falsch, oder kein Zugriff auf diese Seite. Auch: return-Adresse nicht erlaubt. |
404 | – | Seite oder Foto gibt es nicht (oder nicht mehr), oder die Funktion ist abgeschaltet (Online-Speicherung, Konten, Bild-Import, Premium). |
409 | image-limit | Die Seite hat schon die Höchstzahl an Fotos. Erst ein Foto löschen. |
409 | account-limit | Das Konto besitzt schon die erlaubte Zahl an Seiten (frei 1, Premium 25). |
409 | – | claim: Die Seite gehört schon einem anderen Konto. |
410 | – | Die Seite ist abgelaufen und wird gelöscht. Neu anlegen. |
413 | – | Zu groß: Daten über 64 KiB, Anfrage über 512 KiB oder Foto über der Grenze. |
415 | – | Kein unterstütztes Bild (nur JPEG, PNG, WebP, GIF, AVIF). |
422 / 504 | import-denied, import-not-found, import-not-image, import-timeout | Bild-Import per {"url":…} gescheitert. Die Meldung nennt den Grund und einen Tipp für eine direkte Bildadresse. Google-Drive-Freigabelinks werden automatisch umgeschrieben. |
429 | rate | Tageslimit für neue Seiten oder Uploads erreicht, oder zu viele Anfragen in kurzer Zeit. Später erneut versuchen oder den Link-Modus nutzen. |
503 | capacity | Seiten- oder Bildspeicher voll. Link-Modus nutzen. |
503 | daily-cap: Tageskontingent des Dienstes aufgebraucht (Kostenbremse). Morgen wieder versuchen oder den langen Link ohne Speicherung verwenden. | |
502, 500 | – | Bild konnte nicht von der url geladen werden, Anbieter oder Stripe nicht erreichbar, oder Serverfehler. |
Wer Anfragen ausführen kann, kann die Seite selbst anlegen. Wer nicht, gibt dem Nutzer den fertigen
curl-Befehl oder den langen Link. Alles zum Inhalt der Seite steht in der
llms.txt; hier geht es nur um das Speichern. MCP-Clients nutzen statt roher HTTP-Aufrufe den MCP-Server.
GET /api/limits. Ist features.cloud falsch oder die Anfrage scheitert, beim langen Link bleiben. Den Nutzer eine Frist aus retention.options wählen lassen (Standard retention.defaultDays).POST /api/sites mit {data, retentionDays}. id, url, editKey aus der Antwort übernehmen.POST /api/sites/<id>/images mit {"url":"https://…"} (Server-Import). Die zurückgegebene url als Wert für photo/p1photo … in data setzen. Dann PUT /api/sites/<id> mit den vollständigen Daten.GET /api/sites/<id>, dann den Kurzlink öffnen oder dem Nutzer geben.expiresAt).data, nicht in die Adresse eines Aufrufs, nicht an Dritte. Er gehört in den Authorization-Header.PUT mit retentionDays auf.{url}-Import laden, wenn du keine Dateien senden kannst. Nur Adressen verwenden, die du gefunden und geprüft hast.PUT mit data ersetzt alles. Erst GET, ändern, dann PUT.429 oder 503 auf den Link-Modus ausweichen und den langen Link liefern; dem Nutzer kurz sagen, warum.409 image-limit ein Foto weniger oder ein altes löschen, nicht neu versuchen.
Für Chat-Clients, die selbst keine HTTP-Aufrufe machen können (Claude, ChatGPT und andere MCP-Clients), gibt es dieselbe API als
MCP-Server (Model Context Protocol): https://d-solve.de/apartment-search/mcp, Transport Streamable HTTP,
JSON-RPC 2.0 per POST, ohne Anmeldung, ohne Sitzungen. Als Connector oder benutzerdefinierte Integration hinzufügen; die
instructions des Servers verweisen auf die llms.txt.
Jedes Werkzeug ist genau einer der Endpunkte oben, durch denselben Handler, mit derselben Prüfung, denselben Grenzen und Fehlercodes.
Der Bearbeitungsschlüssel ist ein Werkzeug-Argument (editKey) und wird intern zum Authorization-Header.
| Werkzeug | Argumente | Endpunkt |
|---|---|---|
apartment_limits | – | GET /limits |
apartment_create | data, retentionDays | POST /sites |
apartment_get | id | GET /sites/:id |
apartment_update | id, editKey, data, retentionDays | PUT /sites/:id |
apartment_delete | id, editKey | DELETE /sites/:id |
apartment_add_image | id, editKey, url | POST /sites/:id/images (URL-Import) |
apartment_delete_image | id, editKey, imageId | DELETE /sites/:id/images/:imageId |
apartment_rotate_key | id, editKey | POST /sites/:id/rotate-key |
Ergebnisse sind JSON, als Text und als structuredContent. Ein API-Fehler wird zum Werkzeug-Ergebnis mit isError: true
und den Feldern status, error und code. Ausprobieren mit curl:
curl -sS -X POST https://d-solve.de/apartment-search/mcp -H "content-type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Konto, Premium und Sammel-Anlage sind keine Werkzeuge; Agenten arbeiten mit dem Bearbeitungsschlüssel. GET /apartment-search/mcp
antwortet mit 405 und einer kurzen Beschreibung, die OpenAPI-Beschreibung nennt den Server unter x-mcp.
Ein Konto ist optional und nur für Premium oder mehrere Seiten nötig. Es gibt keine Passwörter, nur die Anmeldung mit Google.
GET /api/auth/providers nennt die verfügbaren Anbieter (derzeit nur Google, per accounts.providers). GET /api/auth/login?provider=google&return=<Adresse> leitet zu Google (OAuth 2.1, Authorization Code mit PKCE; der Worker ist der vertrauliche Client, kein Token erreicht den Browser). Die Rückkehr-Adresse muss auf einem erlaubten Ursprung liegen (https://d-solve.de). Nach dem Rücksprung setzt der Worker das HttpOnly-Cookie __Secure-as_session (Secure, SameSite=Lax, Path=/apartment-search; 30 Tage gleitend, höchstens 90) und leitet zu <return>#/auth/ok weiter. Die Sitzung liegt in D1 nur als SHA-256-Hash. Beenden: POST /api/auth/logout (diese) oder POST /api/auth/logout-all (alle).GET /api/auth/me zeigt Name, Premium-Status und die Grenzen dieses Kontos. GET /api/me/sites listet die eigenen Seiten.POST /api/sites/<id>/claim und {"editKey":"…"} übernommen. Der Schlüssel bleibt zusätzlich gültig. Wie viele Seiten ein Konto haben darf und welche Fristen gelten, steht in /api/auth/me unter limits.POST /api/billing/checkout mit {"return":"…"} liefert eine Stripe-Checkout-Adresse für ein Abo (Preis aus premium.stripePriceId, monatlich oder jährlich). Danach geht es zu <return>#/konto/premium-ok, bei Abbruch zu <return>#/konto. Premium gilt bis zum Ende der bezahlten Periode plus 3 Tage Kulanz; nach Kündigung endet es. Kündigen und Zahlungsdaten ändern: POST /api/billing/portal mit {"return":"…"} liefert die Adresse des Stripe-Kundenportals. Die Freischaltung kommt per Stripe-Webhook, nicht über die Rückkehr-Adresse. Nur sichtbar, wenn premium.available in /api/limits wahr ist.POST /api/sites/bulk legt für Premium-Konten mehrere Seiten auf einmal an, sofern features.bulk aktiv ist.workers/apartment-search-api/README.md im Repository.