# How do you receive Rodz leads by webhook and verify their HMAC signature?

> Source: https://www.rodz.io/en/guides/api/webhook-receiver-hmac/
> Updated on 2026-10-12 · Peter Cools · API recipe · All signals · Webhook

## Short answer

Expose an HTTPS POST route, read the raw body, recompute the HMAC-SHA256 of "timestamp + dot + body" with your API key’s webhook secret and compare it in constant time with the X-Webhook-Signature header. Reject stale timestamps, skip an X-Webhook-Id you already processed, answer 200 at once and process the lead afterwards. Test it with the test-webhook endpoint.

## Key facts

- The signature is a hex HMAC-SHA256 of `{X-Webhook-Timestamp}.{raw body}`, with a Unix timestamp in seconds computed again on every send.
- A timeout or a `5xx` triggers **retries** with exponential backoff; a `4xx` is permanent; a `429` asks Rodz to slow down and retry.
- The API accepts **100 requests per second** per key (sliding one-second window).
- Rodz identifies **400,000+ signals a month** (July to September 2026 average), delivered by webhook or readable through the API.

## Before you start

- A Rodz API key and its webhook secret: **Integrations → API** in the dashboard (the secret can be regenerated there).
- A signal linked to that API key (created with it through the API): with no key linked, deliveries go out unsigned.
- A public HTTPS URL for your server (a tunnel is enough during development).
- Node.js 18+ with Express 4, or Python 3.10+ with FastAPI.

## Steps

### 1. Get the webhook secret

In the Rodz dashboard, open **Integrations → API**. Each API key has a webhook secret generated automatically. Copy it into a `RODZ_WEBHOOK_SECRET` environment variable, never into the code.

### 2. Write the receiver in Node.js

The key point: read the **raw** body (`express.raw`), because the signature covers the exact bytes sent. The receiver checks the signature, refuses a timestamp older than 5 minutes, skips a delivery it has already seen, answers `200` and then processes the lead.

`node/server.js`

```javascript
// Rodz webhook receiver (Node.js 18+, Express 4).
const express = require('express');
const crypto = require('crypto');

const SECRET = process.env.RODZ_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
const seen = new Set(); // in production: Redis or your database

function isValid(rawBody, signature, timestamp) {
  if (!signature || !timestamp) return false;
  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(Buffer.concat([Buffer.from(`${timestamp}.`, 'utf8'), rawBody]))
    .digest('hex');
  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(signature, 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

async function handleLead(lead) {
  if (lead.signal && lead.signal.is_test) return console.log('test payload received');
  // Your logic: CRM, message queue, database...
  console.log(lead.signal.type, lead.company && lead.company.name);
}

const app = express();
app.post('/webhooks/rodz', express.raw({ type: 'application/json', limit: '1mb' }), (req, res) => {
  const signature = req.get('X-Webhook-Signature');
  const timestamp = req.get('X-Webhook-Timestamp');
  if (!isValid(req.body, signature, timestamp)) return res.status(401).send('invalid signature');
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
    return res.status(401).send('stale timestamp');
  }

  const deliveryId = req.get('X-Webhook-Id');
  if (deliveryId && seen.has(deliveryId)) return res.status(200).send('duplicate');
  if (deliveryId) seen.add(deliveryId);

  const lead = JSON.parse(req.body.toString('utf8'));
  res.status(200).send('ok'); // answer first
  setImmediate(() => handleLead(lead).catch(console.error)); // then process
});

app.listen(process.env.PORT || 3000);
```

### 3. Or write it in Python

Same logic with FastAPI: `await request.body()` gives the raw bytes, `hmac.compare_digest` compares in constant time, and a background task processes the lead after the response.

`python/app.py (uvicorn app:app)`

```python
# Rodz webhook receiver (Python 3.10+, FastAPI).
import hashlib
import hmac
import json
import os
import time

from fastapi import BackgroundTasks, FastAPI, Request, Response

SECRET = os.environ["RODZ_WEBHOOK_SECRET"].encode("utf-8")
TOLERANCE_SECONDS = 300
seen: set[str] = set()  # in production: Redis or your database

app = FastAPI()


def is_valid(body: bytes, signature: str, timestamp: str) -> bool:
    if not signature or not timestamp:
        return False
    message = f"{timestamp}.".encode("utf-8") + body
    expected = hmac.new(SECRET, message, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)


def handle_lead(lead: dict) -> None:
    if lead.get("signal", {}).get("is_test"):
        print("test payload received")
        return
    company = lead.get("company") or {}
    print(lead["signal"]["type"], company.get("name"))


@app.post("/webhooks/rodz")
async def rodz_webhook(request: Request, tasks: BackgroundTasks) -> Response:
    body = await request.body()
    signature = request.headers.get("X-Webhook-Signature", "")
    timestamp = request.headers.get("X-Webhook-Timestamp", "")
    if not is_valid(body, signature, timestamp):
        return Response("invalid signature", status_code=401)
    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return Response("stale timestamp", status_code=401)

    delivery_id = request.headers.get("X-Webhook-Id")
    if delivery_id and delivery_id in seen:
        return Response("duplicate", status_code=200)
    if delivery_id:
        seen.add(delivery_id)

    tasks.add_task(handle_lead, json.loads(body))
    return Response("ok", status_code=200)
```

### 4. Connect the signal and send a test

Save your URL as the signal’s `webhookUrl` (at creation, or with `PATCH /api/v1/signals/{signal_id}`). Then call `POST /api/v1/signals/{signal_id}/test-webhook`: with a `{"url": "..."}` body you test another address before saving it. The response gives `success`, your server’s `statusCode`, the `payloadSent` (with `signal.is_test` set to `true`) and `signed`: `false` means no API key is linked to the signal.

