> ## Documentation Index
> Fetch the complete documentation index at: https://docs.maximem.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# WhatsApp: Single-WABA Inbound + Outbound Campaign

> One WABA number running scheduled outbound campaigns alongside memory-aware inbound support.

<Info>
  **Status:** In Development · Playground demo coming soon.
  The recipe below is complete and runnable today; only the hosted playground showcase is pending.
</Info>

A single WABA number doing two jobs at once: pushing scheduled outbound campaigns (template messages) to opted-in recipients, and handling the inbound replies those campaigns generate, all with shared memory so the inbound agent knows what was sent, when, and to whom.

## What you'll build

A WhatsApp system that:

* **Schedules and sends outbound campaign templates** to a segmented audience
* **Tracks delivery + read receipts + replies** per recipient in memory
* **Routes inbound replies** through a memory-aware agent that knows the campaign context
* **Respects opt-outs**: STOP keywords flip a flag the campaigner reads before each send

**Est. build time:** 75-90 minutes.

## When to use this recipe

Build this if:

* You run outbound campaigns (announcements, re-engagement, reminders) on WhatsApp
* You need the inbound response on those campaigns to feel coherent ("yes I want it" matches a specific template send)
* Compliance (24-hour rule, opt-outs, template approval) matters
* You want one number for both directions, not two separate systems

## Architecture at a glance

```mermaid theme={null}
flowchart TD
    Scheduler[Campaign scheduler<br/>cron / queue worker] -->|template send| WA[WhatsApp Cloud API]
    WA --> Recipient[Recipient]
    WA -->|delivery / read receipts| Webhook1[Webhook]
    Webhook1 --> Ingest1[("Synap ingest:<br/>outbound + delivery state")]
    Recipient -->|reply| Webhook2[Webhook inbound]
    Webhook2 --> Fetch[(Synap context fetch<br/>includes campaign context)]
    Fetch --> Agent[Reply agent]
    Agent -->|outbound message| WA
    Agent -.-> Ingest2[(Synap ingest: reply)]
```

Memory is the bridge. The inbound agent doesn't need to be told "this person got campaign C-23 yesterday"; it pulls that from Synap.

## Stack

| Layer         | Choice                                                                                          |
| ------------- | ----------------------------------------------------------------------------------------------- |
| **Synap SDK** | `maximem-synap` (Python) / `@maximem/synap-js-sdk` (TypeScript)                                 |
| **WhatsApp**  | WhatsApp Cloud API                                                                              |
| **Scheduler** | Celery + Redis (Python) / BullMQ + Redis (TypeScript), or any cron-shaped runner                |
| **Framework** | [OpenAI Agents SDK](/integrations/openai-agents) / [Vercel AI SDK](/integrations/vercel-ai-sdk) |
| **LLM**       | OpenAI `gpt-4o-mini` (cheap; replies are short)                                                 |

## Prerequisites

* A Synap API key. See [Authentication](/setup/authentication)
* A WABA number with **approved template messages** for your campaigns
* A scheduler / queue (Celery, BullMQ, cron, your call)
* Redis for opt-out flags, send dedupe, session windows
* **Python:** Python 3.11+
* **TypeScript:** Node 18+ **and** Python 3.11+ on the host

<Warning>
  WhatsApp requires every outbound-to-cold-recipient message to use a pre-approved template. Free-form messages only work inside the 24-hour customer-initiated session window. Build this constraint into your scheduler, not the AI.
</Warning>

### Install

<CodeGroup>
  ```bash Python theme={null}
  pip install maximem-synap maximem-synap-openai-agents openai-agents heyoo celery redis
  ```

  ```bash uv theme={null}
  uv add maximem-synap maximem-synap-openai-agents openai-agents heyoo celery redis
  # pip-compatible (existing venv): uv pip install maximem-synap maximem-synap-openai-agents openai-agents heyoo celery redis
  ```

  ```bash TypeScript theme={null}
  npm install @maximem/synap-js-sdk @maximem/synap-vercel-adk ai @ai-sdk/openai bullmq ioredis
  ```
</CodeGroup>

