outrnk. | Architektur, Betrieb, Admin-API
Nur fuer Betreiber — nicht an Kunden geben

Interne Dokumentation

INTERNAL — Architektur, Betrieb, Admin-API

Interne Dokumentation für Betreiber. Nicht an Kunden geben. Kundenseitige Dokumente: API.md und QUEUE.md. Betriebsabläufe (Deploy, Rollback, Notfall) stehen in ../RUNBOOK.md und werden hier nicht wiederholt, nur verlinkt.

Diese Datei ist die Quelle für public/admin-api-docs.html. Das HTML wird generiert, nie von Hand bearbeitet — siehe Dokumentation und Generator.


Inhalt

  1. Architektur in einem Bild
  2. Prozessmodell
  3. Netz, Bind-Adressen, Shared Secret
  4. Queue — Aufbau und Datenmodell
  5. Memory-Guard
  6. Browser-Liveness und Quarantäne
  7. Proxy-Relay und SSRF-Schutz
  8. Medienablage
  9. Webhooks intern
  10. Admin-API (JWT)
  11. Interne Instanz-API (X-Internal-Auth)
  12. Konfiguration und Umgebungsvariablen
  13. Deploy und Rollback

· 13.1 Inbetriebnahme der Queue — gestufte Schrittfolge

  1. Dokumentation und Generator
  2. Bekannte Grenzen und offene Punkte
  3. Abweichungen zwischen Kontrakt und Code

1. Architektur in einem Bild

                       Internet
                          │  TLS, nginx
                          ▼
        ┌──────────────────────────────────────────┐
        │  admin-server.js        :3001            │
        │  ─ Admin-Panel (JWT)                     │
        │  ─ Externe API (Bearer <apiKey>)         │
        │  ─ 6 Bestandsrouten  +  ~45 v2-Routen    │
        │  ─ reine Durchreichung, KEINE Umformung  │
        └───────┬───────────────┬──────────────────┘
                │  HTTP 127.0.0.1, X-Internal-Auth
      ┌─────────▼───┐     ┌─────▼───────┐    …
      │ instance-   │     │ instance-   │
      │ server.js   │     │ server.js   │      ein Kindprozess je Instanz
      │ :3013       │     │ :3014       │      Bind ausschliesslich 127.0.0.1
      │             │     │             │
      │ ┌─────────┐ │     │ ┌─────────┐ │
      │ │ lib/    │ │     │ │ lib/    │ │      Outbox-Queue je Prozess,
      │ │ queue   │ │     │ │ queue   │ │      genau EIN Schreiber je Instanz
      │ └────┬────┘ │     │ └────┬────┘ │
      │      │      │     │      │      │
      │ ┌────▼────┐ │     │ ┌────▼────┐ │
      │ │ wwebjs  │ │     │ │ wwebjs  │ │      whatsapp-web.js 1.34.7
      │ │ + Chrome│ │     │ │ + Chrome│ │      LocalAuth, sessions/<id>/
      │ └─────────┘ │     │ └─────────┘ │
      └──────┬──────┘     └──────┬──────┘
             │                   │
             └────────┬──────────┘
                      ▼
        PostgreSQL 17.7, DB chatbot_management, Schema wa_gateway

Redis läuft auf dem Host, wird von uns aber nicht benutzt. Begründung siehe §4.


2. Prozessmodell

ProzessDateiStartPort
Gateway/Adminadmin-server.jsPM2 (ecosystem.config.js), Prozessname whatsapp-api3001
Instanzinstance-server.jsKindprozess, gespawnt von admin-server.js3010+ je Instanz
  • Eine Instanz = ein Node-Prozess = ein Chrome-Baum = eine WhatsApp-Nummer.
  • Der Kindprozess erbt process.env vom Admin-Prozess. Alle QUEUE_*-Variablen und das

Shared Secret brauchen deshalb keinen eigenen Übergabeweg.

  • Die Instanzkonfiguration (id, name, apiKey, port, proxy, webhook, queue) kommt als

INSTANCE_CONFIG-JSON in der Umgebung des Kindprozesses.

  • Registry: data/instances.json. Die einzige Quelle der Wahrheit für Limits.

loadInstances() ergänzt fehlende queue-Blöcke beim Lesen aus DEFAULTS_EXISTING — die Migration der Registry ist damit ein reiner Lesevorgang, es gibt keine Migrationsdatei.

  • Autostart nach einem Neustart des Gateways: gestaffelt, AUTOSTART_STAGGER_MS.

Wichtig: genau ein Schreiber je Instanz

Die Queue-Zähler werden nicht separat geführt, sondern per Indexabfrage aus genau den Zeilen abgeleitet, die den Versand protokollieren. Sie können deshalb nicht driften. Das funktioniert, weil es je Instanz genau einen schreibenden Prozess gibt. Diese Annahme darf nicht gebrochen werden — zwei Prozesse auf derselben instance_id würden sich gegenseitig über das Limit schieben, obwohl FOR UPDATE SKIP LOCKED Doppelversand verhindert.


3. Netz, Bind-Adressen, Shared Secret

RegelUmsetzung
Instanzen sind nie extern erreichbarapp.listen(PORT, process.env.INSTANCE_BIND_HOST \|\| '127.0.0.1') (instance-server.js:3577)
Jeder Aufruf des Gateways an eine Instanz trägt das Shared SecretinternalHeaders() setzt X-Internal-Auth: $INSTANCE_SHARED_SECRET
Die Instanz prüft es zeitkonstantsafeCompareSecret(req.get('X-Internal-Auth'), process.env.INSTANCE_SHARED_SECRET) (instance-server.js:250)
Ausnahmen von der Prüfungausschliesslich /health und /health/deep (INTERNAL_AUTH_ALLOWLIST)
Fehlt/falsch401 {code:'INTERNAL_AUTH_REQUIRED'}
Das Secret darf den Loopback nie verlassenDer v2-Proxy setzt maxRedirects: 0. Ohne das würde axios einem Redirect mit dem Header folgen.

INSTANCE_SHARED_SECRET ist Pflicht. Ohne den Schlüssel bricht deploy.sh ab. Die .env muss 0600 gehören — sie enthält Shared Secret, JWT_SECRET, Admin-Passwort und die DB-URL.

INSTANCE_AUTH_MODE=audit ist ein Notausgang, kein Betriebsmodus. In diesem Modus wird ein Aufruf ohne gültiges X-Internal-Auth durchgelassen und nur als WARN geloggt (unauthenticated internal request …). Gedacht ist er ausschliesslich für die Umstellungsphase, in der man sehen will, wer den Header noch nicht setzt. Default und Zielzustand ist enforce. Wer ihn dauerhaft auf audit stehen lässt, hat die Loopback-Grenze effektiv aufgehoben: jeder Prozess auf der Maschine — inklusive des fremden Chatbot-Dienstes unter derselben UID — darf dann senden. Nach dem Deploy prüfen: grep INSTANCE_AUTH_MODE .env muss leer sein oder enforce liefern.

Ein Trailing Slash wird vor der Prüfung normalisiert, damit /health/ nicht fälschlich 401 liefert.

Externe Autorisierung

