Give an assistant on WhatsApp a memory of each person who messages it. Meta sends every message to your webhook; your server puts what is known about the sender in front of Claude, replies through the Cloud API, and saves the exchange. The next message, tomorrow or next month, starts knowing this one.
Install
pip install fastapi uvicorn httpx anthropic geniffyuv add fastapi uvicorn httpx anthropic geniffySet GENIFFY_API_KEY from API keys in the Geniffy app and ANTHROPIC_API_KEY. From your Meta app, set
WHATSAPP_TOKEN (a system user's access token), WHATSAPP_APP_SECRET (under App settings, Basic) and
WHATSAPP_VERIFY_TOKEN, a secret of your own that you enter again when you add the webhook.
Your server
import hashlib
import hmac
import json
import os
import httpx
from anthropic import AsyncAnthropic
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
from fastapi.responses import PlainTextResponse
from geniffy import AsyncGeniffy
geniffy = AsyncGeniffy() # reads GENIFFY_API_KEY
claude = AsyncAnthropic() # reads ANTHROPIC_API_KEY
graph = httpx.AsyncClient(base_url="https://graph.facebook.com/v25.0",
headers={"Authorization": f"Bearer {os.environ['WHATSAPP_TOKEN']}"})
app = FastAPI()
seen: dict[str, None] = {} # recent message ids: Meta can send one message more than once
recent: dict[str, list] = {} # each sender's last few turns, for "yes" and "that one"
@app.get("/whatsapp")
async def verify(request: Request):
"""Meta checks the address once, when you add it."""
q = request.query_params
if q.get("hub.mode") == "subscribe" and hmac.compare_digest(q.get("hub.verify_token", ""),
os.environ["WHATSAPP_VERIFY_TOKEN"]):
return PlainTextResponse(q.get("hub.challenge", ""))
raise HTTPException(403)
@app.post("/whatsapp")
async def receive(request: Request, background: BackgroundTasks):
body = await request.body()
signed = "sha256=" + hmac.new(os.environ["WHATSAPP_APP_SECRET"].encode(), body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(request.headers.get("X-Hub-Signature-256", ""), signed):
raise HTTPException(401)
for entry in json.loads(body).get("entry", []):
for change in entry.get("changes", []):
value = change.get("value", {})
for message in value.get("messages", []): # delivery and read receipts come as "statuses"
if message.get("type") != "text" or message["id"] in seen:
continue
seen[message["id"]] = None
if len(seen) > 10_000:
seen.pop(next(iter(seen)))
background.add_task(answer, value["metadata"]["phone_number_id"], message["from"],
message["text"]["body"])
return {} # at once: Meta sends a message again when it has to wait
async def answer(phone_number_id: str, sender: str, text: str) -> None:
mem = geniffy.space(f"user_{sender}") # the sender's WhatsApp number, as digits
turn = {"role": "user", "content": text}
context = await mem.context(text) # what is known that bears on the message
past = recent.setdefault(sender, [])
response = await claude.messages.create(
model="claude-opus-5-5",
max_tokens=1024,
system=f"You are a helpful assistant on WhatsApp. Keep replies short.\n\n<memory>\n{context}\n</memory>",
messages=[*past[-10:], turn],
)
reply = "".join(block.text for block in response.content if block.type == "text")
sent = await graph.post(f"/{phone_number_id}/messages", json={
"messaging_product": "whatsapp", "to": sender, "type": "text", "text": {"body": reply}})
sent.raise_for_status()
past += [turn, {"role": "assistant", "content": reply}]
await mem.memories.add(messages=[turn, {"role": "assistant", "content": reply}])Run it with uvicorn server:app, somewhere Meta can reach over HTTPS.
Meta wants a quick answer to every webhook and sends a message again when it waits too long, so the server answers at once and replies after. It keeps the ids it has seen and the last few turns of each conversation in memory, which is right for one process; with more than one, keep both where every process can see them.
Each person is the space named by their WhatsApp number. If you know them as customers, use your customer id instead, and the same memory serves their messages, calls and emails.
In Meta
- In your app, under WhatsApp, Configuration, set the Callback URL to
https://your-server/whatsappand the Verify token to yourWHATSAPP_VERIFY_TOKEN. - Under Webhook fields, subscribe to messages.
When nothing is known about the sender, the memory says so in one sentence, so Claude says it doesn't know instead of guessing. Each exchange is added as a conversation, so what the person said becomes a fact about them, and what your assistant said stays the assistant's.