## Build it

### 1. Identity & scoping

* `customer_id = "<your-business>"`
* `user_id = <recipient phone>` (hashed if you prefer)
* `conversation_id = <one per phone>`: rolling

<Note>
  `conversation_id`, `user_id`, and `customer_id` must be valid UUIDs. Since these key off the recipient phone, derive a deterministic UUID from it with `uuid.uuid5(...)` (Python) rather than passing the raw phone string.
</Note>

### 2. The outbound side: campaign sender

Templates are pre-approved on Meta. Your scheduler picks the audience, fills in template variables, and sends.

<CodeGroup>
  ```python Python theme={null}
  from celery import Celery
  celery = Celery("campaigns", broker=os.environ["REDIS_URL"])

  @celery.task
  async def send_campaign(campaign_id: str, recipient_phone: str, vars: dict):
      if await is_opted_out(recipient_phone):
          return {"skipped": "opted_out"}

      # 1. Send the template via WABA
      wa_response = wa.send_template(
          recipient_id=recipient_phone,
          template=campaign_id,            # e.g. "may_relaunch_v3"
          components=template_components(vars),
      )

      # 2. Ingest the outbound into Synap so the inbound agent has context
      await sdk.memories.create(
          document=f"Outbound campaign: {campaign_id}\nVariables: {vars}",
          document_type="campaign-send",
          user_id=recipient_phone,
          customer_id=CUSTOMER_ID,
          metadata={
              "campaign_id": campaign_id,
              "wa_message_id": wa_response["messages"][0]["id"],
              "direction": "outbound",
          },
      )
      return {"sent": wa_response["messages"][0]["id"]}
  ```

  ```typescript TypeScript theme={null}
  // Worker (BullMQ)
  import { Worker } from "bullmq";

  new Worker("campaigns", async (job) => {
    const { campaignId, recipientPhone, vars } = job.data;
    if (await isOptedOut(recipientPhone)) return { skipped: "opted_out" };

    const waResponse = await sendWaTemplate(recipientPhone, campaignId, vars);

    await synap.sdk.memories.create({
      document: `Outbound campaign: ${campaignId}\nVariables: ${JSON.stringify(vars)}`,
      documentType: "campaign-send",
      userId: recipientPhone,
      customerId: CUSTOMER_ID,
      metadata: {
        campaignId,
        waMessageId: waResponse.messages[0].id,
        direction: "outbound",
      },
    });
    return { sent: waResponse.messages[0].id };
  }, { connection: redis });
  ```
</CodeGroup>

### 3. Webhook: delivery / read receipts + inbound

The same webhook receives both delivery state updates and recipient replies.

<CodeGroup>
  ```python Python theme={null}
  @app.post("/webhook/whatsapp")
  async def webhook(request: Request, bg: BackgroundTasks):
      body = await request.json()

      # Delivery / read receipts
      for status in iter_statuses(body):
          bg.add_task(record_delivery, status)

      # Inbound messages
      for inbound in iter_inbound(body):
          if is_optout(inbound["text"]):
              bg.add_task(mark_opted_out, inbound["from"])
              wa.send_message("You're opted out. Reply START to opt back in.", inbound["from"])
              continue
          bg.add_task(handle_reply, inbound["from"], inbound["text"], inbound["context"])

      return {"ok": True}

  async def record_delivery(status: dict):
      await sdk.memories.create(
          document=f"Delivery status: {status['status']}",
          document_type="campaign-delivery",
          user_id=status["recipient_id"],
          customer_id=CUSTOMER_ID,
          metadata={"wa_message_id": status["id"], "status": status["status"]},
      )
  ```

  ```typescript TypeScript theme={null}
  export const runtime = "nodejs";

  export async function POST(req: Request) {
    const body = await req.json();
    for (const status of iterStatuses(body)) {
      recordDelivery(status).catch(console.error);
    }
    for (const inbound of iterInbound(body)) {
      if (isOptout(inbound.text)) {
        markOptedOut(inbound.from);
        sendWaMessage(inbound.from, "You're opted out. Reply START to opt back in.");
        continue;
      }
      handleReply(inbound.from, inbound.text, inbound.context).catch(console.error);
    }
    return Response.json({ ok: true });
  }
  ```
