Skip to main content
API recipe Webhook Developer integration

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

Peter Cools · Updated on · 4 min read · View as Markdown

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.
Setup time : 30 min

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. Step 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. Step 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

    // 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. Step 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)

    # 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. Step 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

    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. Step 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

    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.

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

HeaderContentUse
X-Webhook-SignatureHex HMAC-SHA256Authenticate the request
X-Webhook-TimestampUnix timestamp (seconds) used in the signatureRefuse stale messages
X-Webhook-IdStable delivery id, the same on every retryDeduplicate

Response codes and their effect

Your responseEffect on the Rodz side
2xxDelivery succeeded
429Rodz slows down and retries a little later
Timeout or 5xxRetries with exponential backoff
Any other 4xxPermanent 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 and Make to Salesforce 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

Try it on your own market

Create an account: 100 free credits to test, no credit card.