# 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…

> Source: <https://gist.github.com/Jivaansh-Yadav/8c7e39ba92ef32b382bc8a129d8107d0>
> Published: 2026-08-11 11:36:34+00:00

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