Design Plan — WhatsApp Gateway via Evolution API + n8n

Decision date: 2026-05-09
Status: Approved for implementation
Replaces: OpenClaw DE number entirely (DE number fully dedicated to Evolution API)


Verdict: Yes — Good Idea

The concept is sound. Evolution API + n8n is the right stack. Evolution API handles all WhatsApp traffic for the DE number (groups + DMs) via a single webhook. n8n routes and processes. OpenClaw is removed from the DE number completely.

Why Evolution API instead of fixing OpenClaw groups

Fix OpenClaw groupsEvolution API + n8n
EffortWrite custom forwarder skill against undocumented internal busOne Docker container + import n8n workflow
Group supportFragile, groupPolicy=allowlist, limited hooksNative — built specifically for this
Debug visibilityOpenClaw logs onlyn8n execution log per message
MaintainabilityTied to OpenClaw internalsStandard REST webhooks — survives upgrades
Dialog stateInside OpenClaw skillsn8n + Supabase — clean separation
DM handlingRequires separate OpenClaw skillSame instance, same webhook, just route differently

Webhook Architecture

Evolution API allows one webhook URL per instance (per number). All events — groups, DMs, status — go to that one URL. The split happens in n8n.

DE Number (SIM in drawer)
  │
  ▼
Evolution API — instance: p24-de  (Hostinger VPS)
  │  one webhook → POST /webhook/wa-router
  ▼
n8n: WA Router  (Hostinger VPS)
  │  routes by remoteJid type
  ├──► @g.us + 120363411900921854 ──► n8n: Issue Reporter (sub-workflow)
  ├──► @g.us + other JID          ──► n8n: ignore (no-op, or future groups)
  └──► @s.whatsapp.net            ──► n8n: DM Handler (sub-workflow)

Each handler is a separate n8n workflow called via the Execute Workflow node. The router is tiny and never changes; each use-case evolves independently.

Adding more groups later

To handle a second group, add one more JID check in the router and create a new sub-workflow. Evolution API config stays unchanged — no new webhook, no new instance.


Infrastructure Placement

ComponentWhereWhy
Evolution APIHostinger VPSn8n already there — webhook round-trips stay local
n8n workflowsHostinger VPSalready running
Session stateSupabasealready used by the platform
IONOS VPSunchangedmonitoring stack stays clean

Secrets — Project Standards

This project keeps all secrets in .env files on servers (never committed). Same rule applies here.

Hostinger .env — add:

EVOLUTION_API_KEY=<strong-random-32-chars>   # master key for Evolution API manager + REST calls

n8n credential store — add two credentials:

  • Evolution API — type: HTTP Header Auth, header: apikey, value: ${EVOLUTION_API_KEY}
  • (Anthropic key already exists if used in other workflows)

GitHub Secrets — add EVOLUTION_API_KEY to radieu/p24-infra secrets inventory
(listed in CLAUDE.md under “Still to add”)

Never: hardcode keys in n8n workflow nodes — always reference named n8n credentials.


Phase 0 — Infrastructure (Evolution API on Hostinger)

Docker Compose

Add to Hostinger’s existing docker-compose.yml:

  evolution-postgres:
    image: postgres:15-alpine
    container_name: evolution-postgres
    restart: unless-stopped
    environment:
      - POSTGRES_PASSWORD=evolutionpass
      - POSTGRES_DB=evolution
    volumes:
      - evolution_postgres:/var/lib/postgresql/data
 
  evolution-api:
    image: atendai/evolution-api:v2.2.3      # v2.2.3 — latest stable; v2.3.0 does not exist on Docker Hub
    container_name: evolution-api
    restart: unless-stopped
    labels:
      - traefik.enable=true
      - traefik.http.routers.evolution.rule=Host(`wa.vps-h1.infra.zintegrowana.online`)
      - traefik.http.routers.evolution.tls=true
      - traefik.http.routers.evolution.entrypoints=web,websecure
      - traefik.http.routers.evolution.tls.certresolver=mytlschallenge
      - traefik.http.services.evolution.loadbalancer.server.port=8080
    environment:
      - SERVER_URL=https://wa.vps-h1.infra.zintegrowana.online
      - AUTHENTICATION_API_KEY=${EVOLUTION_API_KEY}
      - DEL_INSTANCE=false
      - DATABASE_PROVIDER=postgresql
      - DATABASE_CONNECTION_URI=postgresql://postgres:evolutionpass@evolution-postgres:5432/evolution
      - DATABASE_ENABLED=true
      - REDIS_ENABLED=false
      - LOG_LEVEL=ERROR
    depends_on:
      - evolution-postgres
    volumes:
      - evolution_store:/evolution/store
      - evolution_instances:/evolution/instances
 
