Die API
Mit der API legen Sie Kurzlinks an, fragen ab, wohin ein Kurzlink führt, schalten einen eigenen Link wieder ab oder melden Missbrauch. Ein Konto oder einen API-Schlüssel brauchen Sie dafür nicht.
ano-link.de
Grundsätzliches
Die API setzt Access-Control-Allow-Origin für jede Herkunft. Sie können sie also auch direkt aus einer Webseite heraus aufrufen. Access-Control-Allow-Credentials setzt der Server nie, denn es gibt weder Cookies noch Sitzungen.
Einen Rumpf schicken Sie als JSON mit der Kopfzeile Content-Type: application/json, höchstens 16 KB groß. Ohne diese Kopfzeile liest der Server den Rumpf nicht.
Antworten sind immer JSON, auch Fehler und überschrittene Grenzen. Sie können also jede Antwort als JSON lesen, bevor Sie den Statuscode auswerten. Ein Fehler sieht so aus:
{ "error": "Diese Lebensdauer gibt es nicht.", "field": "days" }
error nennt den Grund auf Deutsch. field steht bei Eingabefehlern in POST /api/links (url, proof, days) und POST /api/report (code, reason) und sagt, welche Angabe nicht passt. Alle anderen Fehler tragen nur error, auch die Grenzen, unbekannte Pfade und ein unlesbarer Rumpf.
Für alle Routen gilt außerdem:
- 400 Der Rumpf ist kein lesbares JSON.
- 404 Unter /api/ gibt es diesen Pfad nicht.
- 413 Der Rumpf ist größer als 16 KB.
- 429 Eine Grenze ist erreicht (siehe nächster Abschnitt).
- 500 Fehler im Server
Übersicht und Grenzen
| Methode | Pfad | Zweck | Grenze |
|---|---|---|---|
| GET | /api/challenge | Rechenaufgabe holen | 300 je 10 Minuten |
| POST | /api/links | Kurzlink anlegen | 60 je 10 Minuten, je IPv6-/48 zusammen 240 |
| GET | /api/links/:code | Ziel eines Kurzlinks abfragen | 120 je 10 Minuten, dazu der Fehlgriffzähler |
| POST | /api/disable | eigenen Kurzlink abschalten | 10 je Stunde, je IPv6-/48 zusammen 40 |
| POST | /api/report | Missbrauch melden | gemeinsam mit /api/disable |
| GET | /api/health | Lebenszeichen | keine eigene |
Die Grenzen zählen je Anschluss, bei IPv4 also je Adresse und bei IPv6 je /64. Ein IPv6-Anschluss bekommt oft ein /56 mit 256 /64-Netzen, manchmal ein ganzes /48 mit 65.536. Deshalb haben Anlegen, Abschalten und Melden eine zweite Grenze für das ganze /48. Greift diese zweite Grenze, beginnt die Fehlermeldung mit „Aus diesem Netzbereich …“. Für IPv4 gibt es diese zweite Stufe nicht.
Die Grenze für Rechenaufgaben liegt mit Absicht weit über der fürs Anlegen. Jeder Versuch, einen Link anzulegen, verbraucht eine Aufgabe, auch ein abgelehnter.
Über allen Routen liegt eine Gesamtgrenze von 1200 Anfragen je fünf Minuten und Anschluss. Davon ausgenommen sind nur die Aufrufe der Kurzlinks selbst (GET /<code>). Wenn zweihundert Leute hinter demselben Firmenanschluss einen Newsletter öffnen, soll das nicht wie ein Angriff aussehen.
Anfragen nach Codes, die es nicht gibt, zählt ein eigener Fehlgriffzähler: auf GET /api/links/:code, auf POST /api/report und beim Aufruf eines Kurzlinks. Ab dem 60. Fehlgriff binnen zehn Minuten aus demselben Bereich (IPv4 je /24, IPv6 je /64) antworten diese Stellen mit 429, für ein ganzes IPv6-/48 ab dem 240. Vorhandene Codes sperrt der Zähler nie. Wer nur Links abfragt, die es gibt, merkt von ihm nichts.
Die Antworten tragen die Kopfzeilen RateLimit-Policy und RateLimit nach dem IETF-Entwurf „RateLimit header fields for HTTP“ in Fassung 7. Sie beschreiben die Grenze der aufgerufenen Route; hat eine Route zwei Stufen, die mit dem kleineren Rest. w und reset sind Sekunden. Lehnt eine dieser Grenzen ab, steht in der 429 zusätzlich Retry-After. Die 429 des Fehlgriffzählers hat diese Kopfzeile nicht. Sein Fenster beginnt mit dem ersten Fehlgriff und dauert zehn Minuten.
RateLimit-Policy: 60;w=600
RateLimit: limit=60, remaining=57, reset=412
GET /api/challenge
Bevor Sie einen Kurzlink anlegen, löst Ihr Programm eine kleine Rechenaufgabe. Das dauert einen Sekundenbruchteil. Bei einem einzelnen Link fällt das nicht auf, bei zehntausend Spam-Links schon. Eine Zugangssperre ist die Aufgabe nicht, jedes Programm kann sie lösen.
{
"salt": "9afe6e3e9b55ed2ae4cb3584d6f0904f",
"difficulty": 14,
"valid_until": 1799919612033,
"signature": "6b13179cdb88e68e2681f95f8beb8c5a0b4b9c5925eccb6f3801ba409b228a3a"
}
Gesucht ist eine ganze Zahl nonce ab 0 (höchstens 15 Ziffern), für die der SHA-256-Wert von salt + nonce mit mindestens difficulty Nullbits beginnt. salt + nonce ist die Zeichenkette salt, direkt gefolgt von der Zahl in Dezimalschreibweise. Am einfachsten zählen Sie von 0 aufwärts. Bei 14 Bit braucht das im Mittel rund 16.000 Versuche, je nach Sprache 20 bis 300 Millisekunden.
valid_until ist ein Zeitpunkt in Millisekunden seit 1970. Eine Aufgabe gilt zehn Minuten und lässt sich einmal einlösen. Schicken Sie salt, signature, difficulty und valid_until unverändert zurück. Der Server prüft seine Signatur über diese Werte; eine selbst verkleinerte difficulty fällt dabei auf.
Der Server verbraucht die Aufgabe, sobald er die Lösung als richtig erkannt hat. Scheitert die Anfrage danach etwa an der Adresse, holen Sie für den nächsten Versuch eine neue Aufgabe.
Eine Instanz kann die Aufgabe abschalten. Dann sieht die Antwort so aus, und proof darf beim Anlegen fehlen:
{ "salt": null, "difficulty": 0, "valid_until": null, "signature": null, "disabled": true }
Prüfen Sie dafür das Feld disabled, nicht, ob salt fehlt.
POST /api/links
| Feld | Pflicht | Bedeutung |
|---|---|---|
| url | ja | Ziel mit http:// oder https://, ohne #. Fehlt das Schema, ergänzt der Server https://. Höchstens 2048 Zeichen. |
| proof | ja | Die gelöste Aufgabe: salt, nonce, signature, difficulty, valid_until. Entfällt, wenn die Aufgabe abgeschaltet ist. |
| fragment | nein | true, wenn die ursprüngliche Adresse ein # enthielt. Nur diese Angabe, nie der Teil hinter dem #. |
| one_time | nein | true: Der Link leitet genau einmal weiter. Vorgabe ist false, bei Notizen von secret-note true. |
| days | nein | Lebensdauer in Tagen: 1, 7, 30, 90 oder 365. Ohne Angabe oder mit null hat der Link kein Ablaufdatum. Ein Link, den in den ersten zwölf Monaten nach dem Anlegen kein Mensch aufruft, wird gelöscht, mit oder ohne Ablaufdatum. Danach antwortet der Server auf den Code mit 404. Einmal-Links und abgeschaltete Links sind davon ausgenommen. |
Andere Werte für days lehnt der Server mit 400 ab, er rundet sie nicht. Felder, die er nicht kennt, ignoriert er.
POST /api/links
Content-Type: application/json
{
"url": "https://example.com/eine/lange/adresse",
"days": 30,
"proof": {
"salt": "9afe6e3e9b55ed2ae4cb3584d6f0904f",
"nonce": 20871,
"signature": "6b13179cdb88e68e2681f95f8beb8c5a0b4b9c5925eccb6f3801ba409b228a3a",
"difficulty": 14,
"valid_until": 1799919612033
}
}
{
"code": "r5cjp8kf",
"short_url": "https://ano-link.de/r5cjp8kf",
"url": "https://example.com/eine/lange/adresse",
"fragment": false,
"one_time": false,
"expires_at": "2027-02-13 09:30:12",
"token": "8lJ3b4gFg4RZEVcu2A2_FXuTOn9I-wfU"
}
url ist die Adresse so, wie der Server sie gespeichert hat: Internationale Namen stehen in Punycode, ein Standardport fehlt, ein ergänztes https:// ist dabei. expires_at ist null für Links ohne Ablaufdatum, sonst ein Zeitpunkt in UTC in der Form JJJJ-MM-TT hh:mm:ss.
token ist der Verwaltungsschlüssel, mit dem Sie den Link später abschalten können. Der Server gibt ihn nur in dieser einen Antwort heraus und speichert selbst nur seinen SHA-256-Wert. Geht er verloren, lässt er sich nicht ersetzen.
- 201 angelegt
- 400 Eingabefehler, field ist url, proof oder days
- 413 Rumpf größer als 16 KB
- 429 zu viele Anfragen von diesem Anschluss oder aus diesem /48
Der Code besteht aus acht Zeichen, Kleinbuchstaben und Ziffern ohne die leicht verwechselbaren 0, 1, l und o. Er stammt aus einem kryptographischen Zufallsgenerator. Der Code eines abgeschalteten Links und eines Einmal-Links wird nie ein zweites Mal vergeben. Das gilt auch für jeden Link, der mindestens einmal aufgerufen wurde. Löscht der Server einen nie aufgerufenen Link nach zwölf Monaten, ist dessen Code wieder frei.
Ein Kürzel können Sie nicht selbst wählen; ein mitgeschicktes Feld dafür ignoriert der Server. Auf einem Dienst ohne Konten wäre ein frei wählbares Kürzel wie ano-link.de/sparkasse-login ein fertiges Werkzeug für Phishing, und keine Sperrliste fängt alle Schreibweisen ab.
Einmal-Links
Ein Link mit one_time: true leitet genau einmal weiter. Beim ersten Öffnen löscht der Server die Zieladresse. Wer den Link danach im Browser öffnet, bekommt 410. Vorschau-Abrufe, auch mit curl oder fetch(), bekommen dagegen weiter eine Seite mit 200 (siehe unten). Treffen zwei Aufrufe gleichzeitig ein, kommt nur einer durch.
Notizen von secret-note
Zeigt url auf eine Notiz von secret-note (https://secret-note.de/n/…, auch mit www.) und fehlt one_time oder ist es null, legt der Server einen Einmal-Link an. Eine solche Notiz zerstört sich beim ersten Lesen. Ein Kurzlink, der danach weiter funktioniert, führte nur noch zu einer Notiz, die es nicht mehr gibt. Soll der Link trotzdem mehrfach nutzbar sein, schicken Sie one_time: false mit. Welche Wahl gilt, steht in der Antwort im Feld one_time.
Vorschau-Abrufe verbrauchen nichts
Messenger wie WhatsApp oder Slack, Mailscanner und das Vorausladen im Browser rufen einen geteilten Link ab, bevor ein Mensch ihn öffnet. Solche Abrufe verbrauchen einen Einmal-Link nicht. Der Server erkennt sie an der Browserkennung, an Kopfzeilen wie Sec-Purpose oder Purpose, an HEAD-Anfragen, an einem Sec-Fetch-Mode ungleich navigate und an einem Accept ohne text/html.
Ein Vorschau-Abruf bekommt eine neutrale Seite mit 200, egal ob der Link noch offen, schon eingelöst oder abgelaufen ist. Sonst verriete schon der Statuscode, ob der Link bereits geöffnet wurde. Wurde ein Einmal-Link ohne fragment abgeschaltet, nach Meldungen vorläufig gesperrt oder vom Betreiber gesperrt, bekommt auch ein Vorschau-Abruf 410. Rufen Sie einen Einmal-Link mit curl oder fetch() auf, zählt das ebenfalls als Vorschau.
Einlösen in zwei Schritten, wenn fragment: true gesetzt ist
So legt secret-note seine Kurzlinks an. Der Server löst sie in zwei Schritten ein:
- GET /<code> liefert immer dieselbe Seite mit 200, für jeden solchen Link und in jedem Zustand. Ein Skript auf dieser Seite wechselt sofort zu /<code>?o=1 und nimmt den Teil hinter dem # dabei im Browser mit.
- Erst GET /<code>?o=1 löst ein. Dafür darf der Abruf kein Vorschau-Abruf sein, und Sec-Fetch-Site muss same-origin oder none lauten. Sendet ein älterer Browser gar keine Fetch-Metadaten, genügt das auch. Kommt ?o=1 von einer fremden Seite (cross-site oder same-site), gibt es wieder die Seite aus Schritt 1.
Ein Mailscanner, der sich als Browser ausgibt, aber kein JavaScript ausführt, verbraucht den Link damit nicht. Geben Sie Kurzlinks ohne ?o=1 weiter; den Parameter setzt nur das Skript. Ohne JavaScript lässt sich ein solcher Link nicht öffnen, und die Seite aus Schritt 1 sagt das auch.
Einmal-Links ohne fragment werden in einem Schritt eingelöst: Der erste Abruf eines Menschen leitet mit 302 weiter.
Adressen mit #
Eine url mit # lehnt der Server mit 400 ab. Er schneidet den Teil dahinter nicht ab.
Der Grund ist secret-note. Dessen Links sehen so aus:
https://secret-note.de/n/<id>#<key>
Hinter dem # steht der Schlüssel, mit dem der Browser die Notiz entschlüsselt. Er darf den Server von ano-link nie erreichen, sonst stünde er in der Datenbank und in jeder Sicherung. Schneidet ihn erst der Server ab, ist er mit der Anfrage schon angekommen.
Trennen Sie die Adresse deshalb vor dem Absenden am ersten #. Schicken Sie nur den Teil davor und fragment: true. Den Rest hängen Sie danach selbst an den Kurzlink:
const base = 'https://ano-link.de';
const input = 'https://secret-note.de/n/abc123#schluessel';
const hashIndex = input.indexOf('#');
const target = hashIndex < 0 ? input : input.slice(0, hashIndex);
const fragment = hashIndex < 0 ? '' : input.slice(hashIndex + 1);
// createLink() aus dem Browser-Beispiel weiter unten. Gesendet werden nur
// die Adresse ohne '#' und die Angabe, dass es eins gab.
const link = await createLink(base, { url: target, fragment: fragment !== '' });
// Der Teil hinter dem '#' bleibt auf Ihrer Seite und kommt erst hier dazu.
const shareUrl = link.short_url + (fragment !== '' ? '#' + fragment : '');
Trennen Sie mit indexOf und nicht mit new URL(). Wirft der Parser bei einer fehlerhaften Eingabe eine Ausnahme, steht die ganze Adresse samt Schlüssel in der Fehlermeldung, und die landet oft in einem Protokoll.
Auch ein %23 im Pfad lehnt der Server ab, denn es ist ein kodiertes #. In der Abfragezeichenfolge bleibt %23 erlaubt: Die Suche nach „C#“ schreibt sich ?q=C%23.
Ist fragment: true gesetzt, antwortet ano-link beim Öffnen nicht mit 302. Es liefert eine kleine HTML-Seite, deren Skript den Teil hinter dem # wieder an das Ziel hängt. Nach RFC 9110 müsste der Browser das Fragment auch bei einer Weiterleitung übernehmen, aber In-App-Browser und Mail-Gateways tun das nicht verlässlich. Auf dieser Seite läuft kein fremder Code.
Wer trotzdem ein # mitschickt, bekommt diese Antwort:
{
"error": "Diese Adresse enthält ein #. Was dahinter steht, darf den Server nicht erreichen – bitte JavaScript einschalten und die Adresse noch einmal einfügen. Dann bleibt dieser Teil in Ihrem Browser.",
"field": "url"
}
Abgelehnte Ziele
Neben Adressen mit # nimmt der Server diese Ziele nicht an:
- andere Schemata als http und https, etwa javascript: oder ftp:
- Adressen mit Benutzername oder Passwort
- eine IP-Adresse statt eines Namens, IPv4 wie IPv6
- Namen ohne Punkt, localhost und interne Endungen wie .local, .internal, .lan, .home.arpa oder fritz.box
- ano-link selbst, auch mit Unterdomain
- andere Kurzlinkdienste wie bit.ly, tinyurl.com oder t.co, auch mit Unterdomain (ausgenommen maps.app.goo.gl, die Teilen-Links von Google Maps)
- Portangaben außer 80 und 443
- Steuerzeichen, Zeilenumbrüche und Tabulatoren in der Adresse
- mehr als 2048 Zeichen, auch nach der Normalisierung
- Hosts auf der Sperrliste von ano-link, samt Unterdomains
Die Antwort ist jeweils 400 mit "field": "url" und einem deutschen Text, der den Grund nennt.
Der Server ruft das Ziel nie selbst ab. Er prüft nur die Zeichenkette und weiß deshalb nicht, ob die Seite erreichbar ist.
GET /api/links/:code
Zeigt, wohin ein Kurzlink führt, ohne ihn zu öffnen. Groß- und Kleinschreibung im Code spielen keine Rolle.
GET /api/links/r5cjp8kf
{
"code": "r5cjp8kf",
"url": "https://example.com/eine/lange/adresse",
"one_time": false,
"fragment": false,
"disabled": false,
"created_at": "2027-01-14 09:30:12",
"expires_at": "2027-02-13 09:30:12"
}
disabled: true mit url: null heißt: Der Link leitet nicht weiter. Das gilt für abgeschaltete und abgelaufene Links, deren Zieladresse gelöscht ist. Es gilt auch, solange ein Link nach Meldungen vorläufig gesperrt ist oder sein Ziel auf der Sperrliste steht; dann gibt der Server die Adresse nur nicht heraus. Zeitangaben stehen in UTC.
Für Einmal-Links sieht die Antwort in jedem Zustand gleich aus, ob unbenutzt, eingelöst, abgelaufen oder gesperrt:
{
"code": "eyux4fdw",
"url": null,
"one_time": true,
"fragment": true,
"disabled": false,
"created_at": "2027-01-14 09:30:12",
"expires_at": null,
"message": "Ein Einmal-Link. Weder das Ziel noch sein Zustand werden angezeigt."
}
Ließe sich der Zustand abfragen, wäre die Route eine Lesebestätigung. Wer den Code kennt, etwa aus einer Gruppe oder aus einem abgefangenen Link, sähe sonst, ob und wann der Empfänger seine Notiz geöffnet hat.
Aufrufzahlen gibt die Route nicht heraus, auch nicht gegen den Verwaltungsschlüssel.
- 200 Auskunft, auch für einen abgeschalteten Link
- 400 Der Code hat nicht die Form eines Codes.
- 404 Diesen Kurzlink gibt es nicht. Zählt als Fehlgriff.
- 429 zu viele Abfragen oder zu viele Fehlgriffe
POST /api/disable
POST /api/disable
Content-Type: application/json
{ "code": "r5cjp8kf", "token": "8lJ3b4gFg4RZEVcu2A2_FXuTOn9I-wfU" }
{ "ok": true, "message": "Der Kurzlink ist abgeschaltet und wird nicht neu vergeben." }
Danach leitet der Link nicht mehr weiter, jeder Aufruf endet mit 410. Der Server löscht dabei die Zieladresse; zurückholen lässt sie sich nicht.
Ein unbekannter Code und ein falscher Schlüssel ergeben dieselbe Antwort 403. Sonst ließe sich über diese Route prüfen, welche Codes es gibt.
Solange ein Link nach Meldungen vorläufig gesperrt ist, lässt er sich nicht abschalten. Abschalten löscht die Zieladresse, und die braucht der Betreiber, um die Meldungen zu prüfen. Der Server antwortet dann mit 409, aber erst nach der Prüfung des Schlüssels. Wer den Schlüssel nicht hat, bekommt wie sonst 403.
{
"error": "Dieser Kurzlink ist vorläufig gesperrt und leitet nicht weiter. Nach der Prüfung wird er entweder wieder freigegeben – dann lässt er sich abschalten – oder endgültig gesperrt und seine Zieladresse gelöscht."
}
- 200 abgeschaltet
- 400 Code oder Schlüssel fehlt
- 403 falscher Schlüssel oder unbekannter Code
- 409 vorläufig gesperrt
- 429 zu viele Anfragen
POST /api/report
POST /api/report
Content-Type: application/json
{ "code": "r5cjp8kf", "reason": "phishing", "comment": "sieht aus wie die Anmeldeseite einer Bank" }
| reason | Bedeutung |
|---|---|
| phishing | gefälschte Seite, die Zugangs- oder Zahlungsdaten abgreifen soll |
| malware | Schadsoftware |
| spam | unerwünschte Massenwerbung |
| fraud | Betrug |
| illegal | rechtswidriger Inhalt |
| other | etwas anderes |
code darf auch der vollständige Kurzlink sein, aber ohne den Teil ab #. Ein Schlüssel hat in einer Meldung nichts zu suchen. Eine Eingabe mit # lehnt der Server mit 400 ab. comment ist freiwillig. Der Server entfernt daraus Steuerzeichen und kürzt ihn auf 2000 Zeichen.
{ "ok": true, "message": "Danke. Die Meldung liegt vor und wird geprüft." }
Jede Meldung liest ein Mensch. Melden Menschen aus mindestens drei verschiedenen Netzen (IPv4 je /24, IPv6 je /48) denselben Link binnen 24 Stunden, sperrt der Server ihn vorläufig, bis jemand die Meldungen geprüft hat. Die Antwort an die meldende Person bleibt in jedem Fall dieselbe.
Meldungen, die ein Browser von einer fremden Webseite aus abschickt, zählen für diese vorläufige Sperre nicht. Gespeichert und gelesen werden sie trotzdem, die Antwort bleibt 201. Als fremd gilt eine Anfrage, deren Origin gesetzt ist und nicht zu ano-link gehört, oder deren Sec-Fetch-Site cross-site oder same-site lautet. Ohne diese Regel könnte jede Webseite die Browser ihrer Besucher melden lassen und so beliebige Links sperren. Anfragen ohne diese Kopfzeilen, etwa von einem Server oder mit curl, zählen normal.
- 201 Meldung gespeichert
- 400 field ist code oder reason; code auch bei einer Eingabe mit #
- 404 Diesen Kurzlink gibt es nicht. Zählt als Fehlgriff.
- 429 zu viele Anfragen (gemeinsame Grenze mit /api/disable)
Ohne Programm geht es über das Meldeformular oder per E-Mail an missbrauch@ano-link.de.
GET /api/health
{ "ok": true }
Die Route eignet sich für eine Erreichbarkeitsprüfung. Version, Betriebszeit oder Zahlen nennt sie nicht.
Beispiele: Rechenaufgabe lösen und Link anlegen
Die Beispiele für Node.js, Browser, PHP und Python holen eine Aufgabe, lösen sie, legen einen Kurzlink an und geben ihn aus. Für eine andere Instanz ändern Sie nur die Basisadresse.
Node.js
import crypto from 'node:crypto';
const base = 'https://ano-link.de';
function leadingZeroBits(bytes) {
let bits = 0;
for (const byte of bytes) {
if (byte === 0) { bits += 8; continue; }
bits += Math.clz32(byte) - 24;
break;
}
return bits;
}
async function getProof() {
const response = await fetch(base + '/api/challenge');
const challenge = await response.json();
if (!response.ok) throw new Error(challenge.error);
if (challenge.disabled) return undefined; // Aufgabe abgeschaltet
let nonce = 0;
while (leadingZeroBits(crypto.createHash('sha256').update(challenge.salt + nonce).digest()) < challenge.difficulty) {
nonce++;
}
return {
salt: challenge.salt,
nonce,
signature: challenge.signature,
difficulty: challenge.difficulty,
valid_until: challenge.valid_until,
};
}
const response = await fetch(base + '/api/links', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com/lang', proof: await getProof() }),
});
const body = await response.json();
if (!response.ok) throw new Error(body.error);
console.log(body.short_url);
Browser (Web Crypto)
crypto.subtle.digest() arbeitet asynchron, und ein await je Versuch macht die Suche im Browser deutlich langsamer als in Node. Das Beispiel rechnet deshalb 128 Werte gleichzeitig.
function leadingZeroBits(bytes) {
let bits = 0;
for (const byte of bytes) {
if (byte === 0) { bits += 8; continue; }
bits += Math.clz32(byte) - 24;
break;
}
return bits;
}
async function getProof(base) {
const response = await fetch(base + '/api/challenge');
const challenge = await response.json();
if (!response.ok) throw new Error(challenge.error);
if (challenge.disabled) return undefined; // Aufgabe abgeschaltet
const encoder = new TextEncoder();
const batchSize = 128;
for (let start = 0; ; start += batchSize) {
const nonces = Array.from({ length: batchSize }, (_, i) => start + i);
const digests = await Promise.all(
nonces.map((nonce) => crypto.subtle.digest('SHA-256', encoder.encode(challenge.salt + nonce)))
);
for (let i = 0; i < batchSize; i++) {
if (leadingZeroBits(new Uint8Array(digests[i])) >= challenge.difficulty) {
return {
salt: challenge.salt,
nonce: nonces[i],
signature: challenge.signature,
difficulty: challenge.difficulty,
valid_until: challenge.valid_until,
};
}
}
}
}
// fields: { url, fragment, one_time, days }
async function createLink(base, fields) {
const response = await fetch(base + '/api/links', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...fields, proof: await getProof(base) }),
});
const body = await response.json();
if (!response.ok) throw new Error(body.error);
return body;
}
const link = await createLink('https://ano-link.de', { url: 'https://example.com/lang' });
console.log(link.short_url);
PHP
<?php
function leadingZeroBits(string $bytes): int {
$bits = 0;
foreach (unpack('C*', $bytes) as $byte) {
if ($byte === 0) { $bits += 8; continue; }
for ($i = 7; $i >= 0 && !($byte & (1 << $i)); $i--) $bits++;
break;
}
return $bits;
}
$base = 'https://ano-link.de';
$challenge = json_decode(file_get_contents($base . '/api/challenge'), true);
$payload = ['url' => 'https://example.com/lang'];
if (empty($challenge['disabled'])) {
$nonce = 0;
while (leadingZeroBits(hash('sha256', $challenge['salt'] . $nonce, true)) < $challenge['difficulty']) {
$nonce++;
}
$payload['proof'] = [
'salt' => $challenge['salt'],
'nonce' => $nonce,
'signature' => $challenge['signature'],
'difficulty' => $challenge['difficulty'],
'valid_until' => $challenge['valid_until'],
];
}
$response = file_get_contents($base . '/api/links', false, stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'content' => json_encode($payload),
'ignore_errors' => true, // auch bei 4xx den Rumpf lesen
],
]));
$body = json_decode($response, true);
echo $body['short_url'] ?? $body['error'], "\n";
Python
import hashlib
import json
import urllib.error
import urllib.request
base = 'https://ano-link.de'
def leading_zero_bits(digest: bytes) -> int:
bits = 0
for byte in digest:
if byte == 0:
bits += 8
continue
bits += 8 - byte.bit_length()
break
return bits
def get_proof():
with urllib.request.urlopen(base + '/api/challenge') as response:
challenge = json.load(response)
if challenge.get('disabled'):
return None # Aufgabe abgeschaltet
nonce = 0
while leading_zero_bits(hashlib.sha256(f"{challenge['salt']}{nonce}".encode()).digest()) < challenge['difficulty']:
nonce += 1
return {
'salt': challenge['salt'],
'nonce': nonce,
'signature': challenge['signature'],
'difficulty': challenge['difficulty'],
'valid_until': challenge['valid_until'],
}
payload = {'url': 'https://example.com/lang'}
proof = get_proof()
if proof is not None:
payload['proof'] = proof
request = urllib.request.Request(
base + '/api/links',
data=json.dumps(payload).encode(),
headers={'Content-Type': 'application/json'},
)
try:
with urllib.request.urlopen(request) as response:
print(json.load(response)['short_url'])
except urllib.error.HTTPError as error:
print(json.load(error)['error'])
curl
Ohne Rechenaufgabe geht es nur, wenn die Instanz sie abgeschaltet hat. Sonst antwortet der Server mit 400 und "field": "proof".
curl -s https://ano-link.de/api/links \
-H 'Content-Type: application/json' \
-d '{"url":"https://example.com/lang"}' | jq -r .short_url
Was die API nicht kann
- Aufrufzahlen abfragenBei einem Link auf eine Notiz verriete die Zahl, ob sie schon gelesen wurde.
- Links auflistenOhne Konten gäbe es niemanden, dem eine solche Liste gehört.
- Ein Ziel nachträglich ändernEin gedruckter Kurzlink soll dorthin führen, wohin er beim Drucken zeigte.
- Ein Kürzel selbst wählenOhne Konten wäre das ein Werkzeug für Phishing unter fremdem Namen.
- Den Zustand eines Einmal-Links abfragenDas wäre eine Lesebestätigung.
- Titel oder Vorschaubild der Zielseite liefernDer Server ruft keine fremden Adressen ab. So lässt er sich nicht als Sprungbrett in fremde oder interne Netze benutzen.
- Adressen mit # annehmenDer Teil hinter dem # soll den Server nie erreichen.
- Eine gelöschte Zieladresse zurückholenIst ein Link eingelöst, abgelaufen oder abgeschaltet, löscht der Server seine Zieladresse. Ein Archiv gibt es nicht.
Feldnamen und Statuscodes auf dieser Seite bleiben stabil. Ändert sich daran etwas, steht es hier, bevor es in Betrieb geht.
Stand: 19. Oktober 2026