How to get a profile photo in Python with the Wapito WhatsApp API

Read a contact or chat picture where privacy allows, set your own number's photo, and refresh your cache from contact events.

A script, a token and an HTTP client is the whole stack. Everything below runs from a file you can execute today, and the same code moves to a worker or a container without changing shape.

Profile pictures from Python are two reads and a write: requests.get for a contact or a chat, requests.patch with an uploaded image to set your own number's photo, and a FastAPI route that invalidates a cached avatar when a contacts event arrives. An empty result is the normal privacy case, so the reads return None rather than raising.

Before you start

  • pip install requests
  • os.environ["WAPITO_TOKEN"]
  • A channel with a WhatsApp number linked to it, and its API token. Create one in the dashboard — the authentication guide shows where the token goes.

How it works

  1. Read a contact's profile

    Fetch the display name and picture for a contact. Privacy settings apply: a person who shows their photo only to contacts will return nothing to a number they have not saved, and that is a normal result rather than a failure.

    import requests
    
    url = "https://api.wapito.com/v1/contacts/+15551234567/profile"
    
    headers = {"Authorization": "Bearer wpt_YOUR_TOKEN"}
    
    response = requests.get(url, headers=headers)
    
    print(response.json())

    In Python read a contact's profile with requests.get on /contacts/{id}/profile and treat an empty picture field as a normal privacy outcome; return None from your wrapper rather than raising. The dict carries the display name too, which is worth caching for an agent view even when the photo is withheld.

    API reference for this step
  2. Read a chat picture

    The same call pattern works for a chat, which covers groups as well as people. Use it to populate an agent dashboard so a human sees the same avatar they would see on their phone.

    import requests
    
    url = "https://api.wapito.com/v1/chats/15551234567@s.whatsapp.net/picture"
    
    headers = {"Authorization": "Bearer wpt_YOUR_TOKEN"}
    
    response = requests.get(url, headers=headers)
    
    print(response.json())

    In Python the chat picture uses the same requests.get shape on /chats/{id}/picture, so one function with the path as a parameter covers both; a group chat works with the group id in that path. Cache the picture URL in a dict keyed by chat id with the time you fetched it.

    API reference for this step
  3. Set the linked number's own photo

    Upload a square image and set it as the number's own picture. A number with a real photo and a real name looks like a business rather than a burner, which measurably affects how people respond to it.

    import requests
    
    url = "https://api.wapito.com/v1/users/profile"
    
    payload = {
        "name": "Acme Support",
        "status": "Replies Mon-Fri, 9 to 6 UK time."
    }
    headers = {
        "Authorization": "Bearer wpt_YOUR_TOKEN",
        "Content-Type": "application/json"
    }
    
    response = requests.patch(url, json=payload, headers=headers)
    
    print(response.json())

    In Python set your own photo with requests.patch(url, json={"picture": media_id}) after uploading a square image; Pillow's ImageOps.fit makes it square. Catch requests.HTTPError for a 415 unsupported_media_type, which means the upload was not a JPEG or PNG the profile accepts.

    API reference for this step
  4. Refresh when a contact changes theirs

    Contact updates arrive as events, so a cached avatar can be invalidated at the moment it changes instead of being re-fetched on a timer for thousands of contacts.

    Arrives on your webhook as contacts.

    In Python the FastAPI handler receives contacts events and pops the changed contact's id out of your cache dict, or deletes the row, from a background task; the next read fetches the new picture. Return the 2xx immediately so the invalidation never delays the acknowledgement.

The whole script

Every step above in one runnable file. Save it as profile-picture.py, put your token in the environment, and run it.

"""Profile Picture with the Wapito WhatsApp API.

Read a contact or chat picture where privacy allows, set your own number's photo, and refresh your cache from contact events.

Run it with:
    export WAPITO_TOKEN="wpt_..."
    python profile-picture.py
"""
import os

import requests

BASE_URL = "https://api.wapito.com/v1"
TOKEN = os.environ["WAPITO_TOKEN"]

# --- Read a contact's profile ---
url = BASE_URL + "/contacts/+15551234567/profile"

headers = {"Authorization": "Bearer " + TOKEN}

response = requests.get(url, headers=headers)

print(response.json())

# --- Read a chat picture ---
url = BASE_URL + "/chats/15551234567@s.whatsapp.net/picture"