volumes:
  evolution_store:
  evolution_instances:
  evolution_postgres:

Deployed ✓ — evolution-api and evolution-postgres running on Hostinger. Traefik handles TLS.
DNS: wa.vps-h1 A record → 72.60.32.61 added to Cloudflare (proxy OFF — DNS only). The *.vps-h1 wildcard already covers it; the specific wa.vps-h1 record was added for clarity.
Note: Redis disconnected errors in logs are harmless — REDIS_ENABLED=false, it’s just the retry loop.


Phase 1 — Disconnect DE from OpenClaw, Connect to Evolution API

Step 1 — Remove DE session from OpenClaw (IONOS)

OpenClaw stores Baileys session files on disk. Delete them to force a clean disconnect:

# SSH to IONOS as root
ssh -i C:\Users\konar\.ssh\id_ed25519 root@217.154.82.162
 
# Find OpenClaw session dir for DE account (adjust path if different)
ls /root/.openclaw/sessions/
# Expected: de/  (or similar)
 
# Delete DE session — forces WhatsApp to log out the linked device
rm -rf /root/.openclaw/sessions/de/
 
# Remove DE group from allowlist in OpenClaw config (prevents reconnect attempt)
# Edit channels.whatsapp.accounts.de.groupAllowlist → remove 120363411900921854@g.us
# Or set groupPolicy: deny to block all groups for DE account

Step 2 — Connect DE number to Evolution API

  1. Open https://wa.vps-h1.infra.zintegrowana.online/manager
  2. Login with EVOLUTION_API_KEY
  3. New Instance → name: p24-de → Create
  4. Click Connect → QR code appears
  5. On DE phone: WhatsApp → Linked Devices → Link a Device → scan QR
  6. Status changes to open — phone goes in a drawer

Step 3 — Register webhook

curl -X POST https://wa.vps-h1.infra.zintegrowana.online/webhook/set/p24-de \
  -H "apikey: ${EVOLUTION_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://n8n.srv1072950.hstgr.cloud/webhook/wa-router",
    "enabled": true,
    "events": ["MESSAGES_UPSERT"]
  }'

Phase 2 — n8n Workflows

Workflow 1: WA Router (webhook entry point)

Simple routing only — no business logic here.

Webhook POST /wa-router
  │
  ├──► Respond 200 OK immediately
  │
  └──► Extract JID + type (Code node)
            │
            ├── fromMe=true ──► Stop
            │
            ├── remoteJid contains @g.us
            │     ├── = 120363411900921854@g.us ──► Execute Workflow: Issue Reporter
            │     └── other JID ──► Stop (no-op, log for future)
            │
            └── remoteJid contains @s.whatsapp.net ──► Execute Workflow: DM Handler

Workflow 2: Issue Reporter (group dialog)

Stateful 8-step dialog. State stored in Supabase.

Session table:

