> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://developer.wamo.io/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://developer.wamo.io/_mcp/server.

# Verify and handle webhooks

wamo signs every webhook request according to the [Standard Webhooks](https://www.standardwebhooks.com) specification. Always verify the signature before trusting a request.

## Request headers

Each request includes these headers:

| Header              | Description                                                                                               |
| ------------------- | --------------------------------------------------------------------------------------------------------- |
| `webhook-id`        | Unique identifier for the message. Stays the same across retries of the same event.                       |
| `webhook-timestamp` | Unix timestamp (seconds) of the delivery attempt.                                                         |
| `webhook-signature` | Space-separated list of signatures, each in the form `v1,<base64 signature>`.                             |
| `webhook-topic`     | The event type, for example `transaction.status_changed`. It is the same as the `type` field in the body. |

## Verify with a library

The simplest approach is an official [Standard Webhooks library](https://github.com/standard-webhooks/standard-webhooks). Pass the raw request body, the headers, and your endpoint's [signing secret](/webhooks/manage-endpoints#webhook-secrets).

**`Node.js`**

```javascript title="Node.js"
import express from "express";
import { Webhook } from "standardwebhooks";

const app = express();
const wh = new Webhook(process.env.WAMO_WEBHOOK_SECRET);

app.post("/webhooks/wamo", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = wh.verify(req.body.toString(), req.headers);
  } catch (err) {
    return res.status(400).send("Invalid signature");
  }

  if (event.type === "transaction.status_changed") {
    // Handle the transaction status update
  }

  res.sendStatus(200);
});
```

**`Python`**

```python title="Python"
import os
from flask import Flask, request
from standardwebhooks.webhooks import Webhook

app = Flask(__name__)
wh = Webhook(os.environ["WAMO_WEBHOOK_SECRET"])

@app.post("/webhooks/wamo")
def wamo_webhook():
    try:
        event = wh.verify(request.get_data(as_text=True), dict(request.headers))
    except Exception:
        return "Invalid signature", 400

    if event["type"] == "transaction.status_changed":
        pass  # Handle the transaction status update

    return "", 200
```

> **Warning**
>
> Verify against the raw request body exactly as received. Parsing the JSON and serializing it again can change the bytes and make verification fail.

## Verify manually

If you can't use a library:

#### Build the signed content

Concatenate the `webhook-id` header, the `webhook-timestamp` header, and the raw body, separated by periods: `{webhook-id}.{webhook-timestamp}.{body}`.

#### Compute the expected signature

Remove the `whsec_` prefix from your secret and base64-decode the rest. Compute an HMAC-SHA256 of the signed content with that key, then base64-encode the result.

#### Compare signatures

Split `webhook-signature` on spaces and strip the `v1,` prefix from each entry. The request is valid if any entry matches your expected signature. Use a constant-time comparison.

#### Check the timestamp

Reject requests whose `webhook-timestamp` is too far from the current time (for example, more than 5 minutes) to protect against replay attacks.

## Respond quickly

wamo treats any `2xx` response as a successful delivery. Any other status code, a timeout, or a connection error counts as a failure. Return a `2xx` response as soon as you've verified and stored the event, and do slower processing asynchronously.

## Retries and duplicates

Failed deliveries are retried automatically with exponential backoff. Delivery is at least once, so your endpoint can receive the same event more than once.

* Use the `webhook-id` header as an idempotency key and skip events you've already processed. A retry of the same event has the same `webhook-id`.
* Don't rely on events arriving in order. For a transaction, `COMPLETED` and `FAILED` are final statuses: if a `PENDING` event arrives after one of them, ignore it.