How to resolve a LID in Python with the Wapito WhatsApp API

Resolve the identities you already know, resolve the ones that arrive in events, and design for the case where no phone number comes back.

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.

Resolving linked identities in Python is two small requests.get calls and one schema decision: keep the identity and the phone number in separate columns, let the phone be None, and cache what you resolve. A functools.lru_cache around the lookup is enough for a script; a table with both columns is what a long-running FastAPI receiver needs, since a restart empties every in-process cache at once.

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. Look up the LID for a number you know

    Given a phone number, fetch the linked identity it maps to. Doing this for your own known contacts up front means later group events resolve from cache instead of needing a lookup each time.

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

    In Python look up a number's identity with requests.get on /contacts/{id}/lid, passing the E.164 number in the path, and store response.json()["lid"] beside the number in your contacts table. Run it once over your known contacts from a script so later events resolve from your own table; a number the account never spoke to answers 404 not_found, worth recording as unknown.

    API reference for this step
  2. Resolve a LID that arrived in an event

    Group and channel events increasingly carry a linked identity instead of a number. Resolve it once, store both, and key your own records on the linked identity because that is the value that will keep arriving.

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

    In Python resolve an identity that arrived in an event with requests.get on /contacts/lid/{lid}; the dict may carry a phone of None, and raise_for_status() will raise requests.HTTPError for a 404. Catch it, store the identity with a null phone, and move on: both are normal outcomes. The same dict carries name, push_name and is_business, so save those too.

    API reference for this step
  3. Handle the case where it cannot be resolved

    Sometimes there is no mapping to be had, and the event arrives with a null phone number. Design for that: a participant you can address and count but cannot match to a CRM row is still a participant.

    Arrives on your webhook as groups.participants.

    In Python the FastAPI handler reads payload["data"]["participant"] and uses dict.get("phone") so a missing number is None rather than a KeyError; upsert by the identity column and fill the phone only when it is present. The participant is still countable and addressable without it, and a later event may bring the number, so only overwrite the phone with a non-null value.

The whole script

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

"""LID to Phone with the Wapito WhatsApp API.

Resolve the identities you already know, resolve the ones that arrive in events, and design for the case where no phone number comes back.

Run it with:
    export WAPITO_TOKEN="wpt_..."
    python lid-to-phone.py
"""
import os

import requests

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

# --- Look up the LID for a number you know ---
url = BASE_URL + "/contacts/+15551234567/lid"

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

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

print(response.json())

# --- Resolve a LID that arrived in an event ---
url = BASE_URL + "/contacts/lid/187264518273645@lid"

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

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

print(response.json())

Receive the webhook

FastAPI receiver for groups.participants, messages — 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 = ["groups.participants", "messages"]


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

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

  • A dataclass with phone: str rather than str | None will be constructed with None at runtime anyway and only mypy will complain later; declare the field Optional from the start and let the type checker enforce it across the receiver and the batch script alike.
  • An lru_cache around a function that raises on 404 caches nothing for that identity and re-requests it on every event; cache the None result explicitly in your own dict, or return None from the wrapped function instead of letting the exception escape.
  • SQLAlchemy's session.merge on a model whose primary key is the phone column will insert a second row for the same identity the first time a phone arrives after a null; make the identity the primary key and give the phone a plain nullable index instead.

Frequently asked questions

Why did WhatsApp introduce linked identities?

To stop group and channel participation from exposing everybody's phone number to everybody else. It is a privacy improvement for users, and a real migration cost for anyone whose automation assumed the sender of a group message is always a number they can look up.

Is a LID stable over time?

It is stable for a given account, which is what makes it useful as a key. Treat it the way you would treat any external identifier: store it, index on it, and do not try to parse meaning out of it. If the account itself goes away, so does the identity.

Can I message someone using only their LID?

In contexts where the identity is the participant - inside a group, for instance - yes. Starting a fresh one-to-one conversation from an identity alone is not something to rely on, so resolve to a phone number when you need to open a new thread, and expect that to sometimes be impossible.

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.