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