cd /news/developer-tools/whatsapp-discord-message-forwarder-f… · home topics developer-tools article
[ARTICLE · art-91869] src=gist.github.com ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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.

read13 min views1 publishedAug 11, 2026

| """ | |

| 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) |

── more in #developer-tools 4 stories · sorted by recency
── more on @gowa 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/whatsapp-discord-mes…] indexed:0 read:13min 2026-08-11 ·