WhatsApp -> Discord message forwarder for go-whatsapp-web-multidevice (GOWA) — forwards messages from a specific WhatsApp chat to a Discord channel via webhook, with sender avatar, formatted phone… A developer has built a WhatsApp-to-Discord message forwarder that integrates with the go-whatsapp-web-multidevice (GOWA) REST API. The tool filters messages from a specific WhatsApp chat, resolves sender profile pictures via a separate GOWA endpoint, and reposts them to a Discord channel via webhook with the sender's name and avatar. It runs as a Flask server and can be deployed locally or on a host like Render. | """ | | | WhatsApp - Discord message forwarder for go-whatsapp-web-multidevice GOWA . | | | Built against: https://github.com/aldinokemal/go-whatsapp-web-multidevice | | | GOWA - WhatsApp REST API with webhook, multi-device, and MCP support | | | What this does, end to end: | | | 1. GOWA sends a webhook POST every time a message event happens. | | | 2. We filter for messages that belong to ONE specific chat TARGET GROUP JID . | | | 3. We figure out who sent it, format their number nicely, and grab their | | | WhatsApp profile picture GOWA doesn't include this in the webhook, so we | | | have to call a separate GOWA endpoint for it — device-scoped, so we pass | | | the device id GOWA already gives us in the webhook body . | | | 4. We repost the message into Discord via a webhook, disguised as if it came | | | from a user named after the sender, with their WhatsApp DP as the avatar. | | | SETUP running locally : | | | 1. Fill in the values in the CONFIGURATION block below DISCORD WEBHOOK URL, | | | TARGET GROUP JID, GOWA BASE URL, and the GOWA basic-auth credentials if | | | you set any . Every value to fill in is marked with a "<-- SET THIS" comment. | | | 2. Install dependencies: | | | pip install flask requests | | | 3. Run it: | | | python app.py | | | This starts a local server on http://0.0.0.0:5000, listening for | | | webhooks at the /webhook path. | | | 4. Point GOWA at it. Since GOWA needs to reach this server over the | | | internet, expose your local port with a tunnel tool like ngrok: | | | ngrok http 5000 | | | Then set GOWA's webhook URL --webhook flag, or WHATSAPP WEBHOOK env | | | var to the ngrok URL + "/webhook", e.g.: | | | https://abcd1234.ngrok-free.app/webhook | | | This can just as easily run on a host like Render instead of locally — | | | same file, same variables, just deploy it there and skip the ngrok step | | | since it'll already have a public URL. | | | NOTE: this file is meant to be committed with the CONFIGURATION values | | | left BLANK as they are below . Never commit your real webhook URL or | | | GOWA credentials — fill them in locally after cloning, or set them as | | | environment variables if you'd rather not have them in the file at all. | | | """ | | | from flask import Flask, request, jsonify | | | from datetime import datetime, timezone, timedelta | | | import requests | | | import time | | | import re | | | app = Flask name | | | ======================= CONFIGURATION ======================= | | | Fill these in before running. Leave this file's committed values blank — | | | only fill them in on your own local copy / deployment. | | | Discord webhook URL Server Settings - Integrations - Webhooks . | | | Every forwarded WhatsApp message gets POSTed here. | | | DISCORD WEBHOOK URL = "" <-- SET THIS, e.g. "https://discord.com/api/webhooks/XXXX/YYYY" | | | The WhatsApp chat JID to forward messages from. Everything else other | | | groups/DMs is silently ignored. Group JIDs end in @g.us, individual DM | | | JIDs end in @s.whatsapp.net. You can find this in GOWA's chat list/logs. | | | TARGET GROUP JID = "" <-- SET THIS, e.g. "1203xxxxxxxxxxxxxx@g.us" | | | Base URL of your running GOWA REST server. Needed because the webhook | | | payload only gives us a phone number/JID — the actual profile picture URL | | | has to be fetched separately from GOWA's /user/avatar endpoint. | | | GOWA BASE URL = "" <-- SET THIS, e.g. "http://localhost:3000" | | | Basic auth credentials for GOWA, ONLY if you started it with | | | --basic-auth=user:pass. Leave both as None if GOWA has no basic auth. | | | GOWA BASIC AUTH USER = None <-- SET THIS if applicable, e.g. "myuser" | | | GOWA BASIC AUTH PASS = None <-- SET THIS if applicable, e.g. "mypassword" | | | GOWA BASIC AUTH = GOWA BASIC AUTH USER, GOWA BASIC AUTH PASS \ | | | if GOWA BASIC AUTH USER and GOWA BASIC AUTH PASS else None | | | How long in seconds to remember a sender's avatar URL before re-fetching it. | | | Avoids hammering GOWA's /user/avatar endpoint on every single message from | | | the same person — WhatsApp DPs don't change that often. | | | AVATAR CACHE TTL = 3600 | | | =============================================================== | | | Fail loudly and immediately if required config hasn't been filled in, | | | instead of silently misbehaving e.g. posting to nowhere, or forwarding | | | every chat because TARGET GROUP JID is blank and matches nothing/everything . | | | missing = | | | name for name, value in | | | "DISCORD WEBHOOK URL", DISCORD WEBHOOK URL , | | | "TARGET GROUP JID", TARGET GROUP JID , | | | "GOWA BASE URL", GOWA BASE URL , | | | if not value | | | | | | if missing: | | | raise RuntimeError | | | f"Missing required configuration: {', '.join missing }. " | | | "Fill these in at the top of app.py before running — see the " | | | "CONFIGURATION block and the module docstring for details." | | | | | | GOWA sends timestamps in UTC RFC3339 . We convert to IST for display | | | since that's the timezone that actually matters here. | | | IST = timezone timedelta hours=5, minutes=30 | | | Simple in-memory cache: { "919876543210": avatar url, timestamp fetched } | | | Lives only as long as the Flask process is running — that's fine here, | | | worst case is one extra API call after a restart. | | | avatar cache = {} | | | def format phone in jid or phone: str - str: | | | """ | | | Convert a raw WhatsApp JID or phone number into a readable Indian | | | format: "+91 XXXXX XXXXX". | | | Examples: | | | "919876543210@s.whatsapp.net" - "+91 98765 43210" | | | "919876543210" - "+91 98765 43210" | | | "9876543210" - "+91 98765 43210" | | | If the number doesn't look like a 10-digit Indian number e.g. it's a | | | different country code , we fall back to just slapping a "+" on the | | | front so we still show something sane instead of crashing or hiding it. | | | """ | | | Strip the "@s.whatsapp.net" / "@g.us" suffix, then keep only digits. | | | digits = re.sub r"\D", "", jid or phone.split "@" 0 | | | if digits.startswith "91" and len digits == 12: | | | Standard case: "91" country code + 10-digit number. | | | national = digits 2: | | | elif len digits == 10: | | | Already just the 10-digit number, no country code attached. | | | national = digits | | | else: | | | Unknown/foreign format — don't guess, just return it prefixed with "+". | | | return f"+{digits}" | | | Indian mobile numbers are conventionally split 5+5 for readability. | | | return f"+91 {national :5 } {national 5: }" | | | def format timestamp ist raw timestamp: str - str: | | | """ | | | Convert GOWA's RFC3339 UTC timestamp e.g. "2023-10-15T10:30:00Z" into | | | a readable IST date/time string, e.g. "15 Oct 2026, 4:00 PM IST". | | | Falls back to the current IST time if the timestamp is missing or in a | | | format we don't recognize — we never want a parsing hiccup here to | | | block the message from being forwarded. | | | """ | | | if not raw timestamp: | | | dt utc = datetime.now timezone.utc | | | else: | | | try: | | | Python's fromisoformat doesn't accept a trailing "Z" directly, | | | so swap it for the explicit "+00:00" UTC offset first. | | | dt utc = datetime.fromisoformat raw timestamp.replace "Z", "+00:00" | | | except ValueError: | | | dt utc = datetime.now timezone.utc | | | dt ist = dt utc.astimezone IST | | | Example output: "15 Aug 2026, 4:00 PM IST" | | | NOTE: "%-I" no leading zero on hour works on Linux/macOS. | | | On Windows, swap it for "% I" instead. | | | return dt ist.strftime "%d %b %Y, %-I:%M %p IST" | | | def get avatar url phone digits: str, device id: str - str | None: | | | """ | | | Fetch the WhatsApp profile picture URL for a given phone number by | | | calling GOWA's GET /user/avatar endpoint. | | | IMPORTANT: since GOWA added multi-device support, device-scoped | | | endpoints this one included require you to identify WHICH connected | | | WhatsApp device should handle the request. GOWA only auto-picks a | | | default device if you have exactly one registered — anything else and | | | the call fails with "device id is required". So we always pass it | | | explicitly as a device id query parameter, using the device id GOWA | | | already includes at the top level of every webhook payload. | | | Results are cached in memory keyed by phone number for AVATAR CACHE TTL | | | seconds so repeated messages from the same person don't trigger repeated | | | API calls. | | | Returns None instead of raising if the lookup fails for any reason — | | | e.g. the person has privacy settings hiding their DP, GOWA is briefly | | | unreachable, wrong credentials, etc. We never want an avatar-fetch | | | hiccup to block the actual message from being forwarded. The failure | | | reason is printed so it shows up in your terminal/server logs for | | | debugging. | | | """ | | | now = time.time | | | Check cache first — skip the network call entirely if still fresh. | | | cached = avatar cache.get phone digits | | | if cached and now - cached 1 < AVATAR CACHE TTL: | | | return cached 0 | | | try: | | | resp = requests.get | | | f"{GOWA BASE URL}/user/avatar", | | | params={"phone": phone digits, "device id": device id} if device id | | | else {"phone": phone digits}, | | | auth=GOWA BASIC AUTH, | | | timeout=5, don't let a slow GOWA instance hang the webhook handler | | | | | | resp.raise for status | | | url = resp.json .get "results", {} .get "url" | | | Cache both successes and "no url found" so we don't keep retrying | | | a lookup that's just going to fail again immediately. | | | avatar cache phone digits = url, now | | | return url | | | except Exception as e: | | | Printed so it shows up in your logs — tells you exactly why the | | | avatar lookup failed bad auth, wrong device id, 404, timeout... . | | | print f" avatar fetch failed phone={phone digits} device id={device id r} error={e}" | | | avatar cache phone digits = None, now | | | return None | | | @app.route '/webhook', methods= 'POST' | | | def forward to discord : | | | """ | | | Main webhook receiver. GOWA calls this endpoint for every WhatsApp event. | | | We only care about "message" events from our target chat — everything | | | else gets a quiet 200 OK with no further action, so GOWA doesn't retry it. | | | """ | | | data = request.json or {} | | | --- Step 1: only handle actual chat messages, ignore everything else | | | reactions, acks, group updates, calls, etc. --- | | | if data.get "event" = "message": | | | return jsonify {"status": "ignored"} , 200 | | | GOWA includes this at the TOP LEVEL of the webhook body sibling of | | | "event" and "payload" , not inside payload itself — it identifies | | | which connected WhatsApp device received the message, and we need it | | | to make device-scoped API calls like /user/avatar later on. | | | device id = data.get "device id", "" | | | payload = data.get "payload", {} | | | chat id = payload.get "chat id", "" | | | --- Step 2: only forward messages from the one chat we care about --- | | | if chat id = TARGET GROUP JID: | | | return jsonify {"status": "ignored"} , 200 | | | --- Step 3: extract and format sender info --- | | | Full JID of whoever sent the message, e.g. "919876543210@s.whatsapp.net" | | | sender jid = payload.get "from", "" | | | Just the digits, needed for the /user/avatar API call no @ suffix . | | | phone digits = re.sub r"\D", "", sender jid.split "@" 0 | | | Pretty "+91 XXXXX XXXXX" version for display. | | | formatted phone = format phone in sender jid | | | Message text. GOWA sends an empty/missing body for media-only messages | | | images, stickers, etc. , so we show a placeholder in that case. | | | body = payload.get "body", " Media or Attachment " | | | GOWA gives us an RFC3339 UTC timestamp of when the message was sent. | | | We convert it to a readable IST date/time for the Discord message. | | | formatted time = format timestamp ist payload.get "timestamp", "" | | | Figure out the best name to display, in priority order: | | | 1. sender display name - resolved saved contact / WhatsApp profile name | | | only present on newer GOWA versions | | | 2. from name - the sender's pushname what they've set as their own | | | WhatsApp display name, always present | | | 3. formatted phone - last resort if neither name is available | | | display name = | | | payload.get "sender display name" | | | or payload.get "from name" | | | or formatted phone | | | | | | Build the Discord webhook "username" the name shown above the message . | | | If we actually have a real name, show it; otherwise just show the | | | formatted phone number. Both get the "- on WhatsApp" suffix. | | | username = f"{display name} - on WhatsApp" if display name = formatted phone \ | | | else f"{formatted phone} - on WhatsApp" | | | --- Step 4: fetch their WhatsApp profile picture for the Discord avatar --- | | | avatar url = get avatar url phone digits, device id if phone digits else None | | | --- Step 5: build and send the Discord webhook payload --- | | | discord payload = { | | | "username": username, | | | Quote-block with the sender's number, then the date/time it was | | | sent, then the actual message body underneath. | | | "content": f" {formatted phone} \n {formatted time}\n{body}", | | | } | | | Only include avatar url if we actually found one — Discord will use the | | | webhook's default avatar if this key is omitted. | | | if avatar url: | | | discord payload "avatar url" = avatar url | | | requests.post DISCORD WEBHOOK URL, json=discord payload | | | return jsonify {"status": "ok"} , 200 | | | if name == ' main ': | | | Runs locally on port 5000 by default — see the SETUP section at the | | | top of this file for how to expose this to GOWA via ngrok or deploy | | | it to a host like Render instead, if you'd rather not run it locally . | | | app.run host='0.0.0.0', port=5000 |