`Test the receiver`

```bash
curl -X POST "https://api.rodz.io/api/v1/signals/$SIGNAL_ID/test-webhook" \
  -H "Authorization: Bearer $RODZ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://hooks.example.com/webhooks/rodz"}'
```

### 5. Recover from an outage with replay-webhook

If your server was down for longer than the retries, `POST /api/v1/signals/{signal_id}/replay-webhook` resends every lead the signal has produced, with `signal.is_replay` set to `true`. The response gives `total`, `sent` and `failed`. Your deduplication stops the same lead from being created twice. For a simple catch-up without a webhook, `GET /api/v1/signals/{signal_id}/leads?since=...` reads the leads directly.

`Resend the signal’s leads`

```bash
curl -X POST "https://api.rodz.io/api/v1/signals/$SIGNAL_ID/replay-webhook" \
  -H "Authorization: Bearer $RODZ_API_KEY"
```

## Example payload

Excerpt of a request as your server receives it: headers, then the JSON body. The `signal` fields vary with the signal type; `contact` is `""` for a lead without a person; `signal.is_test` only appears on a test.

```http
POST /webhooks/rodz HTTP/1.1
Content-Type: application/json
X-Webhook-Id: 7f3c2a9e-1b4d-4c8e-9a01-5d6e7f8a9b0c
X-Webhook-Timestamp: 1760256000
X-Webhook-Signature: 3b1f0c...e9a4 (64 hex characters)

{
  "signal": {
    "id": "d4f5a6b7-c8d9-4e0f-a1b2-c3d4e5f6a7b8",
    "name": "Series A+ in France",
    "type": "fundraising",
    "last_signal": "Fundraising",
    "last_signal_date": "2026-10-12",
    "is_test": true,
    "funding_stage": "Series A",
    "funding_amount_usd": 5500000,
    "announcement_date": "2026-10-10"
  },
  "contact": "",
  "company": {
    "name": "Northwind Robotics",
    "website": "https://northwind-robotics.example",
    "linkedin_url": "https://www.linkedin.com/company/northwind-robotics-example",
    "hq_country_code": "FR",
    "hq_city": "Nantes"
  }
}
```

## The headers of a delivery

| Header                | Content                                        | Use                      |
| --------------------- | ---------------------------------------------- | ------------------------ |
| `X-Webhook-Signature` | Hex HMAC-SHA256                                | Authenticate the request |
| `X-Webhook-Timestamp` | Unix timestamp (seconds) used in the signature | Refuse stale messages    |
| `X-Webhook-Id`        | Stable delivery id, the same on every retry    | Deduplicate              |

## Response codes and their effect

| Your response    | Effect on the Rodz side                    |
| ---------------- | ------------------------------------------ |
| `2xx`            | Delivery succeeded                         |
| `429`            | Rodz slows down and retries a little later |
| Timeout or `5xx` | Retries with exponential backoff           |
| Any other `4xx`  | Permanent failure, no retry                |

Answer within a second or two and move the heavy work (CRM call, enrichment) to a queue or a background task.

## Good practice

1. **Always verify the signature**, even behind a firewall: a webhook URL ends up being shared.
2. **Store processed `X-Webhook-Id` values** with a few days of expiry (Redis `SET NX EX`) rather than in memory.
3. **Log `signal.id` and `contact.lead_id`**: they are the ids support will ask for.
4. **Ignore `signal.is_test`** in production, and treat `signal.is_replay` as a normal, deduplicated delivery.

To send these leads to a tool without writing a server, see the [n8n](/en/guides/use-cases/funding-rounds-to-n8n/) and [Make to Salesforce](/en/guides/use-cases/job-offers-to-salesforce-with-make/) recipes.

## Frequently asked questions

### What happens if my server returns an error?

A `2xx` marks the delivery as successful. A timeout or a `5xx` is retried with exponential backoff. A `4xx` (bad URL, auth, validation) is treated as permanent and not retried, except `429`, which asks Rodz to slow down and retry.

### Why can the same lead arrive twice?

Because retries guarantee at-least-once delivery: if your response is lost, Rodz sends again. The `X-Webhook-Id` header is the same on every attempt of one delivery; deduplicate on it.

### Where do I find the webhook secret?

In **Integrations → API** in the dashboard: each API key has its secret, which you can regenerate on the same page. After a regeneration, update your receiver’s environment variable.

### Why does verification fail when the secret is right?

Almost always because the signature is recomputed over JSON that was parsed and re-serialized instead of the raw bytes. Read the body before any JSON parser (`express.raw`, `await request.body()`). Also check that the test returns `signed: true`.

### Do I need a receiver to read leads?

No. `GET /api/v1/signals/{signal_id}/leads` returns a signal’s leads in the webhook format, with `since`, `limit` and `offset`. Reading leads is free: a lead is charged once, when it is produced.

## Sources

- [Rodz API reference: webhooks, signature, retries, test-webhook and replay-webhook](https://api.rodz.io/docs)
- [RFC 2104: HMAC, keyed-hashing for message authentication](https://www.rfc-editor.org/rfc/rfc2104)
- [Express: express.raw()](https://expressjs.com/en/api.html)

## Related guides

- https://www.rodz.io/en/guides/api/poll-signal-leads/
- https://www.rodz.io/en/guides/api/create-and-route-a-signal/
- https://www.rodz.io/en/guides/use-cases/funding-rounds-to-n8n/