</CodeGroup>

### 4. The inbound reply agent

The agent reads the recent `campaign-send` and `campaign-delivery` memories before responding, so its reply matches the campaign that triggered the conversation.

<CodeGroup>
  ```python Python theme={null}
  SYSTEM = """You are a WhatsApp business agent. Your replies are short.

  - The recipient may be responding to a recent campaign. Search memory for the most recent
    `campaign-send` document. If it's within the last 7 days, treat their reply as a
    response to that campaign and act on the campaign's intent.
  - If they're asking a generic support question unrelated to recent campaigns, respond
    from general memory.
  - Always honor opt-out (STOP, UNSUBSCRIBE, REMOVE), but the webhook handles those before
    you ever see them, so if you receive the message it's not an opt-out.
  - Keep replies under 2 sentences when possible."""

  async def handle_reply(phone: str, text: str, ctx: dict):
      # Ingest the inbound first
      await sdk.memories.create(
          document=f"Customer: {text}",
          document_type="ai-chat-conversation",
          user_id=phone,
          customer_id=CUSTOMER_ID,
          metadata={
              "channel": "whatsapp",
              "direction": "inbound",
              "in_reply_to_wa_id": ctx.get("id"),  # WA threads replies to specific msg
          },
      )

      synap_search = create_search_tool(sdk=sdk, user_id=phone, customer_id=CUSTOMER_ID)
      synap_store  = create_store_tool(sdk=sdk, user_id=phone, customer_id=CUSTOMER_ID)

      agent = Agent(
          name="wa_reply_agent",
          instructions=SYSTEM,
          tools=[
              FunctionTool(synap_search, name_override="synap_search"),
              FunctionTool(synap_store,  name_override="synap_store"),
              # ...your business tools
          ],
      )
      result = await Runner.run(agent, input=text)
      reply = result.final_output

      wa.send_message(reply, phone)
      await sdk.memories.create(
          document=f"Agent: {reply}",
          document_type="ai-chat-conversation",
          user_id=phone,
          customer_id=CUSTOMER_ID,
          metadata={"channel": "whatsapp", "direction": "outbound"},
      )
  ```

  ```typescript TypeScript theme={null}
  const SYSTEM = `You are a WhatsApp business agent. Your replies are short.

  - The recipient may be responding to a recent campaign. Check memory for a recent
    campaign-send document; if it's within the last 7 days, treat the reply as a response
    to that campaign.
  - Otherwise respond from general memory.
  - Keep replies under 2 sentences when possible.`;

  async function handleReply(phone: string, text: string, ctx: any) {
    await synap.sdk.memories.create({
      document: `Customer: ${text}`,
      documentType: "ai-chat-conversation",
      userId: phone,
      customerId: CUSTOMER_ID,
      metadata: {
        channel: "whatsapp",
        direction: "inbound",
        inReplyToWaId: ctx?.id,
      },
    });

    const model = synap.wrap(openai("gpt-4o-mini"), {
      userId: phone,
      customerId: CUSTOMER_ID,
      conversationId: phone,
    });

    const { text: reply } = await generateText({
      model,
      system: SYSTEM,
      prompt: text,
      // tools: { ...your business tools }
    });

    await sendWaMessage(phone, reply);
    await synap.sdk.memories.create({
      document: `Agent: ${reply}`,
      documentType: "ai-chat-conversation",
      userId: phone,
      customerId: CUSTOMER_ID,
      metadata: { channel: "whatsapp", direction: "outbound" },
    });
  }
  ```
</CodeGroup>

### 5. Opt-out handling

Compliance-critical. Two layers: STOP keyword in the webhook (immediate), and a Redis flag the sender checks before every send.