headers = {"Authorization": "Bearer " + TOKEN}

response = requests.get(url, headers=headers)

print(response.json())

# --- Set the linked number's own photo ---
url = BASE_URL + "/users/profile"

payload = {
    "name": "Acme Support",
    "status": "Replies Mon-Fri, 9 to 6 UK time."
}
headers = {
    "Authorization": "Bearer " + TOKEN,
    "Content-Type": "application/json"
}

response = requests.patch(url, json=payload, headers=headers)

print(response.json())

Receive the webhook

FastAPI receiver for contacts — it verifies the signature, answers immediately and does the work after. Install it with pip install fastapi uvicorn and save it as webhook.py.

import hashlib
import hmac
import os
import time

from fastapi import FastAPI, Header, HTTPException, Request

app = FastAPI()
SECRET = os.environ["WAPITO_WEBHOOK_SECRET"].encode()
EVENTS = ["contacts"]


def verify(raw: bytes, header: str | None) -> bool:
    """Checks the X-Wapito-Signature header: t=<ms>,v1=<hex hmac-sha256>."""
    if not header:
        return False
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    ts, sig = parts.get("t"), parts.get("v1")
    if not ts or not sig:
        return False
    if abs(time.time() * 1000 - int(ts)) > 300_000:  # 5 minute clock skew
        return False
    expected = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)


@app.post("/wapito")
async def wapito(request: Request, signature: str | None = Header(default=None, alias="X-Wapito-Signature")):
    raw = await request.body()
    if not verify(raw, signature):
        raise HTTPException(status_code=401, detail="bad signature")
    payload = await request.json()
    if payload["event"] in EVENTS:
        print(payload["event"], payload["data"])
    return {"ok": True}  # answer 2xx fast; do the real work in a queue
Every event, its payload and the retry rules

Errors you may hit

Gotchas

  • requests does not raise on a 4xx by itself. Call response.raise_for_status() or check response.ok, otherwise a 429 send_rate_limited silently looks like a successful send and your loop keeps hammering the queue.
  • json= and data= are not interchangeable. Pass json={...} so requests sets Content-Type: application/json and encodes UTF-8 for you; data= with a dict sends form encoding and the API answers 400 invalid_request.
  • The default requests timeout is None, which means a stalled connection hangs your worker forever. Always pass timeout=(5, 30) and treat a read timeout as "maybe delivered" - re-check with the message id before you send again.
  • A group id such as 120363000000000000@g.us is a string, not a number. Building it with an f-string from an int drops the precision and you get 404 not_found.
  • Emoji and accented text need no extra work in Python 3, but reading a CSV of numbers with the default encoding on Windows will mangle them - open files with encoding="utf-8".

Where to run it

  • Google Cloud Run (container, scale to zero, one revision per deploy)
  • Render background worker for the sender, Render web service for the webhook receiver
  • A 1 GB VPS with systemd for the worker and Caddy in front of the FastAPI receiver
  • AWS Lambda + API Gateway for the webhook receiver only (the sender needs a long-lived process for queue spacing)

Pitfalls in Python

  • Caching None for a withheld picture with no expiry means a contact who later saves your number still shows as empty; store the time you checked and re-read after the contacts event or a long timeout.
  • requests.patch with data= sends form encoding and the profile endpoint answers 400 invalid_request; pass json= as with every other write.
  • A cache in a module-level dict is per-process, and a FastAPI app under several workers keeps several caches; use Redis or a table when you run more than one worker.

Frequently asked questions

Why does a contact's photo come back empty?

Almost always because of their privacy settings. WhatsApp lets everyone choose who can see their picture, and a linked number that is not in their contacts will often see nothing. It is the expected outcome for a cold contact rather than something to retry or work around.

How large should my own profile picture be?

Square and a few hundred pixels on a side is plenty; WhatsApp compresses it heavily and renders it in a small circle. A simple mark on a solid background survives that treatment far better than a detailed photograph or anything with small text in it.

Can I read the picture of a group?

Yes, through the chat picture call, provided the linked number is a member of that group. For groups the picture is the icon an admin set, and there is a dedicated group icon endpoint if you also need to change it rather than only read it.

Related

Try it on your own number

Create a channel, link a WhatsApp number by QR or pairing code, and call the API in a couple of minutes. The Sandbox plan is free and needs no card.