Docs / Webhooks

Webhooks

Stop polling. Give nvoke a URL and every company with a verified address lands there the moment its checks are done. Pair it with a drop and fresh launches flow into your CRM every day.

Set one up

In the desktop app: Account, Webhooks, New webhook. Or over the API with any key. The response holds the signing secret, once. Up to 10 webhooks per account.

shell
curl -X POST "https://api.nvoke.run/v1/webhooks" \
  -H "Authorization: Bearer $NVOKE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/hooks/nvoke","events":["lead.ready"],"scope":"drops"}'

scope is all (every search, drops included) or drops (only what drops find). Add dropId to follow one drop. events defaults to both. Then send a test: POST /v1/webhooks/$WEBHOOK_ID/test fires a signed ping and tells you what your server answered.

Events

lead.ready One per company that has at least one valid address, sent once every address it has is checked. Greylisting mail servers make some checks wait up to 20 minutes, so leads arrive over the life of a search, not all at once. If a search stops at your monthly limit, the companies that already have a valid address are sent anyway. company has the same shape as the company list, with every checked address and its verdict.

search.finished A search ended as done or failed. Carries the search with its counters. Usually arrives after that search’s leads. Canceling sends nothing.

lead.ready
{
  "type": "lead.ready",
  "timestamp": "2026-10-13T08:12:03.512Z",
  "data": {
    "search": {
      "id": "6f1c…",
      "kind": "drop",
      "dropId": "0b7e…",
      "dropName": "AI launches"
    },
    "company": {
      "id": "9a2d…",
      "name": "Acme Robotics",
      "domain": "acme.dev",
      "website": "https://acme.dev/",
      "source": "show_hn",
      "sourceRef": "41234567",
      "location": null,
      "phone": null,
      "category": "Robots that fold laundry",
      "crawlStatus": "done",
      "crawledAt": "2026-10-13T08:02:11.000Z",
      "emails": [
        {
          "id": "c41e…",
          "address": "hello@acme.dev",
          "kind": "generic",
          "sourceUrl": "https://acme.dev/contact",
          "verification": "valid",
          "checkedAt": "2026-10-13T08:12:03.000Z"
        }
      ]
    }
  }
}

Verify the signature

Requests follow Standard Webhooks, so its libraries for Node, Python, Go, Ruby, PHP and more verify them. webhook-signature is v1, plus the base64 HMAC SHA-256 of {webhook-id}.{webhook-timestamp}.{body}, keyed with the base64 part of your whsec_ secret. Verify the raw body, before any JSON parsing.

request
POST /hooks/nvoke HTTP/1.1
content-type: application/json
user-agent: nvoke-webhooks/1.0 (+https://nvoke.run/docs/webhooks)
webhook-id: 2f8e0c4a-5b1d-4b8e-9d4f-1c2a3b4c5d6e
webhook-timestamp: 1791792723
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
express
// npm install standardwebhooks
import { Webhook } from 'standardwebhooks';

const wh = new Webhook(process.env.NVOKE_WEBHOOK_SECRET); // whsec_...

app.post('/hooks/nvoke', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = wh.verify(req.body, req.headers); // throws on a bad signature or an old timestamp
  } catch {
    return res.status(400).end();
  }
  res.status(204).end(); // answer first, work after
  queue.push(event);
});

Without a library:

node
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(secret, headers, rawBody) {
  const id = headers['webhook-id'];
  const ts = headers['webhook-timestamp'];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // older than 5 minutes
  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest();
  return headers['webhook-signature'].split(' ').some((sig) => {
    const given = Buffer.from(sig.replace(/^v1,/, ''), 'base64');
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}

Delivery and retries

Answer with any 2xx within 10 seconds. Do the work after you answer. Redirects are not followed.

A failed attempt is tried again after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours. Every retry carries the same webhook-id, so dedupe on it. Order is not guaranteed.

A webhook that fails for a whole day is turned off. Fix the receiver, then turn it on again in the app or with PATCH /v1/webhooks/$WEBHOOK_ID {"enabled":true}. Pausing or turning a webhook off drops what was queued for it. GET /v1/webhooks/$WEBHOOK_ID/deliveries shows the last 50 with your server’s answers.

Each payload is built when it is sent. If the search was deleted or an address opted out in the meantime, the delivery is skipped rather than sent with data we no longer hold.

Rules

HTTPS only, on a public address. URLs that resolve to localhost, private networks or cloud metadata addresses are refused with webhook_url_not_allowed. Keep the secret on your server; anyone who has it can forge events. To rotate it, create a new webhook and delete the old one.

What you do with the leads is on you. In the EU, marketing email to these addresses usually needs prior consent. nvoke is a research tool and never sends email.