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 groups | Evolution API + n8n | |
|---|---|---|
| Effort | Write custom forwarder skill against undocumented internal bus | One Docker container + import n8n workflow |
| Group support | Fragile, groupPolicy=allowlist, limited hooks | Native — built specifically for this |
| Debug visibility | OpenClaw logs only | n8n execution log per message |
| Maintainability | Tied to OpenClaw internals | Standard REST webhooks — survives upgrades |
| Dialog state | Inside OpenClaw skills | n8n + Supabase — clean separation |
| DM handling | Requires separate OpenClaw skill | Same 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
| Component | Where | Why |
|---|---|---|
| Evolution API | Hostinger VPS | n8n already there — webhook round-trips stay local |
| n8n workflows | Hostinger VPS | already running |
| Session state | Supabase | already used by the platform |
| IONOS VPS | unchanged | monitoring 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 callsn8n 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-apiandevolution-postgresrunning on Hostinger. Traefik handles TLS.
DNS:wa.vps-h1A record →72.60.32.61added to Cloudflare (proxy OFF — DNS only). The*.vps-h1wildcard already covers it; the specificwa.vps-h1record was added for clarity.
Note: Redisdisconnectederrors 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 accountStep 2 — Connect DE number to Evolution API
- Open
https://wa.vps-h1.infra.zintegrowana.online/manager - Login with
EVOLUTION_API_KEY - New Instance → name:
p24-de→ Create - Click Connect → QR code appears
- On DE phone: WhatsApp → Linked Devices → Link a Device → scan QR
- 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:
| Step | Bot 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 panel | number |
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:
imageMessagepresent → trigger Media Upload sub-workflow (sync, wait for result) → store returned Wasabi URL indata.media[0], advance stepaudioMessageorvideoMessagepresent → same sub-workflow, store indata.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 | |
|---|---|
| Bucket | ecotrans-whatsapp-media (new — separate from monitoring bucket) |
| Region | eu-central-1 |
| Endpoint | s3.eu-central-1.wasabisys.com |
| Access | Private — 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-median8n credential — add “Wasabi S3”:
- Type: AWS credentials
- Access key / secret from
.envabove - 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
getBase64FromMediaMessagerequires the full messagekeyobject from the webhook payload (id,remoteJid,fromMe,participant) - For audio: WhatsApp sends
.ogg(Opus codec). AI transcription APIs (Whisper) accept.oggdirectly — 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. Settrueonly 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 case | How |
|---|---|
| Screenshot analysis | n8n S3 GET → base64 → Claude Vision (claude-sonnet-4-6) image block |
| Audio transcription | n8n S3 GET → binary → Whisper API multipart POST |
| Auto-description on issue creation | n8n 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
| Risk | Probability | Mitigation |
|---|---|---|
| WhatsApp bans DE number | Low (internal tool, low volume) | Human-like reply delays (1–2s Wait node), no bulk sends |
| Session disconnects | Monthly-ish | Nightly health check + Discord alert |
| Evolution API breaks on update | Low (pinned v2.3.0) | Only upgrade after checking changelog |
| Ghost sessions | Low | 15-min cron cleanup |
Implementation Checklist
Pre-requisites
- Verify port 8088 is free on Hostinger
- Cloudflare (
zintegrowana.online): add A recordwa→72.60.32.61, proxy OFF (grey cloud) - Add
EVOLUTION_API_KEY,WASABI_ACCESS_KEY,WASABI_SECRET_KEY,WASABI_MEDIA_BUCKETto Hostinger.env - Add
EVOLUTION_API_KEY,WASABI_ACCESS_KEY,WASABI_SECRET_KEYto GitHub Secrets (radieu/p24-infra) - Create
ecotrans-whatsapp-mediabucket 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-apiservice to Hostingerdocker-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-dein Evolution Manager - Scan QR with DE phone, verify status =
open - Register webhook → n8n
/webhook/wa-router
Phase 2 — n8n workflows + media
- Create
whatsapp_sessionstable in Supabase (UPSERT-safe unique index) - Migrate
p24_issues: dropscreenshot_url, addmedia_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_issuesINSERT withmedia_urlspopulated + 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