+
= {
+ 'statischer-vs-dynamischer-qr-code': {
+ enSlug: 'static-vs-dynamic-qr-code',
+ enPath: '/blog/static-vs-dynamic-qr-code',
+ enableScrollReveal: true,
+ slug: 'statischer-vs-dynamischer-qr-code',
+ href: '/de/statischer-vs-dynamischer-qr-code',
+ title: 'Statischer oder dynamischer QR-Code?',
+ cluster: 'qr-code-basics',
+ summary:
+ 'Entscheiden Sie vor dem Druck, ob Ihr QR-Ziel dauerhaft unverändert bleibt oder später ohne Neudruck aktualisiert werden muss.',
+ parentHref: '/dynamic-qr-code-generator',
+ parentTitle: 'Dynamischer QR-Code-Generator',
+ ctaLabel: 'Dynamischen QR-Code erstellen',
+ eyebrow: 'Vor dem Druck entscheiden',
+ titleSuffix: 'vor dem Druck',
+ metaDescription:
+ 'Statischer oder dynamischer QR-Code? Vergleichen Sie Änderbarkeit, Druckrisiko und Tracking – und entscheiden Sie vor Flyer, Speisekarte oder Tischaufsteller.',
+ intro:
+ 'Bleibt Ihr Link garantiert unverändert, reicht ein statischer QR-Code. Können sich PDF, Menü, Angebot oder Zielseite später ändern, bleibt ein dynamischer QR-Code nach dem Druck flexibel.',
+ answer:
+ 'Ein statischer QR-Code enthält sein Ziel direkt und lässt sich nach dem Druck nicht ändern. Ein dynamischer QR-Code führt über ein verwaltbares Ziel: Sie können die Zielseite später aktualisieren, während der gedruckte Code bestehen bleibt.',
+ whenToUse: [
+ 'Das Ziel bleibt dauerhaft gleich, etwa bei einer unveränderlichen PDF-Datei oder einer festen Kontaktinformation.',
+ 'Ein Menü, Preis, Angebot, PDF oder Link kann sich ändern, nachdem Flyer, Karten oder Aufsteller gedruckt sind.',
+ 'Sie möchten Scans von Tischen, Fenstern, Flyern oder anderen Platzierungen getrennt auswerten.',
+ ],
+ comparisonItems: [
+ { label: 'Ziel nach dem Druck ändern', text: 'Nicht möglich', value: true },
+ { label: 'Neues PDF oder Angebot verlinken', text: 'Neudruck nötig', value: true },
+ { label: 'Scans nach Platzierung messen', text: 'Nicht verfügbar', value: true },
+ ],
+ howToSteps: [
+ 'Erstellen Sie einen dynamischen QR-Code mit Ihrem aktuellen Link, Menü oder PDF.',
+ 'Testen Sie den Scan und drucken Sie denselben Code auf Flyer, Speisekarten oder Tischaufsteller.',
+ 'Ändern Sie später das Ziel im Dashboard, statt den gedruckten QR-Code zu ersetzen.',
+ ],
+ workflowTitle: 'Wann ein dynamischer QR-Code die bessere Entscheidung ist',
+ workflowIntro:
+ 'Die wichtige Frage ist nicht, ob ein QR-Code heute funktioniert. Entscheidend ist, ob Ihr gedrucktes Material noch funktioniert, wenn sich dahinter etwas ändert.',
+ workflowCards: [
+ {
+ title: 'Speisekarte oder PDF aktualisieren',
+ description:
+ 'Ein Café druckt Tischaufsteller mit QR-Code zur Speisekarte. Ändern sich Preise oder die PDF-Datei, aktualisiert das Team nur das Ziel statt alle Aufsteller neu zu bestellen.',
+ },
+ {
+ title: 'Flyer und Aktionen flexibel halten',
+ description:
+ 'Ein gedruckter Aktionsflyer bleibt im Umlauf, auch wenn sich Landingpage, Saisonangebot oder Termin ändern. Der Code führt weiterhin zur aktuellen Aktion.',
+ },
+ {
+ title: 'Platzierungen getrennt verstehen',
+ description:
+ 'Mit eigenen dynamischen Codes für Fenster, Tische und Take-away-Flyer erkennen Sie, welche gedruckte Platzierung tatsächlich Scans auslöst.',
+ },
+ ],
+ checklistTitle: 'QR-Code vor dem Druck prüfen',
+ checklist: [
+ 'Wählen Sie einen statischen Code nur, wenn das Ziel wirklich nicht geändert werden muss.',
+ 'Nutzen Sie einen dynamischen Code für wechselnde Menüs, PDFs, Angebote und Kampagnen.',
+ 'Testen Sie Größe, Kontrast und Scan-Distanz mit einem echten Smartphone vor der Druckfreigabe.',
+ "Ergänzen Sie einen klaren Hinweis wie 'Scannen für die aktuelle Speisekarte'.",
+ ],
+ supportLinks: [
+ {
+ href: '/dynamic-qr-code-generator',
+ title: 'Dynamischer QR-Code-Generator',
+ description:
+ 'Erstellen Sie einen QR-Code, dessen Ziel Sie später verwalten können.',
+ },
+ {
+ href: '/qr-code-tracking',
+ title: 'QR-Code-Tracking',
+ description:
+ 'Messen Sie Scans nach Zeit, Gerät und gedruckter Platzierung.',
+ },
+ {
+ href: '/reprint-calculator',
+ title: 'Neudruck-Rechner',
+ description:
+ 'Vergleichen Sie den Aufwand wiederkehrender Neudrucke mit einem aktualisierbaren QR-Ziel.',
+ },
+ {
+ href: '/qr-code-print-size-guide',
+ title: 'QR-Code-Druckgrößen-Guide',
+ description:
+ 'Prüfen Sie die passende QR-Größe, bevor Ihr Material in den Druck geht.',
+ },
+ ],
+ faq: [
+ {
+ question: 'Kann ich einen QR-Code nach dem Druck noch ändern?',
+ answer:
+ 'Bei einem statischen QR-Code nicht: Das Ziel ist direkt im Code gespeichert. Ist der Link nach dem Druck falsch oder veraltet, brauchen Sie einen neuen QR-Code und in der Regel einen Neudruck. Bei einem dynamischen QR-Code ändern Sie das Ziel im Dashboard, während der gedruckte Code bestehen bleibt.',
+ },
+ {
+ question: 'Wann brauche ich einen dynamischen QR-Code?',
+ answer:
+ 'Nutzen Sie einen dynamischen QR-Code, wenn sich Zielseite, Menü, PDF, Angebot oder Kampagne später ändern kann oder wenn Sie Scan-Daten auswerten möchten.',
+ },
+ {
+ question: 'Kann ich ein Menü-PDF hinter einem dynamischen QR-Code austauschen?',
+ answer:
+ 'Ja. Sie aktualisieren das Ziel oder die verknüpfte Datei im Dashboard. Der bereits gedruckte dynamische QR-Code kann weiter verwendet werden.',
+ },
+ {
+ question: 'Funktioniert ein dynamischer QR-Code auch auf Flyern und Tischaufstellern?',
+ answer:
+ 'Ja. Gerade bei gedruckten Materialien mit wechselnden Angeboten, Links oder Inhalten ist ein dynamisches Ziel sinnvoll, weil der Code physisch unverändert bleiben kann.',
+ },
+ ],
+ heroImage: '/images/static-vs-dynamic-qr-de.svg',
+ heroImageAlt:
+ 'Illustration: Statischer QR-Code mit festem Link im Vergleich zu einem dynamischen QR-Code mit später änderbarem Ziel',
+ directAnswer:
+ 'Kurz gesagt: Ein statischer QR-Code ist sinnvoll, wenn sich das Ziel garantiert nie ändert. Für alles Gedruckte, das länger im Einsatz bleibt oder auf wechselnde Inhalte verweist, reduziert ein dynamischer QR-Code das Risiko eines veralteten Links.',
+ authoritySignals: [
+ 'Klarer Vergleich vor der Druckfreigabe statt Technik-Jargon.',
+ 'Konkrete Beispiele für Menüs, PDFs, Flyer und Tischaufsteller.',
+ 'Keine pauschalen Einsparversprechen – prüfen Sie Ihren eigenen Neudruck-Aufwand.',
+ ],
+ },
+
'qr-codes-fuer-fitnessstudios': {
enSlug: 'qr-codes-for-gyms',
slug: 'qr-codes-fuer-fitnessstudios',
diff --git a/src/lib/tiktok.ts b/src/lib/tiktok.ts
index c4ede73..e2be5d5 100644
--- a/src/lib/tiktok.ts
+++ b/src/lib/tiktok.ts
@@ -192,20 +192,153 @@ export async function tiktokApi(url: string, options: RequestInit = {}) {
return data;
}
-export async function uploadBinaryToTiktok(uploadUrl: string, buffer: Buffer, mimeType = 'video/mp4') {
- const res = await fetch(uploadUrl, {
- method: 'PUT',
- headers: {
- 'Content-Type': mimeType,
- 'Content-Length': String(buffer.length),
- },
- body: new Uint8Array(buffer),
- });
+// ─── Binary upload (Content-Range, chunking, timeout, retries) ─────────────
- const text = await res.text();
- if (!res.ok && res.status !== 201) {
- throw new TiktokApiError(`TikTok binary upload failed: ${res.status} ${text}`, res.status);
+const UPLOAD_CHUNK_TIMEOUT_MS = 120_000;
+const MAX_CHUNK_ATTEMPTS = 3;
+// TikTok requires chunks between 5 MB and 64 MB; only a file that fits in a
+// single chunk may be smaller than 5 MB.
+export const TIKTOK_MAX_CHUNK_BYTES = 64 * 1024 * 1024;
+
+const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+function retryDelayMs(res: Response | null, attempt: number) {
+ const retryAfter = res?.headers.get('retry-after');
+ if (retryAfter) {
+ const seconds = Number(retryAfter);
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, 30_000);
+ }
+ return Math.min(1000 * 2 ** attempt, 15_000) + Math.floor(Math.random() * 500);
+}
+
+// TikTok expects total_chunk_count = floor(video_size / chunk_size); any
+// remainder is merged into the final chunk.
+export function planTiktokChunks(totalBytes: number) {
+ if (totalBytes <= TIKTOK_MAX_CHUNK_BYTES) {
+ return { chunkSize: totalBytes, totalChunkCount: 1 };
+ }
+ const chunkSize = TIKTOK_MAX_CHUNK_BYTES;
+ return { chunkSize, totalChunkCount: Math.floor(totalBytes / chunkSize) };
+}
+
+export function sniffVideoMimeType(buffer: Buffer): 'video/mp4' | 'video/quicktime' | 'video/webm' | null {
+ if (buffer.length >= 12 && buffer.toString('ascii', 4, 8) === 'ftyp') {
+ const brand = buffer.toString('ascii', 8, 12);
+ return brand.startsWith('qt') ? 'video/quicktime' : 'video/mp4';
+ }
+ if (buffer.length >= 4 && buffer[0] === 0x1a && buffer[1] === 0x45 && buffer[2] === 0xdf && buffer[3] === 0xa3) {
+ return 'video/webm';
+ }
+ return null;
+}
+
+async function putTiktokChunk(
+ uploadUrl: string,
+ chunk: Buffer,
+ opts: { start: number; end: number; total: number; mimeType: string; isLast: boolean }
+) {
+ let lastError: TiktokApiError | null = null;
+
+ for (let attempt = 0; attempt < MAX_CHUNK_ATTEMPTS; attempt++) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), UPLOAD_CHUNK_TIMEOUT_MS);
+ let res: Response | null = null;
+ try {
+ res = await fetch(uploadUrl, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': opts.mimeType,
+ 'Content-Length': String(chunk.length),
+ 'Content-Range': `bytes ${opts.start}-${opts.end}/${opts.total}`,
+ },
+ body: new Uint8Array(chunk),
+ signal: controller.signal,
+ });
+ const text = await res.text();
+ // Complete upload → 201; intermediate chunk → 206. Anything else is an
+ // error even when it is 2xx, so a silently incomplete upload never
+ // counts as success.
+ const expected = opts.isLast ? 201 : 206;
+ if (res.status === expected) {
+ return { status: res.status, body: text };
+ }
+ lastError = new TiktokApiError(`TikTok binary upload failed: ${res.status} ${text}`, res.status);
+ const retryable = res.status === 429 || res.status >= 500;
+ if (!retryable) throw lastError;
+ } catch (err) {
+ if (err instanceof TiktokApiError && !(err.status === 429 || err.status >= 500)) throw err;
+ lastError =
+ err instanceof TiktokApiError
+ ? err
+ : new TiktokApiError(`TikTok binary upload network error: ${err instanceof Error ? err.message : String(err)}`, 502);
+ } finally {
+ clearTimeout(timer);
+ }
+ if (attempt < MAX_CHUNK_ATTEMPTS - 1) await sleep(retryDelayMs(res, attempt));
}
- return { status: res.status, body: text };
+ throw lastError ?? new TiktokApiError('TikTok binary upload failed.', 502);
+}
+
+export async function uploadBinaryToTiktok(uploadUrl: string, buffer: Buffer, mimeType = 'video/mp4') {
+ const total = buffer.length;
+ if (!total) {
+ throw new TiktokApiError('Refusing to upload an empty file to TikTok.', 400);
+ }
+
+ const { chunkSize, totalChunkCount } = planTiktokChunks(total);
+ let result = { status: 0, body: '' };
+ for (let i = 0; i < totalChunkCount; i++) {
+ const start = i * chunkSize;
+ const isLast = i === totalChunkCount - 1;
+ const end = isLast ? total - 1 : start + chunkSize - 1;
+ result = await putTiktokChunk(uploadUrl, buffer.subarray(start, end + 1), {
+ start,
+ end,
+ total,
+ mimeType,
+ isLast,
+ });
+ }
+ return result;
+}
+
+// ─── Publish status polling ────────────────────────────────────────────────
+
+export interface TiktokPublishStatus {
+ status: string;
+ failReason?: string;
+ raw: unknown;
+}
+
+const TERMINAL_PUBLISH_STATUSES = new Set(['SEND_TO_USER_INBOX', 'PUBLISH_COMPLETE', 'FAILED']);
+
+// Polls with bounded backoff (TikTok allows max 30 status calls/min). For
+// draft uploads only SEND_TO_USER_INBOX means the draft actually reached the
+// creator's inbox.
+export async function pollTiktokPublishStatus(publishId: string, { maxWaitMs = 60_000 } = {}) {
+ const delays = [2_000, 4_000, 8_000, 15_000, 30_000];
+ const startedAt = Date.now();
+ let last: TiktokPublishStatus = { status: 'PROCESSING_UPLOAD', raw: null };
+
+ for (let i = 0; Date.now() - startedAt < maxWaitMs; i++) {
+ await sleep(delays[Math.min(i, delays.length - 1)]);
+ try {
+ const result = await tiktokApi('https://open.tiktokapis.com/v2/post/publish/status/fetch/', {
+ method: 'POST',
+ body: JSON.stringify({ publish_id: publishId }),
+ });
+ const data = result?.data as Record | undefined;
+ last = {
+ status: String(data?.status || 'UNKNOWN'),
+ failReason: typeof data?.fail_reason === 'string' && data.fail_reason ? data.fail_reason : undefined,
+ raw: data ?? null,
+ };
+ if (TERMINAL_PUBLISH_STATUSES.has(last.status)) return last;
+ } catch {
+ // Transient status-fetch failures must not fail an already-uploaded
+ // post; keep the last known state and try again within the window.
+ }
+ }
+ return last;
}