EbeneVerfahren
Admin-Panel und alle /api/instances/**JWT, 24 h Laufzeit, POST /api/auth/login
Kunden-API (6 Bestandsrouten + alle /api/v2/**)Authorization: Bearer <apiKey>, validateApiKey, zeitkonstanter Vergleich, zusätzlich Bedingung instance.status === 'online'
QR-FreigabelinkshareToken, abgeleitet je Instanz, /qr/:instanceId/:shareToken
/health am Gatewaykeine — deshalb dort nur Zähler, keine IDs, keine Namen

4. Queue — Aufbau und Datenmodell

Warum PostgreSQL und nicht Redis

Der Host-Redis läuft mit appendonly no und save 3600 1 300 100 60 10000. Bei einem Host-Crash sind damit bis zu 60 Sekunden Schreibvorgänge verloren. Zusammen mit maxmemory 0 und noeviction nimmt eine volllaufende Queue ausserdem den 7,7-GB-Host mit — inklusive aller Kundeninstanzen. Beides disqualifiziert Redis als Quelle der Wahrheit, nicht als Meinung, sondern per Konfiguration. Der einzige echte Redis-Vorteil (atomares INCR über Prozessgrenzen) wird nicht gebraucht, weil es je Instanz genau einen Schreiber gibt.

Ablage

Schema wa_gateway in der bestehenden DB chatbot_management. Die Rolle chatbot darf kein neues DB anlegen (rolcreatedb=f), aber ein neues Schema. Alle Statements sind explizit schema-qualifiziert, kein Verlass auf search_path.

TabelleZweck
wa_gateway.wa_outboxeine Zeile je Nachricht, inkl. Zustand, Zeitplan, Ergebnis
wa_gateway.wa_instance_statelast_dispatch_at, plan_epoch, limits_hash je Instanz
wa_gateway.wa_mediaMetadaten der Medienablage (DDL vorhanden; der Code führt den Index heute in data/media/<instanceId>/index.json, siehe §16)

Migration:

npm run queue:migrate      # legt Schema/Tabellen/Indizes idempotent an
npm run queue:verify       # prueft Existenz, exit 0/1
npm run queue:test         # Selbsttests ohne DB
npm run queue:test:pg      # Selbsttests gegen die echte DB

DDL liegt in lib/queue/schema.sql, der Runner in lib/queue/migrate.js.

Module

DateiVerantwortung
lib/queue/index.jsFassade: init, submit, awaitTerminal, get, list, cancel, requeue, applyConfig, summary, pause, drainInFlight, shutdown
lib/queue/accept.jsAnnahmepfad T1+T2 in einer Transaktion (Idempotenz → Kapazität → INSERT → Advisory-Lock → Planung)
lib/queue/store.js / store-reads.jsSämtliches SQL, schema-qualifiziert
lib/queue/scheduler.jsReiner Planungskern (computePlan, planCursor, resolveSlot, randDelay, exitTime) plus store-gebundener Planner
lib/queue/tz.jsapplyQuietHours über Intl.DateTimeFormat, DST-sicher, ohne neue Abhängigkeit
lib/queue/dispatcher.jsSendeschleife, single-flight, Zustandsübergänge T4–T9, Fehlerklassifikation
lib/queue/recovery.jsWiederanlauf beim Start, Reaper (60 s), Retention (7 Tage, stündlich)
lib/queue/gate.jsLimitprüfung unmittelbar vor dem Versand. Reine Funktion. Wird sowohl vom PG-Dispatcher (im Claim) als auch vom Memory-Fallback ausgewertet.
lib/queue/job.jsNormalisiert die Speicherzeile (PG snake_case oder Fallback camelCase) auf ein Job-Schema für sendFn. Genau einmal, im Kern.
lib/queue/memory-fallback.jsDegraded-/Disabled-Modus, kapselt die alte In-Memory-MessageQueue, wertet aber dasselbe Gate aus
lib/queue/limits.jsDefaults, Validierung, limitsHash, needsReplan
lib/queue/ids.js, view.jsjobId/mediaId/eventId, QueueError, Zeile → API-Objekt

Zwei Schichten, nicht eine

scheduled_at ist eine Planung, keine Durchsetzung. Der Planner verteilt, das Gate setzt durch. Ohne die zweite Schicht wird jeder Stillstand (Disconnect, geordneter Restart, Limitänderung, Uhrumstellung, Wiederanlauf) zu einem Burst, weil schlagartig viele Slots gleichzeitig fällig sind — exakt das Muster, auf das WhatsApps Spam-Erkennung reagiert. Verletzt ein Job das Gate, geht er mit neuem scheduled_at zurück in die Warteschlange und wird nicht gesendet.

Queue.limitsEnforced() sagt, ob gerade durchgesetzt wird: true in pg und in memory-degraded, false nur in disabled. Der Wert steht in stats() und summary().

Zustände und die eine Regel

queued → scheduled → sending → {sent | failed | unknown}, dazu cancelled und expired.

scheduled verlässt den Prozess nicht. Die Unterscheidung queued (kein Slot vergeben) / scheduled (Slot steht) ist eine interne Planungsstufe, keine fachliche Aussage — in beiden Fällen ist die Nachricht angenommen und noch nicht raus. v2SubmitResponse() (instance-server.js:3245) bildet deshalb scheduled → queued ab, bevor geantwortet wird; die Rohstufe bleibt daneben als queueState sichtbar. Terminale Zustände werden nicht eingeebnet, sonst meldete ein Idempotenz-Replay einer längst zugestellten Nachricht queued. /api/send-bulk antwortet aus demselben Grund je Eintrag mit status:'queued'. Ebenso gibt es kein message.scheduled-Ereignis: die Annahme feuert immer message.queued (lib/queue/accept.js:138).
INVARIANTE D: Sobald der wwebjs-Aufruf angestossen wurde, gibt es keinen Uebergang
mehr in einen sendefaehigen Zustand. Weder durch Code, noch durch Recovery,
noch durch einen Operator.

Der Zustandswechsel nach sending wird vor dem wwebjs-Aufruf committed. Findet ein Neustart sending-Zeilen vor, werden sie zu unknown mit process_crash_while_sending — nie zu einem Retry. Genau diese Regel hält den historischen Duplikat-Vorfall geschlossen.

Es gibt keinen automatischen Retry. Die einzige Rückkehr aus sending ist der Fall "beweisbar noch nicht gesendet" (WhatsApp getrennt, Seite während der Nummernvalidierung gestorben) mit Backoff 5 s → 60 s, maximal 10 Mal, danach failed / REQUEUE_EXHAUSTED.

Modi

ModusAuslöserPersistentLimits/api/v2/send-*
pgqueue.enabled = true, PG erreichbarjadurchgesetzt (Gate im Claim)normal
memory-degradedenabled = true, PG beim Start oder zur Laufzeit wegneindurchgesetzt (Gate im Fallback)503 QUEUE_STORE_UNAVAILABLE
disabledqueue.enabled = false (Default aller Bestandsinstanzen)neinkeine (limitsEnforced: false) — die Zufallspause minDelayMs…maxDelayMs läuft trotzdem, genau wie in der alten In-Memory-Queue503 QUEUE_STORE_UNAVAILABLE
unavailableModul liess sich nicht ladenneinkeine503

Laufzeit-Umschaltung. Queue._onStoreFailure() prüft über Queue.isConnectivityError(), ob ein Speicherfehler „die Datenbank ist gerade nicht da" bedeutet (PG-SQLSTATE 08*, 57P0*, 53300/53400 und die üblichen Socket-Codes). Nur dann wird umgeschaltet — ein Constraint-Verstoss oder ein SQL-Tippfehler ist ein Programmfehler und darf die Instanz nicht still in den Fallback rutschen lassen. Alle übrigen Speicherfehler kommen als QueueError('QUEUE_STORE_UNAVAILABLE', 503) heraus statt als nackter pg-Fehler mit 500.

Rückweg: Reconnect-Probe alle QUEUE_DEGRADED_PROBE_MS (30 s). Bei Erfolg wird der In-Memory-Rückstand zuerst leergefahren (_adoptFromMemory), dann übernimmt der Dispatcher. Das im Moment des Umschaltens laufende Item bleibt beim Fallback — eine Übernahme wäre ein Duplikat.

Angenommene Items im Degraded-Modus werden zusätzlich forensisch nach data/queue/<instanceId>.degraded.jsonl geschrieben. Kein automatisches Replay — das würde exakt den Duplikat-Vorfall reproduzieren.

Im Degraded-Modus fallen die message.*-Webhooks aus. lib/queue/memory-fallback.js hat keinen einzigen emit-Aufruf; message.queued/sent/failed/unknown kommen ausschliesslich aus accept.js und dispatcher.js, also nur aus dem PG-Pfad. Gesendet wird weiter, benachrichtigt nicht. Die Lücke ist von aussen exakt eingerahmt: queue.degraded markiert ihren Anfang, queue.recovered ihr Ende. Wer Geschäftslogik an message.sent hängt, muss diese Spanne nachziehen (GET /api/v2/queue/items, sobald mode wieder pg ist). Das gehört in die Ankündigung an jeden Kunden, der Queue-Events abonniert — bisher tut das niemand, weil keines dieser Ereignisse in WEBHOOK_DEFAULT_EVENTS steht.

QUEUE_PAUSED gilt bewusst nur für /api/v2/*. Die sechs Bestandsrouten kennen dieses Fehlerbild heute nicht; sie werden während eines geordneten Restarts angenommen und warten im normalen Ablauf. Der LEGACY_SYNC_TIMEOUT_MS-Timeout (Default 20 s, gedeckelt auf 25 s) ist dort die vorhandene Absicherung.


5. Memory-Guard

Ein Chrome-Baum je Instanz auf einem 7,7-GB-Host. Der Guard misst den Prozessbaum, nicht nur den Node-Heap.

VariableDefaultBedeutung
INSTANCE_MEM_CHECK_MS60000Messintervall
INSTANCE_MEM_SOFT_MB2600weiche Grenze, geordneter Selbst-Restart
INSTANCE_MEM_HARD_MB3000harte Grenze
INSTANCE_MEM_HOST_MIN_AVAIL_MB1000unterschreitet der Host das, greift der Guard auch unterhalb der Instanzgrenze
INSTANCE_MEM_GRACE_MS1800000Schonfrist nach dem Start
INSTANCE_MEM_MIN_INTERVAL_MS21600000Mindestabstand zwischen zwei speichergetriebenen Restarts

Werte stehen live in /health.memory (heapUsedMB, rssMB, browserTreeMB, totalMB, softLimitMB, hardLimitMB, hostAvailMB).

Der geordnete Restart (orderedRestart()) setzt sendPaused = true, pausiert den Dispatcher, wartet bis zu 35 s auf das laufende Item (drainInFlight), speichert den Message-Store und startet erst dann neu. Ein sauberer Restart hinterlässt deshalb keine sending-Zeile und erzeugt kein unknown.

Herleitung der Schwellen und die ehrliche Grenze ("Ausreisser-Detektor, kein Kapazitätsplan"): RUNBOOK.md §8.


6. Browser-Liveness und Quarantäne

Der klassische Zombie: Node lebt, WhatsApp Web meldet weiterhin connected und empfängt Nachrichten, aber client.info ist weg und senden ist unmöglich. Das erholt sich nie von selbst.

VariableDefaultBedeutung
BROWSER_PROBE_MS60000Probe-Intervall
BROWSER_PROBE_TIMEOUT_MS15000Timeout je Probe
BROWSER_PROBE_FAILS5Hard-Fails hintereinander ⇒ Browser gilt als tot
BROWSER_PROBE_SOFT_FAILS10Soft-Fails hintereinander ⇒ Restart
  • GET /health liefert 503, sobald der Browser als tot gilt — das ist die einzige

Verhaltensänderung gegenüber früher. /api/status bleibt bewusst unverändert und leitet status/connected weiterhin nur aus clientStatus ab, weil externe Kundenintegrationen darauf matchen. Die Browser-Wahrheit steht rein additiv in data.browser.

  • GET /health/deep erzwingt eine sofortige Probe (statusneutral, ändert browserState nicht).
  • Zusätzlich: clientStatus === 'authenticated' länger als AUTH_STUCK_MS gilt als hängend.

Quarantäne (admin-server.js): häufen sich Restarts im Fenster RESTART_WINDOW_MS, wird die Instanz auf status = 'quarantined' gesetzt, quarantinedAt/quarantineReason gefüllt und ein Alarm an ALERT_WEBHOOK_URL geschickt. Eine quarantänierte Instanz wird nicht automatisch wieder gestartet — nur über POST /api/instances/:id/start. Vorgehen: RUNBOOK.md §4.


7. Proxy-Relay und SSRF-Schutz

Ist für eine Instanz ein SOCKS5-Upstream konfiguriert, öffnet der Prozess ein lokales Relay auf 127.0.0.1:<relayPort> und schickt allen Node-Egress (Medien-Download, Webhooks an externe Ziele) als socks5h:// dorthin — Remote-DNS, eine Credential-Quelle, ein Pfad. Chrome bekommt zusätzlich --host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE 127.0.0.1.

Webhooks an Loopback-Ziele (z. B. der Chatbot auf 127.0.0.1:3000) gehen bewusst nicht über den Proxy. Ist das Relay nicht bereit, wird der Webhook verworfen und laut geloggt statt ungeproxyt rauszugehen (fail-closed).

SSRF-Prüfung für mediaUrl (isPublicHttpUrl + resolveMediaUrl):

  1. Schema http/https, keine privaten/Loopback-/Link-Local-Ziele, DNS-Auflösung wird geprüft.
  2. Redirects werden selbst verfolgt (redirect: 'manual'), jede Location erneut geprüft,

maximal 3 Hops.

  1. Vorprüfung per HEAD, nicht per Range-GET — ein Vorab-GET entwertet signierte Einmal-URLs.

Antwortet der Server nicht sinnvoll auf HEAD, wird die bereits validierte Original-URL unverändert benutzt; der finale Abruf läuft mit redirect:'manual' und kann nicht umgelenkt werden.

  1. Grössenabbruch bei MEDIA_MAX_BYTES (16 MiB) durch node-fetch.
  2. Die Prüfung läuft beim Dispatch ein zweites Mal. Zwischen Annahme und Versand können

Stunden liegen und DNS kann sich ändern.


8. Medienablage

AspektWert
Eingehende Medien speichernOpt-in je Instanz: queue.storeIncomingMedia, Default false
Ablageortdata/media/<instanceId>/<YYYY-MM-DD>/<mediaId>.<ext>, Datei 0600, Verzeichnis 0700
mediaIdmd_ + 32 Hexzeichen, geprüft gegen ^md_[0-9a-f]{32}$
Indexdata/media/<instanceId>/index.json
AufbewahrungMEDIA_RETENTION_HOURS, Default 72
Grenze je DateiMEDIA_STORE_MAX_BYTES, 16 MiB — darüber stored:false, reason:'too_large'
Grenze je InstanzMEDIA_STORE_MAX_MB, 2048 — LRU-Verdrängung der ältesten Dateien
Globale Bremseunter MEDIA_LOW_DISK_MIN_MB (5120) freiem Platz wird die Ablage abgeschaltet, stored:false, reason:'low_disk', Alarm media_low_disk
Ausgehende Bytesdata/outbox-media/<jobId>.bin, gelöscht beim Übergang in einen terminalen Zustand
PfadsicherheitDer Pfad wird ausschliesslich aus rec.relPath gebildet, nie aus der URL. Path-Traversal ist ausgeschlossen.

Bytes gehören nicht in wa_outbox.payload. In der Zeile steht nur eine media_ref: {type:'url'|'file'|'store', …}.


9. Webhooks intern

  • Opt-in je Instanz. Es gibt keinen globalen WEBHOOK_URL-Fallback mehr — der hätte alle

Bestandsinstanzen auf einen Schlag scharfgeschaltet.

  • Default-Events ohne eigene Auswahl: message, ready, disconnected, instance_restarting, test.

Bewusst ohne message_ack/message_sent/message_reaction (bis zu 4 Events je Nachricht).

  • * abonniert alles.
  • Umschlag v2: {v, eventId, event, instanceId, timestamp, data}. event/instanceId/

timestamp/data behalten Name, Typ und Reihenfolge — der Chatbot bricht nicht.

  • Signatur: der Body wird einmal serialisiert; genau diese Bytes werden signiert und

gesendet. Zwei Formen parallel: X-Webhook-Signature: sha256=<hex> (Legacy, das ist was der Chatbot erwartet) und X-Webhook-Signature-V2: t=<ts>,v1=<hex> über "<ts>.<body>".

  • Hochfrequente Events: 1 Versuch statt 3 (message_ack, message_sent,

message_reaction, unread_count, media_uploaded, message_ciphertext, message_ciphertext_failed, change_battery). Ohne das flutet ein toter Empfänger das Log — das war der 414-MB-Vorfall.

  • remote_session_saved wird nicht abonniert: es feuert nur unter RemoteAuth, wir nutzen

LocalAuth.

  • Das Event code transportiert den Pairing-Code. Er darf in keiner Logzeile stehen.
  • Jeder der 19 zusätzlichen Listener läuft in try/catch und mit .catch(). Eine geworfene

Exception in einem Listener würde sonst zur unhandledRejection und träfe verifyBrowserOrRestart — ein Mapper-Bug würde die ganze Instanz neu starten.


10. Admin-API (JWT)

Basis: https://wa.outrnk.io. Alle Routen ausser /api/auth/login und /health brauchen Authorization: Bearer <JWT>.

Anmelden

TOKEN=$(curl -s -X POST "$WA_URL/api/auth/login" \
  -H "Content-Type: application/json" \
  -d '{"username":"'"$ADMIN_USERNAME"'","password":"'"$ADMIN_PASSWORD"'"}' \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["token"])')

Token läuft nach 24 h ab.

Instanzverwaltung

MethodePfadZweck
GET/api/instancesListe. sanitizeInstance entfernt proxy.password und webhookSecret; der apiKey ist enthalten (sonst bräche das Panel).
GET/api/instances/:id/api-keyGezielter Abruf, erzeugt eine eigene WARN-Logzeile
POST/api/instances{name, proxy?, webhookUrl?, webhookSecret?, webhookEvents?, queue?} — ohne queue gilt DEFAULTS_NEW
PUT/api/instances/:idFelder ändern. id/apiKey/port/createdAt/status sind gegen Mass-Assignment gesperrt, queue ist erlaubt.
DELETE/api/instances/:idInstanz entfernen
POST/api/instances/:id/startStarten. Hebt eine Quarantäne auf — der einzige Weg dafür.
POST/api/instances/:id/stopStoppen
GET/api/instances/:id/qrQR der Instanz
GET/api/instances/:id/statusRegistry + live (Antwort von /api/status der Instanz). Aktualisiert nebenbei queueRuntime.
GET/api/instances/:id/share-tokenToken für den öffentlichen QR-Link
curl -s -X POST "$WA_URL/api/instances" -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"kunde-xy","queue":{"enabled":true,"maxPerHour":40,"maxPer24h":500}}'

Webhooks

MethodePfadBody
GET/api/instances/:id/webhook
POST/api/instances/:id/webhook{url, secret?, events?}
DELETE/api/instances/:id/webhook

Reihenfolge: erst persistieren, dann live pushen. Vorher überlebte die Konfiguration keinen Instanz-Neustart (sie lag nur im Heap).

Limits und Queue

MethodePfadZweck
GET/api/instances/:id/limits{queue, recommended, defaults, runtime, live, liveError} — die persistierte Konfiguration kommt immer, auch wenn die Instanz steht
PUT/api/instances/:id/limitsSetzen. Akzeptiert flach ({maxPerHour:40}) und umschliessend ({queue:{…}}). Erst saveInstances(), dann Live-Push.
GET/api/instances/:id/queueDurchreichung von /api/v2/queue
GET/api/instances/:id/queue/itemsDurchreichung inkl. Querystring
DELETE/api/instances/:id/queue/items/:jobIdJob abbrechen
POST/api/instances/:id/queue/replanReplan erzwingen — siehe §16, aktuell wirkungslos
curl -s -X PUT "$WA_URL/api/instances/$ID/limits" -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"enabled":true,"maxPerHour":40,"maxPer24h":500,"minDelayMs":4000,"maxDelayMs":12000,
       "quietHours":{"start":"22:00","end":"07:00","tz":"Europe/Berlin"}}'

Antwort: {id, queue, persisted:true, livePushed:boolean, liveError:string|null}. Ist die Instanz nicht erreichbar, ist persisted trotzdem true — beim nächsten Start greift die Konfiguration.

Ist die Instanz gestoppt, antworten die Queue-Routen mit 503 INSTANCE_UNREACHABLE; im data-Block steht der persistierte Stand als Kontext. Das ist im Panel ein normaler Betriebszustand, keine Ausnahme.

Nachrichten und Chats (Panel-Ansicht)

MethodePfad
GET/api/instances/:id/chats
GET/api/instances/:id/chats/:chatId/messages?limit=
GET/api/instances/:id/messages/recent?limit=

Gateway-Health

GET /health (öffentlich, keine Auth):

{ "success": true, "service": "WhatsApp Multi-Session API", "version": "1.0.0",
  "timestamp": "…",
  "instances": { "total":5, "online":4, "offline":0, "starting":1, "degraded":0, "quarantined":0 },
  "queue": { "degraded": 0 } }

total/online/offline heissen aus Kompatibilität weiter so. starting/degraded/quarantined sind neu, damit online + offline < total nicht länger stillschweigend Instanzen aus der Überwachung fallen lässt. Bewusst nur Zähler: die Route ist unauthentifiziert. Details (quarantinedAt, quarantineReason) stehen in der JWT-geschützten Instanzliste und gehen zusätzlich per ALERT_WEBHOOK_URL raus.

queue.degraded > 0 bedeutet: mindestens eine laufende Instanz meldet den Modus memory-degraded — PostgreSQL ist für sie gerade nicht erreichbar. Die Limits gelten dort weiter: lib/queue/memory-fallback.js ruft dasselbe evaluateGate() auf wie der PG-Pfad, und queue.degraded als Webhook trägt limitsEnforced: true. (Die frühere Fassung dieses Absatzes behauptete das Gegenteil; das war der Stand vor Entscheidung F4 und ist seither falsch — ein DB-Ausfall wäre sonst die einfachste Methode gewesen, die Drosselung abzuschalten.)

Der verbleibende Unterschied ist einer, und er ist real: der Zähler des Fallbacks ist prozesslokal. Er lebt im Heap des Instanzprozesses (_dispatchTimes), startet beim Umschalten bei null und verschwindet beim Neustart. Das 24-h-Fenster aus der Datenbank geht im Moment des Umschaltens verloren. Folgen, in dieser Reihenfolge:

  • Die Drosselung wird im Ausfallfenster zu grosszügig, nie zu streng — schon versandte

Nachrichten der letzten Stunde zählen nicht mehr mit. In der Spitze kann eine Instanz direkt nach dem Umschalten maxPerHour ein zweites Mal ausschöpfen.

  • Ein Prozessneustart im Degraded-Modus setzt den Zähler erneut auf null.
  • remainingHour / remaining24h in den API-Antworten sind in diesem Modus null

(limitsBlockLive() zählt nur im Modus pg) — Kontingente sind also nicht ablesbar, obwohl sie durchgesetzt werden.

Zum Unterscheiden der Modi ohne Ratespiel: GET /api/v2/queue liefert mode, limitsEnforced und degradedSince. queue.degraded in /api/status ist dagegen ein grober Monitoring-Boolean (queueMode() !== 'pg') und ist deshalb auch im Default-Modus disabled true — nur der Zähler in /health filtert auf memory-degraded.

Externe v2-Durchreichung — Grundsätze

Der Admin-Server reicht alle /api/v2/** 1:1 durch und formt nichts um:

  1. Statuscode und Body der Instanz gehen unverändert nach aussen (validateStatus: () => true).

Bei v2 ist der Statuscode Teil des Kontrakts: 202 (neu eingereiht) gegen 200 (Idempotenz-Replay). Die alten sechs Routen kollabieren jeden Fehler auf 500 — genau das darf hier nicht passieren.

  1. Der Header X-Idempotency-Key reist mit. Wer ihn verliert, macht die zentrale Zusage der

Welle C wirkungslos.

  1. maxRedirects: 0 — das Shared Secret darf den Loopback nie verlassen.
  2. Binäre Routen (…/media) werden gestreamt, nicht gepuffert.
  3. Log auf debug, nicht info — eine Zeile je Aufruf war der Hauptverursacher der 414-MB-Datei.
  4. Transportfehler: 504 INSTANCE_TIMEOUT (Nachricht kann angenommen worden sein) gegen

502 INSTANCE_UNREACHABLE (Instanz hat den Request nie gesehen). Der Unterschied ist für den Aufrufer entscheidungsrelevant.

Timeouts: Senden 60 s, Interaktion/Lesen 30 s, Queue-Status 10 s, Upload 120 s.


11. Interne Instanz-API (X-Internal-Auth)

Nur über Loopback erreichbar. Alles ausser /health und /health/deep braucht den Header.

MethodePfadZweck
GET/health, /health/deepohne Auth, siehe §6
GET/api/status, /api/qrZustand
POST/api/send-message, /api/send-media, /api/send-bulkBestandsrouten
POST/api/send-directNicht extern durchgereicht. Umgeht Queue und sendSeen per pupPage.evaluate. Diagnosewerkzeug.
POST/api/send-rawNicht extern durchgereicht. Diagnosewerkzeug.
GET/api/chats, /api/chats/:chatId/messages, /api/messages/recent, /api/messages/statsMessage-Store (lokal, kein Puppeteer)
GET/POST/DELETE/api/webhook, POST /api/webhook/testWebhook-Konfiguration
GET/api/check-number/:phone, /api/contact/:phone, /api/contact/:phone/pictureKontakte
GET/api/queuealte In-Memory-Statistik
GET/POST/api/limitsQueue-Konfiguration lesen/live setzen — Muster exakt wie POST /api/webhook
alle/api/v2/**siehe API.md

POST /api/limits akzeptiert {queue:{…}} oder den flachen Block, validiert über lib/queue/limits.js und ruft queueCore.applyConfig(). Schlägt der Live-Push fehl, antwortet die Route 500 QUEUE_APPLY_FAILED — die Persistenz liegt beim Admin-Server und ist davon unberührt.


12. Konfiguration und Umgebungsvariablen

Vollständige Liste mit Kommentaren: .env.example. Hier die Schlüssel, die man kennen muss.

Pflicht

VariableBedeutung
INSTANCE_SHARED_SECRETLoopback-Autorisierung Gateway ↔ Instanz. Ohne bricht deploy.sh ab.
JWT_SECRETAdmin-Token
ADMIN_USERNAME, ADMIN_PASSWORDPanel-Login

Queue

VariableDefaultBedeutung
QUEUE_PG_URLpostgresql://<user>:<pw>@127.0.0.1:5432/chatbot_management
QUEUE_SCHEMAwa_gatewaySchema
QUEUE_PG_POOL_MAX3Budget: 5 Instanzen × 3 + Admin 2 = 17 von max_connections=100. Gleichzeitige Verbraucher je Instanz sind Dispatcher-Claim, Planner und Annahmepfad — bei 2 lief davon regelmässig einer in connectionTimeoutMillis. Quelle: lib/queue/index.js (process.env.QUEUE_PG_POOL_MAX \|\| 3).
QUEUE_PG_CONNECT_TIMEOUT_MS3000
QUEUE_PG_STATEMENT_TIMEOUT_MS5000
QUEUE_MAX_PENDING5000422 QUEUE_FULL
QUEUE_INLINE_REPLAN_MAX500Obergrenze für einen Replan im Annahmepfad
QUEUE_DEGRADED_PROBE_MS30000Reconnect-Probe
QUEUE_SUMMARY_REFRESH_MS15000Zwischenspeicher für /health und /api/status
LEGACY_SYNC_TIMEOUT_MS20000Wartezeit der v1-Routen auf das Ergebnis. Hier stand bis zum Review 40000 mit der Begründung „unter dem 45-s-axios-Timeout des Gateways". Beides war falsch: der Code-Default ist 20000 (instance-server.js:186) und der massgebliche Bezugspunkt ist nicht das Gateway, sondern der Aufrufer. Der Wert wird zusätzlich hart auf max(5000, LEGACY_CALLER_TIMEOUT_MS − 5000) gedeckelt, heute also auf 25 s — eine .env-Fehlkonfiguration kann den Duplikat-Vorfall nicht zurückholen. Der Sekundenwert erscheint wörtlich im 504-Fehlertext.
LEGACY_CALLER_TIMEOUT_MS30000Angenommene Geduld des Aufrufers (Chatbot). Nur Bezugsgrösse für den Deckel oben, kein eigener Timer.
LEGACY_RETRY_GRACE_MS120000Fenster, in dem ein inhaltsgleicher v1-Sendewunsch nach Abbruch/504 als Wiederholung gilt und denselben Idempotenzschlüssel bekommt.
LEGACY_IDEM_MAX_ENTRIES2000Deckel der Fingerabdruck-Tabelle (~240 KB).

pg-native ist verboten — es braucht libpq + node-gyp und würde npm ci brechen.

Medien

MEDIA_MAX_BYTES 16 MiB · MEDIA_BASE64_MAX_BYTES 7 MiB · MEDIA_UPLOAD_MAX_BYTES 64 MiB · MEDIA_RETENTION_HOURS 72 · MEDIA_STORE_MAX_BYTES 16 MiB · MEDIA_STORE_MAX_MB 2048 · MEDIA_LOW_DISK_MIN_MB 5120 · MEDIA_ALLOW_HTTP · MEDIA_URL_ALLOWLIST

v2-Grenzen

INTERACTION_RATE_POINTS 60 / INTERACTION_RATE_DURATION_S 60 · FETCH_MESSAGES_MAX 200 / FETCH_MESSAGES_DEADLINE_MS 30000 · V2_MAX_BULK_ITEMS 500

Betrieb

ALERT_WEBHOOK_URL · AUTOSTART_STAGGER_MS (15000) · INSTANCE_BIND_HOST (127.0.0.1) · ADMIN_BIND_HOST · INSTANCE_AUTH_MODE (enforce, siehe §3) · INSTANCE_MEM_* · BROWSER_PROBE_* · BROWSER_GRACE_MS · READY_TIMEOUT_MS · RESTART_WINDOW_MS / RESTART_MAX_IN_WINDOW (Quarantäne-Schwelle) · RESTART_INIT_TIMEOUT_MS / RESTART_DESTROY_TIMEOUT_MS · ORDERED_RESTART_HARD_MS · OUTBOX_MEDIA_TTL_MS · PROXY_RELAY_PORT_OFFSET / PROXY_SELFTEST_* · WEBHOOK_DEFAULT_EVENTS · INSTANCES_DB_PATH · SESSIONS_PATH · LOG_LEVEL

Achtung PM2. Die vier INSTANCE_MEM_*-Schlüssel stehen im env-Block von ecosystem.config.js. PM2 setzt sie, bevor dotenv die .env liest, und dotenv überschreibt nichts bereits Gesetztes. Ein abweichender Wert in der .env wirkt deshalb nicht. Details: RUNBOOK.md §0.1.

Infrastruktur-Voraussetzung, die kein Code lösen kann

In ganz /etc/nginx gibt es kein client_max_body_size. Es gilt der Default 1m. Damit beantwortet nginx jeden Upload und jedes grössere Base64 selbst mit 413, bevor unser Code den Request sieht. Nötig im server-Block von wa.outrnk.io:

client_max_body_size 32m;
client_body_timeout  120s;

proxy_read_timeout 300s ist bereits gesetzt und reicht.


13. Deploy und Rollback

Nicht hier dokumentiert — RUNBOOK.md ist die Quelle. Insbesondere:

ThemaAbschnitt
Zwei Blocker, die Code nicht lösen kannRUNBOOK §0
PM2 und UmgebungsvariablenRUNBOOK §0.1
Schritt 0 vor dem ersten Deploy (.env, 0600, Secrets)RUNBOOK §1.0
Deploy Schritt für Schritt mit AbbruchkriterienRUNBOOK §1.1
Pflichtprüfungen 30 min nach dem DeployRUNBOOK §1.2
RollbackRUNBOOK §1.3
Notfall-Rollback der Härtung ohne DeployRUNBOOK §1.4
Einzelne Instanz neu startenRUNBOOK §2
Zombie erkennenRUNBOOK §3
Quarantäne behandelnRUNBOOK §4
Bibliotheks-Update mit CanaryRUNBOOK §9

13.1 Inbetriebnahme der Queue — gestufte Schrittfolge

Der Ausgangszustand nach dem Deploy ist absichtlich ein Nullzustand:

  • QUEUE_PG_URL ist nicht gesetzt. Der Queue-Kern wirft deswegen nicht — init() wirft

laut Kontrakt nie —, sondern geht in den Fallback (lib/queue/index.js: kein connectionString_enterDegraded('QUEUE_PG_URL not configured')).

  • Alle Instanzen stehen auf queue.enabled = false (QUEUE_DEFAULTS_EXISTING). Für sie

greift der Fallback gar nicht erst: init() bricht vorher mit Modus disabled ab. Ihr Verhalten ist bit-für-bit das von vorher.

Daraus folgt die Reihenfolge. Sie ist nicht beliebig: wird eine Instanz auf enabled = true gestellt, bevor QUEUE_PG_URL in ihrer Prozessumgebung steht, landet sie in memory-degraded statt in pg — sie sendet dann zwar weiter und drosselt korrekt, aber ohne Persistenz und mit 503 auf allen /api/v2/send-*. Das ist kein Ausfall, aber auch nicht das Ziel des Rollouts.

In jedem Schritt ist Rollback ohne Datenverlust möglich.


Schritt 1 — Code deployen, Queue bleibt überall aus.

Normaler Deploy nach RUNBOOK §1.1. QUEUE_PG_URL noch nicht setzen, queue.enabled bei keiner Instanz anfassen.

  • Prüfpunkt: npm run queue:test ist grün (118/118, kein Datenbankzugriff nötig).

GET /api/instances/:id/limits zeigt für jede Instanz queue.enabled = false. Die sechs Bestandsrouten antworten unverändert.

  • Abbruchkriterium: eine Instanz meldet nach dem Start einen anderen Modus als disabled,

oder eine Bestandsroute ändert Statuscode oder Antwortform ⇒ Deploy-Rollback nach RUNBOOK §1.3, bevor Schritt 2 beginnt.


Schritt 2 — Schema anlegen.

cd /home/ubuntu/apps/whatsapp-multi-api
# migrate.js lädt KEIN dotenv. Die Variablen müssen in der Shell stehen:
set -a; . ./.env; set +a
QUEUE_PG_URL='postgresql://…@127.0.0.1:5432/chatbot_management' npm run queue:migrate

Das Skript ist idempotent und mehrfach ausführbar. Es legt nur wa_gateway an, nimmt keinen Lock auf einer Chatbot-Tabelle, und der laufende Alt-Code sieht davon nichts.

  • Prüfpunkt: npm run queue:verify beendet mit Exit 0 und meldet nichts Fehlendes

(3 Tabellen, 10 Indizes, Spalte wa_outbox.dispatch_attempted).

  • Abbruchkriterium: Exit ≠ 0, permission denied for database (die Rolle darf kein Schema

anlegen) oder invalid QUEUE_SCHEMAstopp. Rückfallpunkt: DROP SCHEMA wa_gateway CASCADE. Kein Schritt 3, solange --verify nicht sauber ist.


Schritt 3 — QUEUE_PG_URL setzen und in die Prozesse bringen.

Eintragen in die .env (nicht in ecosystem.config.js — dort ist es beim nächsten pm2 restart <NAME> weg, siehe RUNBOOK §0.1). Die Vorlage .env.example führt seit dem Review alle acht QUEUE_*-Schlüssel mit Default und Wirkung; von dort abschreiben statt raten. Alle acht sind optional — nur QUEUE_PG_URL entscheidet über pg vs. memory-degraded. Danach muss der Wert zweimal ankommen: dotenv läuft nur im admin-server.js; die Instanzen sind Kindprozesse und erben ihre Umgebung im Moment von spawn(). Ein laufender Kindprozess sieht eine neue .env-Zeile also nie.

pm2 restart ecosystem.config.js --only whatsapp-api --update-env
# danach die Instanzen einzeln durchstarten (RUNBOOK §2), nicht alle gleichzeitig
  • Prüfpunkt: im Log jeder neu gestarteten Instanz steht nicht mehr

QUEUE_PG_URL not configured. GET /health liefert weiterhin queue.degraded = 0 (die Instanzen stehen ja noch auf disabled und zählen dort nicht mit).

  • Abbruchkriterium: max_connections-Fehler oder 53300 in irgendeinem Instanzlog ⇒ Zeile

wieder aus der .env entfernen und neu starten. Verbindungsbudget nachrechnen: Instanzen × QUEUE_PG_POOL_MAX (Default 3) + 2 für den Admin.


Schritt 4 — Instanzen einzeln umstellen (Canary).

Pro Instanz, eine nach der anderen, kleinste zuerst:

curl -X PUT "$ADMIN/api/instances/$ID/limits" -H "Authorization: Bearer $JWT" \
  -H 'Content-Type: application/json' \
  -d '{"enabled":true,"maxPerHour":40,"maxPer24h":500,"minDelayMs":4000,"maxDelayMs":12000}'

Ein Neustart ist nicht nötig: applyConfig() startet den PG-Pfad zur Laufzeit (_startPg()), sofern Schritt 3 erledigt ist. Die Antwort ist 200 — bei 207 hat nur das Persistieren geklappt und der Live-Push nicht (warning, liveError lesen).

  • Prüfpunkt: GET /api/instances/:id/queue (bzw. GET /api/v2/queue an der Instanz) meldet

mode: "pg" und limitsEnforced: true. Eine Testnachricht bekommt einen jobId mit Präfix wq_ und erreicht den Zustand sent. unknown24h bleibt 0.

  • Abbruchkriterium: mode bleibt memory-degraded (⇒ Schritt 3 hat diese Instanz nicht

erreicht), oder unknown24h > 0, oder eine Bestandsroute antwortet 504 QUEUE_WAIT_TIMEOUT häufiger als erklärbar ⇒ sofort {"enabled":false} zurücksetzen. Die bereits geschriebenen Zeilen bleiben stehen und gehen bei erneutem Einschalten weiter; verloren geht nichts.


Schritt 5 — beobachten, dann verbreitern.

Erste Instanz 24 h beobachten, danach die nächste. Nach 7 Tagen ohne Befund den Rest.

  • Prüfpunkt (täglich): GET /healthqueue.degraded = 0; je Instanz unknown24h = 0 und

counts.queued + counts.scheduled läuft nicht monoton nach oben (das wäre ein Rückstau). npm run queue:verify weiterhin Exit 0.

  • Abbruchkriterium: queue.degraded > 0 über mehr als zwei Probe-Intervalle

(QUEUE_DEGRADED_PROBE_MS, Default 30 s) hinweg, oder ein Rückstau, der QUEUE_MAX_PENDING (5000, ⇒ 422 QUEUE_FULL) ansteuert ⇒ betroffene Instanz auf enabled = false, Rollout anhalten, Ursache klären.


Vorbedingung für Schritt 4 ist zusätzlich die Prüfliste aus §15 B2: alle dort genannten Nachweise müssen im deployten Stand vorhanden sein. Insbesondere lib/queue/gate.js und lib/queue/job.js müssen existieren und npm run queue:test muss grün sein — ohne sie sind Medienversand, chatId-Ziele und die Limitdurchsetzung defekt.

SchrittRückfallpunkt
1 DeployStandard-Deploy-Rollback (RUNBOOK §1.3)
2 SchemaDROP SCHEMA wa_gateway CASCADE
3 QUEUE_PG_URLZeile aus .env entfernen, Prozesse neu starten
4 Instanz umstellen{"enabled":false} — Zeilen bleiben erhalten
5 Verbreiternsiehe 4, je Instanz einzeln

Dokumentation und Generator

Es gibt genau drei gepflegte Quellen:

QuelleZielgruppeSprache
docs/API.mdKundenEnglisch
docs/QUEUE.mdKundenEnglisch
docs/INTERNAL.mdBetreiberDeutsch

Die HTML-Seiten werden daraus generiert:

node scripts/build-docs.js          # schreibt public/api-docs.html + public/admin-api-docs.html
node scripts/build-docs.js --check  # exit 1, wenn das HTML nicht mehr zur Quelle passt
ZielQuelle
public/api-docs.htmldocs/API.md + docs/QUEUE.md
public/admin-api-docs.htmldocs/INTERNAL.md

Beide HTML-Dateien tragen oben den Hinweis „generiert, nicht von Hand bearbeiten" und im <head> einen data-doc-hash mit dem SHA-256 der Quelle. --check vergleicht diesen Hash und eignet sich als Deploy-Vorprüfung.

Der Generator ist ~300 Zeilen ohne Abhängigkeiten (eigener Markdown-Renderer für die hier benutzte Teilmenge: Überschriften, Tabellen, Listen, Codeblöcke, Blockzitate, Inline-Code, Fett/Kursiv, Links). Kein Build-Schritt, kein npm-Paket, kein CI nötig.

Regel: Wer eine Route ändert, ändert docs/API.md und führt node scripts/build-docs.js aus. Wer das HTML von Hand anfasst, verliert seine Änderung beim nächsten Lauf.


15. Bekannte Grenzen und offene Punkte

A. Bewusst akzeptierte Lücken

#LückeWarum akzeptiert
1Ohne X-Idempotency-Key ist Verlustfreiheit über die Netzwerkgrenze nicht garantierbar.Physik. Ein Absturz vor dem COMMIT ist vom Aufrufer nicht von „nie angekommen" unterscheidbar. Steht fett in beiden Kundendokumenten.
2PG-Ausfall zwischen Dispatch und COMMIT ⇒ unknown, WhatsApp-ID nur in der PM2-Zeile DISPATCH_ID job=… wa=… chat=….Automatische Auflösung ist unmöglich; jede Annahme schadet in eine Richtung.
3In disabled sind Limits nicht durchgesetzt.Die Queue ist aus — es gibt nichts durchzusetzen. memory-degraded setzt sie über dasselbe Gate durch.
3bIn memory-degraded gilt die Zusage „kein Verlust" nicht: ein unversandter Rückstand liegt nur im Heap und ist nach einem Prozessabsturz weg. Die JSONL-Datei ist forensisch, kein Replay.Automatisches Replay wäre der Duplikat-Vorfall. Deshalb lehnen die v2-Senderouten in diesem Modus mit 503 ab, statt etwas Unwahres zuzusagen.
4Video→Sticker bleibt unmöglich (kein ffmpeg).+250 MB und CPU-Last auf einem Host mit fünf Chrome-Bäumen. Route lehnt sauber mit 400 STICKER_VIDEO_UNSUPPORTED ab.
5PTT wird nicht transkodiert.Siehe 4. Nicht-OGG bekommt warnings, keine stille Falschzustellung.
6Base64 > ~750 KB scheitert an nginx.Infrastrukturänderung, kein Code. Siehe §12.
7Legacy /api/send-message kann bei aktiven Limits 504 QUEUE_WAIT_TIMEOUT liefern.Bei maxPerHour = null unerreichbar. Tritt erst nach bewusster Aktivierung auf, und dann ist die Nachricht in der Queue und geht raus.
8wa_gateway liegt in der DB eines Fremddienstes (chatbot_management).Die Rolle chatbot kann kein DB anlegen (rolcreatedb=f). Schema-Isolation ist die stärkste erreichbare Trennung ohne Superuser.
9/api/v2/messages/:id ist doppelt belegt (Queue-Job in C4, WhatsApp-Nachricht in D1.3).Express kann den Pfad nur einmal binden. Unterschieden wird über das Präfix wq_. Eine WhatsApp-Message-ID, die zufällig mit wq_ beginnt, gibt es nicht.

B. Offener Defekt

Verifiziert gegen den Code in diesem Verzeichnis, Stand dieser Datei.

#DefektFundstelleWirkung
B1POST /api/v2/queue/replan ruft queueCore.replan(). Diese Methode existiert auf der Fassade nicht (typeof queue.replan === 'undefined', gegen den geladenen Kern geprüft), also greift der Rückfall applyConfig(queueCfg) — mit unveränderter Konfiguration. needsReplan() ist damit false.instance-server.js Route /api/v2/queue/replan, lib/queue/index.js applyConfig()Die Route antwortet {replanned:false} und tut nichts. Gleiches gilt für POST /api/instances/:id/queue/replan. Kein Schaden, aber eine Zusage, die nicht eingelöst wird. Fix: entweder replan() auf der Fassade ergänzen (planner.replan('manual') plus queue.replanned-Webhook) oder die Route entfernen.

B2. Was in dieser Runde behoben wurde — zur Kontrolle

Der vorangegangene Review hatte sieben weitere Blocker gemeldet. Sie sind im aktuellen Stand nachweislich geschlossen; hier steht, woran man das erkennt, damit ein Rollback nicht unbemerkt dahinter zurückfällt.

Ehemaliger BlockerNachweis im Code
sendFn las camelCase, der Dispatcher übergab die rohe snake_case-Zeile ⇒ Medien und chatId gingen verlorenlib/queue/job.js (toSendJob/normalizeJob) normalisiert beide Speicherformen auf ein Schema; sendFn ruft normalizeJob(item) als erste Zeile
ctx.markDispatchAttempted() wurde nie aufgerufen ⇒ Post-Dispatch-Fehler als failed statt unknown6 Aufrufstellen in instance-server.js, jeweils unmittelbar vor dem wwebjs-Aufruf, ohne await dazwischen
Dispatcher prüfte beim Claim weder Mindestabstand noch Limits ⇒ Burst nach jedem Stillstandlib/queue/gate.js evaluateGate(), ausgewertet in Dispatcher._gateDecision() innerhalb des Claims und im Memory-Fallback
Kein Übergang in den Degraded-Modus zur Laufzeit ⇒ PG-Ausfall legte den Sendepfad lahmQueue._onStoreFailure() + Queue.isConnectivityError() + _guard(); _enterDegraded wird jetzt auch aus dem Laufzeitpfad gerufen
POST /api/v2/groups mit requireTarget:false ⇒ immer 400built.target (erster Teilnehmer) wird in v2SendRoute als synthetisches Ziel gesetzt; sendFn fängt kind==='group-create' vor der Zielauflösung ab
v2SubmitError las e.status, QueueError setzte nur e.httpQueueError spiegelt den Wert: this.status = this.http
AUTOSTART_STAGGER_MS war auf 45000 zurückgefallensteht wieder auf 15000

C. Betriebliche Grenzen

  • Der Memory-Guard ist ein Ausreisser-Detektor, kein Kapazitätsplan (RUNBOOK §8.3).
  • Die Browser-Probe erkennt einen toten Browser, nicht einen langsamen.
  • fetchMessages mit hohem limit bleibt der teuerste Aufruf im System (Cap 200, 30-s-Deadline).
  • Es gibt keine Mandantentrennung innerhalb einer Instanz — eine Instanz = eine Nummer = ein Kunde.

16. Abweichungen zwischen Kontrakt und Code

Der Bauplan der Wellen C+D und der tatsächliche Code weichen an folgenden Stellen ab. Der Code gewinnt, die Dokumente oben beschreiben den Code.

Kontrakt sagtCode machtBewertung
scripts/queue-migrate.js + scripts/queue-migrate.sqllib/queue/migrate.js + lib/queue/schema.sql, npm-Skripte queue:migrate / queue:verifyrein kosmetisch
wa_gateway.wa_media als MetadatenspeicherDDL vorhanden, der Code führt den Index aber in data/media/<instanceId>/index.jsonMedienmetadaten überleben einen Verlust des Datenverzeichnisses nicht. Für 72-h-Daten vertretbar, aber bewusst zu wissen.
mediaBase64 hart auf 8 MiB BinärMEDIA_BASE64_MAX_BYTES = 7340032 (7 MiB)unkritisch
PTT-Mimetypes: 7 Wertezusätzlich audio/opus, audio/mp3, audio/x-wav, audio/webmgrosszügiger, mit Warnung
POST /api/v2/media liefert 200liefert 201dokumentiert
Degraded: /api/v2/* Senderouten ⇒ 503zusätzlich auch im Modus disabled ⇒ 503wichtig: für die fünf Bestandsinstanzen (Default enabled:false) ist jede v2-Senderoute heute 503
queue.replanned-Webhook bei Limitänderungwird gefeuert (applyConfig), aber die explizite Replan-Route ist wirkungslossiehe B1
prepareFn für den zweiten SSRF-Checknicht injiziert; der zweite Check läuft stattdessen in sendFn selbstfunktional gleichwertig, aber Fehler dort werden nicht garantiert als T8 gewertet
Limits ausschliesslich über scheduled_atzweite Schicht lib/queue/gate.js prüft unmittelbar vor dem Versand erneutbesser als der Kontrakt; ohne sie wäre jeder Stillstand ein Burst
Degraded nur beim StartQueue._onStoreFailure() schaltet auch zur Laufzeit um, mit Positivliste der Verbindungsfehlerbesser als der Kontrakt
QUEUE_PAUSED für alle Routennur für /api/v2/*; die sechs Bestandsrouten warten wie bisherbewusst, verhindert eine Verhaltensänderung an der Aussenkante
/api/send-direct, /api/send-raw nicht erwähntexistieren auf der Instanz, sind nicht extern durchgereichtkorrekt so, als Diagnosewerkzeug dokumentiert

Was der Chatbot heute wirklich benutzt

chatbot-src/services/whatsappIntegrationService.ts:221-238 ruft POST /api/instances/:id/send-message gegen http://localhost:3001. Diese Route existiert im admin-server.js nicht — der Pfad ist tot. Der Chatbot ruft über diesen Service also keine der sechs Bestandsrouten produktiv auf. Trotzdem bleiben alle sechs unverändert; die Annahme „der Chatbot hängt daran" darf nicht die einzige Begründung sein, aber die Kompatibilitätszusage gilt gegenüber jedem Kunden.

Relevant für B1: der Chatbot übergibt ausschliesslich chatId, nie phone. Sobald für seine Instanz queue.enabled = true gesetzt wird, träfe ihn B1 sofort und vollständig.