presseportale/public/api/v1.yml
Kevin Adametz 982de2aba2 api: Ziel-Portal einer Einreichung explizit auflösen statt vom Konto erben
Legacy-API-Kunden mit Keys beider Altportale — vor allem connektar — senden
jede Meldung an presseecho.de UND businessportal24.de. Weil die Firma ihr
Portal vom User und die Meldung es von der Firma erbte, landeten beide Kopien
im selben Portal, die zweite mit Slug-Suffix "-2". Verifiziert wurde das über
die Token-Hashes gegen die Legacy-Datenbanken.

Das Portal wird jetzt in fester Reihenfolge aufgelöst: expliziter
portal-Parameter, sonst die aufgerufene Domain, sonst das Portal des
Firmenprofils, zuletzt Portal::Both — genau der Default, den beide Spalten
ohnehin tragen. Die Auflösung ist damit nicht-nullbar; vorher konnte sie null
liefern, was als SQL-Fehler und damit als 500er beim Alt-Client gelandet wäre.
Ein Update über die v1-API verschiebt eine bestehende Meldung ausschließlich
über den expliziten Parameter, nie über die aufgerufene Domain.

press-releases:fix-api-portal-duplicates repariert die Altdaten: Es findet
Paare (gleicher User, Titel, Sprache und Portal, beide per API eingereicht,
kurz nacheinander) und verschiebt die zweite Kopie samt ihrer exklusiven Firma
ins jeweils andere Portal, mit neu vergebenem Slug. Web-Doppel-Submits ohne
Status-Log source=api und Legacy-Sync-Daten mit gesetzter legacy_id werden
geschont. Auf Live ausgeführt und verifiziert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 11:06:58 +00:00

353 lines
13 KiB
YAML