<CodeGroup>
  ```python Python theme={null}
  OPT_OUT_KEYWORDS = {"STOP", "UNSUBSCRIBE", "REMOVE", "QUIT"}

  def is_optout(text: str) -> bool:
      return text.strip().upper() in OPT_OUT_KEYWORDS

  async def mark_opted_out(phone: str):
      await redis.set(f"optout:{phone}", "1")
      await sdk.memories.create(
          document="Recipient opted out of campaigns.",
          document_type="campaign-optout",
          user_id=phone,
          customer_id=CUSTOMER_ID,
      )

  async def is_opted_out(phone: str) -> bool:
      return bool(await redis.get(f"optout:{phone}"))
  ```

  ```typescript TypeScript theme={null}
  const OPT_OUT_KEYWORDS = new Set(["STOP", "UNSUBSCRIBE", "REMOVE", "QUIT"]);
  const isOptout = (text: string) => OPT_OUT_KEYWORDS.has(text.trim().toUpperCase());

  async function markOptedOut(phone: string) {
    await redis.set(`optout:${phone}`, "1");
    await synap.sdk.memories.create({
      document: "Recipient opted out of campaigns.",
      documentType: "campaign-optout",
      userId: phone,
      customerId: CUSTOMER_ID,
    });
  }

  const isOptedOut = async (phone: string) => Boolean(await redis.get(`optout:${phone}`));
  ```
</CodeGroup>

## Run & verify

```text Campaign C-may-relaunch fires for phone +1555... theme={null}
[scheduler] template send "may_relaunch_v3" → +1555…
[webhook]   delivered, read

Recipient (next morning): Interested. What's the price?

Agent (with campaign context loaded from memory):
"Glad to hear! The relaunch bundle is $49/mo with the migration done free if you sign
this week. Want a link to start?"

Recipient: yes please
Agent: Here you go: https://… Let me know if you hit anything weird.
```

The agent's first reply references "the relaunch bundle" without being told which campaign because it pulled `campaign-send: may_relaunch_v3` from memory.

## Customize / extend

* **Add human handoff** on top of campaigns → combine with [Single-WABA Inbound + Human Handoff](/cookbook/whatsapp-single-handoff). Same memory, same scopes.
* **Multi-WABA** for different campaign personas → see [Multi-WABA Shared Memory](/cookbook/whatsapp-multi-waba-shared).
* **Campaign performance memory** → store reply rates and best-performing copy in a separate `customer_id`-scoped memory pool to inform next campaigns.
* **Replay opens / clicks from your CRM** → use [Patterns → Replay History](/patterns/replay-history) to seed campaign engagement signal at launch.

## Troubleshooting

**Agent replies as if no campaign was sent**

* The agent's `synap_search` isn't fetching the recent `campaign-send`. Sharpen the system prompt to require a search first, or increase `maxResults` in the wrapper.
* Verify `record_delivery` runs *after* `send_campaign` writes the `campaign-send` doc: race conditions can hide the outbound.

**Sender keeps sending to opted-out recipients**

* The opt-out check belongs **inside** the worker task, not just at scheduling time. Audiences are computed in advance; opt-outs happen continuously.

**Recipient gets duplicated outbound from the same campaign**

* Schedule with idempotency keys: `(campaign_id, recipient_phone)` → dedupe in Redis with a 30-day TTL.

**Replies come in after the 24-hour window**

* Outside the window, free-form outbound is blocked by WhatsApp. Auto-fall-back to a "please-restart-conversation" template, or stay silent until the recipient initiates.

## Related

* **Integrations:** [OpenAI Agents SDK](/integrations/openai-agents) · [Vercel AI SDK](/integrations/vercel-ai-sdk)
* **Concepts:** [Memory Types](/concepts/memories-and-context#memory-types) · [Runtime Ingestion](/concepts/how-ingestion-works#runtime-ingestion) · [Conversational Context Lifecycle](/concepts/context-end-to-end#short-term-context)
* **Patterns:** [Replay History](/patterns/replay-history) · [Graceful Degradation](/patterns/graceful-degradation) · [Multi-Tenant SaaS](/patterns/multi-tenant-saas)
* **Other recipes:** [WhatsApp + Human Handoff](/cookbook/whatsapp-single-handoff) · [Multi-WABA Shared Memory](/cookbook/whatsapp-multi-waba-shared)
