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

# Hello world: webhook routing

> Route WhatsApp messages from the sandbox number to your own code and reply through the API.

With webhook routing, Wassist hands every inbound WhatsApp message to your endpoint and doesn't run an agent. Your code decides what to say and replies with one API call. Use it to connect an agent you've already built (LangChain, the OpenAI Agents SDK, [Vercel eve](#using-vercel-eve), or your own) or any backend logic.

By the end of this page you'll send "hi" to the sandbox number and your own server will reply **"Hello, world! You said: hi"**.

```mermaid theme={null}
sequenceDiagram
    participant You as You (WhatsApp)
    participant W as Wassist
    participant S as Your server
    You->>W: "hi"
    W->>S: POST subscription.message.received (signed)
    S->>W: conversations.messages.send(...)
    W->>You: "Hello, world! You said: hi"
```

<Warning>
  Use your **personal organization** while testing on the sandbox (switch in **Settings → Organization**), and create the API key and webhook there. Sandbox chats are filed under your personal organization, so routing set up in a team organization won't receive your messages.
</Warning>

## 1. Get an API key

In the dashboard, go to **Settings → Developers → API keys**, click **Create API key** and copy it. Your server uses it to send replies.

## 2. Write the handler

The handler verifies the signature, then replies to every routed message.

<Tabs>
  <Tab title="Node.js">
    ```bash theme={null}
    npm install @wassist/sdk express
    ```

    ```js server.mjs theme={null}
    import express from "express";
    import { Wassist } from "@wassist/sdk";

    const wassist = new Wassist({ apiKey: process.env.WASSIST_API_KEY });
    const app = express();

    // Keep the raw body: the signature is computed over the exact bytes.
    app.post("/webhook", express.raw({ type: "application/json" }), async (req, res) => {
      let event;
      try {
        event = Wassist.webhooks.constructEvent(
          req.body,
          req.header("x-wassist-signature"),
          process.env.WASSIST_WEBHOOK_SECRET,
        );
      } catch {
        return res.status(400).send("bad signature");
      }

      if (event.event === "subscription.message.received") {
        await wassist.conversations.messages.send(event.conversationId, {
          type: "text",
          text: { body: `Hello, world! You said: ${event.message.body ?? "(no text)"}` },
        });
      }

      res.sendStatus(200);
    });

    app.listen(3000, () => console.log("Listening on http://localhost:3000/webhook"));
    ```
  </Tab>

  <Tab title="Python">
    ```bash theme={null}
    pip install flask requests
    ```

    ```python server.py theme={null}
    import hashlib, hmac, os, time

    import requests
    from flask import Flask, request

    API = "https://backend.wassist.app/api/v1"
    app = Flask(__name__)


    def verified(req) -> bool:
        header = req.headers.get("X-Wassist-Signature", "")
        parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
        ts, received = parts.get("t", "0"), parts.get("v1", "")
        signed = f"{ts}.".encode() + req.get_data()
        secret = os.environ["WASSIST_WEBHOOK_SECRET"].encode()
        expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
        return hmac.compare_digest(received, expected) and abs(time.time() - int(ts)) <= 300


    @app.post("/webhook")
    def webhook():
        if not verified(request):
            return "bad signature", 400

        event = request.get_json()
        if event["event"] == "subscription.message.received":
            text = event["message"]["body"] or "(no text)"
            requests.post(
                f"{API}/conversations/{event['conversationId']}/messages/",
                headers={"X-API-Key": os.environ["WASSIST_API_KEY"]},
                json={"type": "text", "text": {"body": f"Hello, world! You said: {text}"}},
                timeout=10,
            )

        return "ok", 200


    if __name__ == "__main__":
        app.run(port=3000)
    ```
  </Tab>
</Tabs>

## 3. Expose it over HTTPS

Wassist needs a public URL to deliver to. Locally, a tunnel is the quickest option:

```bash theme={null}
ngrok http 3000
```

Copy the `https://…ngrok.app` URL it prints. Any HTTPS host works too: Vercel, Cloudflare Workers, Railway, your own server.

## 4. Create the webhook

<Steps>
  <Step title="Add the webhook">
    Go to **Settings → Developers → Webhooks** and click **Create webhook**. Set the URL to your tunnel plus `/webhook`, e.g. `https://abc123.ngrok.app/webhook`.
  </Step>

  <Step title="Keep the routing event ticked">
    All events are ticked by default. Make sure **Subscription message received** stays on. That's the event routed messages arrive as, and a webhook without it silently gets nothing.
  </Step>

  <Step title="Copy the signing secret">
    The secret is shown once, straight after you create the webhook.
  </Step>
</Steps>

Now start the server with both values:

<CodeGroup>
  ```bash Node.js theme={null}
  WASSIST_API_KEY=your-api-key WASSIST_WEBHOOK_SECRET=your-signing-secret node server.mjs
  ```

  ```bash Python theme={null}
  WASSIST_API_KEY=your-api-key WASSIST_WEBHOOK_SECRET=your-signing-secret python server.py
  ```
</CodeGroup>

## 5. Route the sandbox to your webhook

<Steps>
  <Step title="Open the sandbox number">
    Go to **Numbers** and click the number under **Sandbox numbers**.
  </Step>

  <Step title="Switch routing to your webhook">
    In **Routing**, set **Mode** to **Webhook: forward to your endpoint**, pick the webhook you just created, and click **Save routing**.
  </Step>
</Steps>

This only affects your own chat with the sandbox number. Sandbox routing has to be set from the dashboard. API keys belong to the organization rather than a person, so they have no chat to route and get a `400`.

## 6. Say hi

From the phone you signed in with, send `hi` to the sandbox number:

```text theme={null}
You:          hi
Your server:  Hello, world! You said: hi
```

Your code is now answering WhatsApp. Every delivery, including the request, your response and any retries, is listed under **Settings → Developers → Webhooks → your webhook**, where you can also replay it.

## What your endpoint receives

```json theme={null}
{
  "event": "subscription.message.received",
  "timestamp": "2026-09-26T08:15:00.000000+00:00",
  "conversationId": "5a3f…e2",
  "whatsappNumber": "447700900100",
  "contact": { "id": "c81d…4b", "name": "Alex", "phoneNumber": "447700900200" },
  "message": {
    "id": "01HXYZ…",
    "body": "hi",
    "media": [],
    "buttons": [],
    "referral": null
  },
  "latestReferral": null,
  "routing": "webhook",
  "webhookId": "9c1c…a0"
}
```

`message.body` is the text (or the image caption, or the voice note transcription). Images and voice notes also arrive as URLs in `media`, and tapped buttons in `buttons`. Use `conversationId` for everything you send back.

<Tip>
  Reply within 10 seconds and do slow work (like calling an LLM) after you've returned `200`. Wassist retries on `5xx` and timeouts, so use the `X-Wassist-Delivery` header to skip duplicates. See [Webhooks](/concepts/webhooks) for signing, retries and replay.
</Tip>

## Make it an agent

Swap the hello world line for your own logic. A typical handler shows a typing indicator, calls your agent, then replies:

```js theme={null}
if (event.event === "subscription.message.received") {
  res.sendStatus(200); // acknowledge first, work second

  await wassist.conversations.typing(event.conversationId);
  const reply = await myAgent.run(event.message.body, { threadId: event.conversationId });
  await wassist.conversations.messages.send(event.conversationId, {
    type: "text",
    text: { body: reply },
  });
  return;
}
```

Replies can also carry images, buttons and CTAs. See [Send message](/api-reference/conversations/messages/send). WhatsApp only allows free-form replies within 24 hours of the customer's last message. After that, send an approved template.

### Using Vercel eve

If your agent is built with [Vercel eve](https://github.com/vercel/eve), you don't need to write the handler at all. [`@wassist/eve`](https://www.npmjs.com/package/@wassist/eve) is a drop-in channel that verifies webhooks, shows typing indicators and sends replies:

```ts agent/channels/whatsapp.ts theme={null}
import { wassistChannel } from "@wassist/eve";

export default wassistChannel();
```

It reads `WASSIST_API_KEY` and `WASSIST_WEBHOOK_SECRET` from the environment and listens on `/eve/v1/wassist`. Point your webhook there and follow step 5 above.

## Going live

[Connect your own WhatsApp number](/guides/connect-whatsapp), then route it the same way: **Numbers → your number → Routing → Webhook**. On your own numbers you can also do it from code, and route individual conversations:

```ts theme={null}
// Every conversation on the number
await wassist.phoneNumbers.subscribe("447700900100", { webhookId, applyToExisting: true });

// Or a single conversation, e.g. hand one customer to your code
await wassist.conversations.subscribe(conversationId, { webhookId });
```

See [Conversation routing](/guides/webhooks/routing) for per-conversation overrides, lifecycle events and the 24-hour window events.

## Next steps

<CardGroup cols={2}>
  <Card title="Managed agent" icon="robot" href="/quickstart/managed-agent">
    Let Wassist run the agent instead.
  </Card>

  <Card title="Conversation routing" icon="route" href="/guides/webhooks/routing">
    Mix agents and webhooks per number and per conversation.
  </Card>
</CardGroup>