openapi: 3.1.0
info:
title: pressekonto Publisher API
version: "1.0"
description: |
REST-API des Publisher-Hubs (pressekonto) für presseecho.de und
businessportal24.com. Pressemitteilungen werden als Entwurf angelegt,
optional mit Bildern versehen und über die Submit-Route zur
Inhaltsprüfung eingereicht — eine direkte Veröffentlichung über die
API ist bewusst nicht möglich.
Authentifizierung über einen persönlichen API-Token (Bearer). Jeder
Token trägt Berechtigungen (Abilities), die den Zugriff auf die
Endpunkte steuern. Tokens werden im Publisher-Konto unter „API-Tokens"
selbst erstellt und widerrufen (Voraussetzung: aktive Buchung).
Rate-Limit: 60 Anfragen pro Minute je Token (Header X-RateLimit-Limit /
X-RateLimit-Remaining, bei 429 zusätzlich Retry-After).
Migration: Bestehende API-Schlüssel der Altportale wurden übernommen
und bleiben gültig — auch in der alten Übergabeform (X-ApiKey-Header)
und auf den dokumentierten /service/…-Pfaden. Unbekannte Alt-Schlüssel
erhalten 410 mit Migrationshinweis. Neue Integrationen nutzen bitte
Bearer-Tokens auf /api/v1.
servers:
- url: https://pressekonto.de/api/v1
security:
- bearerToken: []
tags:
- name: press-releases
description: "Eigene Pressemitteilungen (Ability: press-releases:read bzw. press-releases:write)"
- name: images
description: "Bilder einer Pressemitteilung (Ability: press-release-images:write zum Schreiben)"
- name: companies
description: "Eigene Firmenprofile/Newsrooms (Ability: companies:read)"
- name: categories
description: "Aktive Rubriken (Ability: press-releases:read)"
- name: newsletter
description: "Newsletter-Anmeldungen (Ability: newsletter:subscribe)"
paths:
/press-releases:
get:
tags: [press-releases]
summary: Eigene Pressemitteilungen auflisten
parameters:
- name: status
in: query
description: Nach Status filtern (draft, review, published, rejected, archived)
schema: { type: string }
- name: per_page
in: query
description: Seitengröße, Standard 25, max. 100
schema: { type: integer, maximum: 100 }
- name: page
in: query
schema: { type: integer }
responses:
"200":
description: Paginierte Liste
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/PressRelease" }
post:
tags: [press-releases]
summary: Pressemitteilung anlegen (immer als Entwurf)
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/PressReleaseInput" }
responses:
"201":
description: Entwurf angelegt
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/PressRelease" }
"403": { description: "Ability fehlt oder Firma gehört nicht zum Konto" }
"422": { description: "Validierungsfehler" }
/press-releases/{id}:
parameters:
- name: id
in: path
required: true
schema: { type: integer }
get:
tags: [press-releases]
summary: Pressemitteilung abrufen
responses:
"200":
description: Mitteilung inkl. Firma, Rubrik und Bildern
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/PressRelease" }
"403": { description: "Nicht gefunden oder fremdes Konto" }
patch:
tags: [press-releases]
summary: Pressemitteilung ändern (nur draft/rejected)
requestBody:
content:
application/json:
schema: { $ref: "#/components/schemas/PressReleaseInput" }
responses:
"200": { description: "Aktualisierte Mitteilung" }
"409": { description: "Nur Entwürfe und abgelehnte Mitteilungen sind änderbar" }
delete:
tags: [press-releases]
summary: Pressemitteilung löschen (nicht bei published)
responses:
"204": { description: "Gelöscht" }
"409": { description: "Veröffentlichte Mitteilungen können nicht per API gelöscht werden" }
/press-releases/{id}/submit:
post:
tags: [press-releases]
summary: Zur Inhaltsprüfung einreichen
description: |
Der einzige API-Weg Richtung Veröffentlichung. Durchläuft denselben
Funnel wie das Web-Formular (Richtlinien-Filter, Kontingent, Status-Log).
Der Aufruf erklärt verbindlich den deklarierten KI-Grad des Textes
(`ai_text_declaration`, Transparenzpflicht nach Art. 50 KI-VO) und
schließt die Kenntnisnahme der dauerhaften Archivierung ein
(AGB § 13: Depublikation nur als kostenpflichtiger Service,
gesetzliche Ansprüche unberührt).
parameters:
- name: id
in: path
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ai_text_declaration]
properties:
ai_text_declaration:
type: string
enum: [none, assisted, mostly_ai, fully_ai]
description: >
Deklarierter KI-Grad des Textes: ohne generative KI (none),
KI als Formulierungshilfe mit redaktioneller Überarbeitung
(assisted), überwiegend KI-erstellt und geprüft (mostly_ai),
vollständig KI-generiert (fully_ai). Ab mostly_ai trägt die
Veröffentlichung ein öffentliches Transparenz-Label.
responses:
"200": { description: "Eingereicht (Status review)" }
"402": { description: "Aktive Buchung erforderlich (Tarif oder Einzel-PM)" }
"409": { description: "Nur draft/rejected kann eingereicht werden" }
"422": { description: "KI-Deklaration fehlt/ungültig, Kontingent erschöpft oder Richtlinienverstoß" }
/press-releases/{id}/images:
parameters:
- name: id
in: path
required: true
schema: { type: integer }
get:
tags: [images]
summary: Bilder einer Mitteilung auflisten
responses:
"200":
description: Bilderliste
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/PressReleaseImage" }
post:
tags: [images]
summary: Bild hochladen (nur bei draft/rejected)
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [image, ai_declaration]
properties:
image:
type: string
format: binary
description: JPG, PNG oder WebP, max. 8 MB
ai_declaration:
type: string
enum: [original, ai_generated, ai_edited]
description: >
KI-Kennzeichnung des Bildes (Art. 50 KI-VO): Originalfoto,
KI-generiert oder KI-bearbeitet.
title: { type: string, maxLength: 120 }
description: { type: string, maxLength: 500 }
copyright: { type: string, maxLength: 255 }
is_preview:
type: boolean
description: Als Vorschaubild setzen (ersetzt ein vorhandenes)
responses:
"201": { description: "Bild gespeichert" }
"409": { description: "Mitteilung ist nicht mehr änderbar" }
"422": { description: "Ungültige Datei" }
/press-release-images/{imageId}:
delete:
tags: [images]
summary: Bild entfernen (nur bei draft/rejected)
parameters:
- name: imageId
in: path
required: true
schema: { type: integer }
responses:
"204": { description: "Gelöscht" }
"409": { description: "Mitteilung ist nicht mehr änderbar" }
/companies:
get:
tags: [companies]
summary: Eigene Firmenprofile auflisten
parameters:
- name: per_page
in: query
schema: { type: integer, maximum: 100 }
responses:
"200": { description: "Paginierte Liste eigener Firmen" }
/companies/{id}:
get:
tags: [companies]
summary: Firmenprofil abrufen
parameters:
- name: id
in: path
required: true
schema: { type: integer }
responses:
"200": { description: "Firmenprofil" }
"403": { description: "Nicht gefunden oder fremdes Konto" }
/categories:
get:
tags: [categories]
summary: Aktive Rubriken auflisten
responses:
"200": { description: "Rubriken inkl. Übersetzungen" }
/newsletter/subscribe:
post:
tags: [newsletter]
summary: Newsletter-Anmeldung anlegen (Double-Opt-in folgt per Mail)
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [portal, email]
properties:
portal:
type: string
enum: [presseecho, businessportal24]
email: { type: string, format: email, maxLength: 190 }
salutation_key: { type: string, maxLength: 20 }
first_name: { type: string, maxLength: 80 }
last_name: { type: string, maxLength: 80 }
responses:
"201": { description: "Anmeldung angelegt (unbestätigt)" }
components:
securitySchemes:
bearerToken:
type: http
scheme: bearer
description: "Persönlicher API-Token aus dem Publisher-Konto (Selfservice unter „API-Tokens")."
schemas:
PressReleaseInput:
type: object
required: [company_id, category_id, language, title, text]
properties:
portal:
type: string
enum: [presseecho, businessportal24]
description: >
Ziel-Portal der Veröffentlichung. Optional: Ohne Angabe wird das
Portal aus der aufgerufenen Domain bestimmt (presseecho.de bzw.
businessportal24.com); ein explizit gesetzter Wert hat Vorrang
vor der Domain. Fehlen beide (z. B. Aufruf über pressekonto.de),
gilt das Portal des Firmenprofils. Beim PATCH wechselt das Portal
ausschließlich über diesen Parameter — die aufgerufene Domain
verschiebt eine bestehende Mitteilung nicht.
company_id:
type: integer
description: ID eines eigenen Firmenprofils — bestimmt den Newsroom
category_id: { type: integer }
language:
type: string
minLength: 2
maxLength: 2
description: de oder en
title: { type: string, maxLength: 255 }
text:
type: string
description: "Erlaubtes HTML: p, br, h2, h3, strong, em, ul, ol, li, blockquote, a"
backlink_url: { type: string, format: uri, maxLength: 255 }
keywords: { type: string, maxLength: 255 }
teaser_begin: { type: integer, minimum: 0 }
teaser_end: { type: integer, minimum: 0 }
no_export: { type: boolean }
PressRelease:
type: object
properties:
id: { type: integer }
uuid: { type: string, format: uuid }
portal: { type: string }
language: { type: string }
title: { type: string }
slug: { type: string }
text: { type: string }
backlink_url: { type: [string, "null"] }
keywords: { type: [string, "null"] }
status:
type: string
enum: [draft, review, published, rejected, archived]
hits: { type: integer }
teaser:
type: object
properties:
begin: { type: [integer, "null"] }
end: { type: [integer, "null"] }
no_export: { type: boolean }
published_at: { type: [string, "null"], format: date-time }
company: { type: object }
category: { type: object }
images:
type: array
items: { $ref: "#/components/schemas/PressReleaseImage" }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
PressReleaseImage:
type: object
properties:
id: { type: integer }
title: { type: [string, "null"] }
description: { type: [string, "null"] }
copyright: { type: [string, "null"] }
is_preview: { type: boolean }
width: { type: integer }
height: { type: integer }
mime: { type: string }