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.
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.
{
"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.
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=// 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:
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.