# Receive messages with webhooks

Source: https://docs.revoplyai.com/guides/receive-messages-with-webhooks/

> Build a receiver that gets every customer message as it arrives, verifies it, deduplicates it and answers in time.

This guide builds an endpoint that receives [`message.received`](/webhooks/events/message-received/)
every time a customer writes to you on any channel, and logs who wrote what. You need the
Business plan and an Owner or Admin login.

## 1. Write the receiver [#1-write-the-receiver]

The receiver must do four things, in this order: verify the signature against the raw
body, skip an event id it has already handled, answer `2xx` within 10 seconds, then do the
work.

<CodeBlockTabs defaultValue="Node.js (Express)">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="Node.js (Express)">
      Node.js (Express)
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python (Flask)">
      Python (Flask)
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="PHP (Laravel)">
      PHP (Laravel)
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="C# (ASP.NET Core)">
      C# (ASP.NET Core)
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="Node.js (Express)">
    ```js
    // A RevoplyAI webhook receiver on Express 4 or 5.
    //
    //   npm install express
    //   REVOPLY_WEBHOOK_SECRET=whsec_… node express.mjs

    import express from 'express';
    import { verifyRevoplySignature } from '../verify.node.mjs';

    const secret = process.env.REVOPLY_WEBHOOK_SECRET;
    const app = express();

    // Stands in for your database: remember the event ids you have handled.
    const handled = new Set();

    // #region route
    // express.raw() on this route, registered before any app.use(express.json()): the signature
    // covers the exact bytes we sent, and a parsed-then-re-serialised body is not those bytes.
    app.post('/webhooks/revoply', express.raw({ type: 'application/json' }), (req, res) => {
      if (!verifyRevoplySignature(req.body, req.get('X-Revoply-Signature'), secret)) {
        return res.status(400).send('Invalid signature');
      }

      const event = JSON.parse(req.body.toString('utf8'));

      // The same event can arrive more than once: acknowledge a repeat and do nothing.
      if (handled.has(event.id)) return res.sendStatus(200);
      handled.add(event.id);

      // Answer within 10 seconds, then do the work.
      res.sendStatus(200);
      setImmediate(() => handleEvent(event));
    });
    // #endregion

    // #region handle
    function handleEvent(event) {
      // "Send test" posts sample data about nobody real, marked "test": true.
      if (event.test) {
        console.log(`test ${event.type} received`);
        return;
      }

      switch (event.type) {
        case 'message.received': {
          const { conversation, contact, message } = event.data;
          console.log(
            `${contact?.name ?? contact?.phone ?? 'A visitor'} wrote on ${conversation.channelType}:`,
          );
          console.log(message.text ?? `[${message.type}]`);
          break;
        }
        default:
          // A type you did not expect is still acknowledged; new events may be added.
          console.log(`ignored ${event.type}`);
      }
    }
    // #endregion

    // The rest of the app may parse JSON as usual, registered after the webhook route.
    app.use(express.json());

    app.listen(3000, () => console.log('Listening on port 3000, path /webhooks/revoply'));

    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python (Flask)">
    ```python
    @app.post("/webhooks/revoply")
    def revoply_webhook():
        # get_data() is the body as it arrived. Never verify json.dumps(request.json):
        # re-serialised JSON is not the bytes we signed.
        raw_body = request.get_data()
        if not verify_revoply_signature(raw_body, request.headers.get("X-Revoply-Signature"), SECRET):
            return "Invalid signature", 400

        event = json.loads(raw_body)
        if event["id"] in handled:
            return "", 200
        handled.add(event["id"])

        # Answer within 10 seconds; hand slow work to a queue.
        print(event["type"], "(test)" if event["test"] else "")
        return "", 200
    ```
  </CodeBlockTab>

  <CodeBlockTab value="PHP (Laravel)">
    ```php
    public function __invoke(Request $request)
    {
        // getContent() is the body as it arrived. Never verify json_encode($request->all()):
        // re-encoded JSON is not the bytes we signed.
        $rawBody = $request->getContent();

        if (!revoply_verify_signature($rawBody, $request->header('X-Revoply-Signature'), (string) config('services.revoply.webhook_secret'))) {
            return response('Invalid signature', 400);
        }

        $event = json_decode($rawBody, true);

        // The same event can arrive more than once: acknowledge a repeat and do nothing.
        if (!Cache::add('revoply-event:' . $event['id'], true, now()->addDays(30))) {
            return response('', 200);
        }

        // Answer within 10 seconds; queue slow work (dispatch a job) instead of doing it here.
        logger()->info('RevoplyAI event', ['type' => $event['type'], 'test' => $event['test']]);

        return response('', 200);
    }
    ```
  </CodeBlockTab>

  <CodeBlockTab value="C# (ASP.NET Core)">
    ```csharp
    app.MapPost("/webhooks/revoply", async (HttpRequest request) =>
    {
        // Verify the bytes that arrived. If anything reads the body before this handler
        // ([FromBody] binding, a logging middleware), buffer it and rewind, or there is
        // nothing left to verify.
        request.EnableBuffering();
        using var copy = new MemoryStream();
        await request.Body.CopyToAsync(copy);
        request.Body.Position = 0;
        var rawBody = copy.ToArray();

        string? header = request.Headers["X-Revoply-Signature"];
        if (!RevoplySignature.Verify(rawBody, header, [secret]))
        {
            return Results.BadRequest();
        }

        using var envelope = JsonDocument.Parse(rawBody);
        var id = envelope.RootElement.GetProperty("id").GetString()!;
        var type = envelope.RootElement.GetProperty("type").GetString();
        var isTest = envelope.RootElement.GetProperty("test").GetBoolean();

        // The same event can arrive more than once: acknowledge a repeat and do nothing.
        if (!Handled.TryAdd(id, true))
        {
            return Results.Ok();
        }

        // Answer within 10 seconds; queue the real work instead of doing it here.
        Console.WriteLine($"{id} {type}{(isTest ? " (test)" : "")}");
        return Results.Ok();
    });
    ```
  </CodeBlockTab>
</CodeBlockTabs>

Each one imports the verifier from [Signatures](/webhooks/signatures/#verifiers). The id
set is a stand-in: in production, record handled ids in your database, in the same
transaction as the work they trigger.

## 2. Make it reachable [#2-make-it-reachable]

We deliver only to public `https://` addresses. Deploy the receiver, or expose it from your
computer with a tunnel such as `ngrok http 3000`; see
[Testing safely](/get-started/testing-safely/#reaching-a-receiver-on-your-computer).

## 3. Add the endpoint [#3-add-the-endpoint]

1. Open **Integrations → Webhooks → Add endpoint**.
2. Enter your receiver's URL, such as `https://example.com/webhooks/revoply`, and choose
   **Message received**.
3. Save, and copy the signing secret it shows once into your receiver's configuration as
   `REVOPLY_WEBHOOK_SECRET`.

## 4. Send a test [#4-send-a-test]

Choose **Send test** on the endpoint, pick **Message received**, and send. Your receiver
logs `test message.received received`, and the dashboard shows the `200` it answered.
The example is marked `"test": true`; the receiver above skips such events before doing
any work.

If the test fails, the dashboard shows why; a `400` usually means the signature check
failed, see [Verify webhook signatures](/guides/verify-webhook-signatures/#when-verification-fails).

## 5. Read the message [#5-read-the-message]

Real events carry `"test": false`. The fields you will use most:

| Field                           | Holds                                                                       |
| ------------------------------- | --------------------------------------------------------------------------- |
| `data.conversation.id`          | The conversation; opaque, store it as it is                                 |
| `data.conversation.channelType` | `whatsapp`, `instagram`, `web_widget`… treat unknown values as "other"      |
| `data.contact.name`             | The customer's name, when known, or `null`                                  |
| `data.contact.phone`            | E.164 or `null`; see [Identifiers](/get-started/identifiers/)               |
| `data.message.type`             | `text`, `image`, `audio`, `document`…                                       |
| `data.message.text`             | The text, or a media message's caption; at most 4,096 characters, or `null` |

The event fires once per customer message, on every channel, whoever is handling the
conversation. It does not fire for messages your business sends: the assistant's replies,
flows, or your team's.

## Before going live [#before-going-live]

* Keep the secret in your server's configuration, never in the code.
* Record handled event ids durably, for at least as long as a delivery can be retried or
  resent (30 days covers both).
* Move slow work (CRM calls, emails) to a queue so every answer is fast.
* Watch the endpoint's delivery log for failures after deploys. An endpoint that fails 100
  times in a row, or for 3 days, is switched off.