CREATE TABLE whatsapp_sessions (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  group_jid text NOT NULL,
  sender_jid text NOT NULL,
  step text NOT NULL DEFAULT 'idle',
  data jsonb NOT NULL DEFAULT '{}',
  updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX ON whatsapp_sessions (group_jid, sender_jid);

Session read/write: always use INSERT ... ON CONFLICT (group_jid, sender_jid) DO UPDATE SET step=..., data=..., updated_at=now() (UPSERT — never plain INSERT).

Step machine:

StepBot message (PL)Expected input
idle → topic”Hej! Opisz krótko temat zgłoszenia:“any text
topic → panel”Który panel?\n1-Manager 2-Technik 3-Kierowca 4-Dyspozytor 5-Magazyn 6-Admin”1–6
panel → module”Który moduł/zakładka?” + contextual list per panelnumber
module → area”Jaki obszar?\n1-Lista/widok 2-Formularz 3-Inne”1–3
area → description”Opisz dokładnie problem:“any text
description → screenshot_wait”Wyślij screenshot 📸 lub wpisz ‘pomiń’ (60s):“image or “pomiń”
screenshot_wait → confirm”Podsumowanie:\n*[topic]*\n[panel] › [module] › [area]\n[description]\n\nZgłosić? (tak/nie)“tak/nie
confirm → done”✅ INFRA-{n} zgłoszono. Dziękujemy!”

Screenshot step handling:

When step = screenshot_wait, check incoming message type:

  • imageMessage present → trigger Media Upload sub-workflow (sync, wait for result) → store returned Wasabi URL in data.media[0], advance step
  • audioMessage or videoMessage present → same sub-workflow, store in data.media[0], advance step
  • text = “pomiń” (case-insensitive) → data.media = [], advance step
  • anything else → reply “Wyślij zdjęcie/nagranie lub wpisz ‘pomiń’”
  • timeout handled by cleanup cron (see below)

See Media Handling section for the upload sub-workflow design.

On confirm = “tak”:

INSERT INTO p24_issues (
  title, panel, module, area, description, media_urls,
  reporter_jid, reporter_name, source, status
) VALUES (
  {{data.topic}}, {{data.panel}}, {{data.module}}, {{data.area}},
  {{data.description}}, {{data.media}}::jsonb,
  {{sender_jid}}, {{pushName}}, 'whatsapp_group', 'open'
)
RETURNING id;
-- ticket ref = INFRA-{id sequence from trigger}

Then send ticket ref to group, DELETE session.

Workflow 3: DM Handler

MVP — auto-reply only:

Incoming DM
  │
  └──► Evolution API sendText to sender:
       "Cześć! Zgłoszenia błędów przez grupę:
        https://chat.whatsapp.com/[invite-link]
        Pisz tam — bot przeprowadzi Cię przez formularz 🙂"

Expand later if needed.

Workflow 4: Session Timeout Cleanup

Schedule trigger — every 5 minutes:

SELECT group_jid, sender_jid FROM whatsapp_sessions
WHERE updated_at < now() - interval '15 minutes';

For each row: send ”⏱ Sesja wygasła. Wyślij wiadomość ponownie, by zacząć od nowa.” → DELETE session.


Media Handling

All media — images, audio, video — received via WhatsApp is downloaded through Evolution API and stored in Wasabi S3. The Wasabi URL is stored in the p24_issues.media_urls column (jsonb array) so it can be referenced, displayed, and passed to AI APIs without any re-download.

Wasabi bucket

Value
Bucketecotrans-whatsapp-media (new — separate from monitoring bucket)
Regioneu-central-1
Endpoints3.eu-central-1.wasabisys.com
AccessPrivate — no public-read ACL; all access via Wasabi credentials (n8n) or presigned URLs (platform UI)
Path pattern{year}/{month}/{day}/{sender_hash}/{timestamp}_{uuid}.{ext}

Secrets — add to Hostinger .env:

WASABI_ACCESS_KEY=...    # already planned in CLAUDE.md
WASABI_SECRET_KEY=...    # already planned in CLAUDE.md
WASABI_MEDIA_BUCKET=ecotrans-whatsapp-media

n8n credential — add “Wasabi S3”:

  • Type: AWS credentials
  • Access key / secret from .env above
  • Region: eu-central-1
  • Custom endpoint: https://s3.eu-central-1.wasabisys.com

p24_issues schema — replace screenshot_url text with media_urls jsonb

ALTER TABLE p24_issues
  DROP COLUMN IF EXISTS screenshot_url,
  ADD COLUMN media_urls jsonb NOT NULL DEFAULT '[]';

Each element in the array:

{
  "type": "image",          // image | audio | video | document
  "wasabi_key": "2026/05/09/a3f2.../1746800000_uuid.jpg",
  "mimetype": "image/jpeg",
  "size_bytes": 184320,
  "duration_s": null         // for audio/video; null for images
}

No url field is stored. The bucket is private — there is no permanent URL. Access is always derived on demand (see Access Patterns below).

Workflow 5: Media Upload (sub-workflow, called synchronously)

Input: { messageKey, instanceName, mediaType }
  │
  ├── POST https://wa.vps-h1.infra.zintegrowana.online/chat/getBase64FromMediaMessage/p24-de
  │     body: { message: { key: {messageKey} }, convertToMp4: false }
  │     credential: Evolution API (HTTP Header Auth)
  │     → returns { base64, mimetype, fileLength }
  │
  ├── Code node: decode base64 → binary buffer
  │              derive extension from mimetype (jpeg/png/ogg/mp4/pdf…)
  │              build wasabi_key = {YYYY/MM/DD}/{sha256(sender)[0:8]}/{ts}_{uuid}.{ext}
  │
  ├── S3 node (Wasabi): PUT object
  │     bucket: ecotrans-whatsapp-media
  │     key: wasabi_key
  │     content-type: mimetype
  │     (no ACL — bucket is private by default)
  │
  └── Output: { wasabi_key, mimetype, size_bytes }

Notes:

  • Evolution API’s getBase64FromMediaMessage requires the full message key object from the webhook payload (id, remoteJid, fromMe, participant)
  • For audio: WhatsApp sends .ogg (Opus codec). AI transcription APIs (Whisper) accept .ogg directly — no conversion needed
  • For video: same flow. Large files (>50MB) are unlikely in a bug-reporting group but won’t break anything — n8n just takes longer
  • convertToMp4: false — keep original format, saves processing. Set true only if the downstream AI tool requires mp4

Access Patterns

Two consumers, two patterns — bucket stays private for both.

Pattern 1 — n8n (AI processing, internal workflows)

n8n has Wasabi credentials. It downloads binary directly using the S3 GET node, then passes content inline to the AI API — no URL exposed:

S3 GET object (wasabi_key) → binary buffer
  │
  ├── Image → base64 encode → Claude API:
  │     { type: "image", source: { type: "base64", media_type: "image/jpeg", data: "<base64>" } }
  │
  └── Audio (.ogg Opus) → multipart POST → Whisper API:
        form-data: file=<binary>, model=whisper-1
        → returns transcript text

No public URL, no presigned URL needed — credentials travel inside n8n, never leave to a browser.

Pattern 2 — Platform UI (displaying attachments)

When a user opens an issue in the ET Operational Platform, the frontend requests a short-lived presigned URL before rendering:

Frontend: GET /api/issues/{id}/media/{index}/presigned-url
  │
  └── Backend (Next.js API route or n8n webhook):
        read wasabi_key from p24_issues.media_urls[index]
        generate S3 presigned URL (GET, expires: 3600s)
        return { presigned_url, expires_at }
        │
  Frontend: <img src={presigned_url} /> or <audio src={presigned_url} />

Presigned URL validity: 1 hour — long enough for a browser session, short enough to limit exposure if leaked. Never stored in DB, always generated fresh.

n8n presigned URL generation (if no Next.js backend yet):

POST /webhook/wa-presign
body: { wasabi_key }
→ HTTP Request node: AWS Signature V4 presign (or use n8n AWS S3 credential + Code node with aws4 signing)
→ return { presigned_url }

AI processing hook

The media_urls array in p24_issues is the integration point. All downstream workflows use Pattern 1 (download binary via credentials):

Use caseHow
Screenshot analysisn8n S3 GET → base64 → Claude Vision (claude-sonnet-4-6) image block
Audio transcriptionn8n S3 GET → binary → Whisper API multipart POST
Auto-description on issue creationn8n workflow triggered by Supabase webhook on p24_issues INSERT with jsonb_array_length(media_urls) > 0 → process → UPDATE p24_issues SET ai_media_summary = ...

No AI processing in Phase 2 — store first, process later. The data structure supports it from day one.


Phase 3 — Health Monitoring

Nightly connection check (n8n Schedule, 06:00)

GET https://wa.vps-h1.infra.zintegrowana.online/instance/connectionState/p24-de
  -H "apikey: ${EVOLUTION_API_KEY}"

Response: {"state": "open"} or {"state": "close"}

If ≠ open → POST to Discord webhook (existing DISCORD_WEBHOOK_URL secret):

⚠️ Evolution API p24-de disconnected — QR re-scan needed at https://wa.vps-h1.infra.zintegrowana.online/manager

AI media processing (Phase 3+)

Wire the AI processing hook described in the Media Handling section: n8n workflow triggered on p24_issues INSERT with non-empty media_urls → Claude Vision for images, Whisper for audio → store summary in ai_media_summary column.


Risk Assessment

RiskProbabilityMitigation
WhatsApp bans DE numberLow (internal tool, low volume)Human-like reply delays (1–2s Wait node), no bulk sends
Session disconnectsMonthly-ishNightly health check + Discord alert
Evolution API breaks on updateLow (pinned v2.3.0)Only upgrade after checking changelog
Ghost sessionsLow15-min cron cleanup

Implementation Checklist

Pre-requisites

  • Verify port 8088 is free on Hostinger
  • Cloudflare (zintegrowana.online): add A record wa72.60.32.61, proxy OFF (grey cloud)
  • Add EVOLUTION_API_KEY, WASABI_ACCESS_KEY, WASABI_SECRET_KEY, WASABI_MEDIA_BUCKET to Hostinger .env
  • Add EVOLUTION_API_KEY, WASABI_ACCESS_KEY, WASABI_SECRET_KEY to GitHub Secrets (radieu/p24-infra)
  • Create ecotrans-whatsapp-media bucket in Wasabi, set ACL public-read
  • Add Evolution API (HTTP Header Auth) credential in n8n
  • Add Wasabi S3 (AWS, custom endpoint) credential in n8n

Phase 0 — Infrastructure

  • Add evolution-api service to Hostinger docker-compose.yml
  • Add Caddy route for subdomain
  • docker compose up -d evolution-api
  • Verify Manager UI accessible

Phase 1 — Connect number

  • Delete OpenClaw DE session files on IONOS (/root/.openclaw/sessions/de/)
  • Remove DE group JID from OpenClaw config (allowlist)
  • Create instance p24-de in Evolution Manager
  • Scan QR with DE phone, verify status = open
  • Register webhook → n8n /webhook/wa-router

Phase 2 — n8n workflows + media

  • Create whatsapp_sessions table in Supabase (UPSERT-safe unique index)
  • Migrate p24_issues: drop screenshot_url, add media_urls jsonb DEFAULT '[]'
  • Build WA Router workflow
  • Build Media Upload sub-workflow (Evolution API → Wasabi)
  • Build Issue Reporter workflow (full step machine, calls Media Upload on image/audio/video)
  • Build DM Handler workflow (auto-reply MVP)
  • Build Session Timeout Cleanup workflow (Schedule, every 5 min)
  • Test full dialog flow end-to-end: text-only + with image attachment
  • Verify p24_issues INSERT with media_urls populated + ticket ref sent to group

Phase 3 — Monitoring

  • Add nightly connection health check + Discord alert in n8n
  • (Optional) Screenshot upload to Supabase Storage

Not needed

  • New phone number — DE number is dedicated
  • New VPS — Hostinger handles it
  • Code changes to OpenClaw — session files deleted, group removed from allowlist
  • Any changes to IONOS monitoring stack