Verify webhook signatures
Every NeoKivo webhook is signed with HMAC-SHA256 so you can prove it came from us and was not replayed. Here is the exact scheme and a Node.js verifier that checks the signature and rejects stale deliveries.
Updated 2026-07-06
When you create a webhook, NeoKivo shows a signing secret once. It POSTs a JSON payload to your endpoint as deals change, and signs each delivery with that secret. Check the signature before you trust the body.
The signing scheme
NeoKivo builds the signed string by joining the send timestamp and the raw request body with a dot: timestamp + "." + body. It computes an HMAC-SHA256 of that string keyed with your secret, hex-encodes it, and prefixes v1=. The result is sent in the X-Neokivo-Signature header, alongside the timestamp it used.
| Header | Value |
|---|---|
| X-Neokivo-Signature | v1=<hex> — the HMAC-SHA256 signature |
| X-Neokivo-Timestamp | Send time, in Unix seconds |
| X-Neokivo-Event | The event name, e.g. deal.created |
| X-Neokivo-Delivery | A unique id for this delivery attempt |
Sign against the raw request bytes, exactly as received. If you parse the JSON and re-serialize it, the bytes change and the signature will never match. Verify first, parse second.
Verify in Node.js
import crypto from 'node:crypto'
// Pass the RAW request body string (the exact bytes NeoKivo signed), the request
// headers, and your webhook's signing secret. Returns true only for a genuine,
// recent delivery.
export function verifyNeokivoWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
const signature = headers['x-neokivo-signature'] // "v1=<hex>"
const timestamp = headers['x-neokivo-timestamp'] // Unix seconds, as a string
if (!signature || !timestamp) return false
// Reject stale deliveries to blunt replay attacks. NeoKivo signs the timestamp
// but does not enforce a window itself, so you choose the tolerance.
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
if (!Number.isFinite(age) || age > toleranceSeconds) return false
const expected =
'v1=' +
crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex')
// Constant-time compare so response timing cannot leak the real signature.
const a = Buffer.from(signature)
const b = Buffer.from(expected)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}Wire it into an endpoint
import express from 'express'
const app = express()
app.post('/webhooks/neokivo', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8')
if (!verifyNeokivoWebhook(rawBody, req.headers, process.env.NEOKIVO_WEBHOOK_SECRET)) {
return res.status(400).send('bad signature')
}
const event = JSON.parse(rawBody)
// event.event is one of: deal.created, deal.stage_changed, deal.won, deal.lost
console.log(event.event, event.data.deal.title)
res.status(200).send('ok') // any 2xx acknowledges; anything else is retried
})The event payload
The JSON body carries a versioned envelope: the event name, when it happened, the workspace, and the deal. On a stage change it also includes the stage moved from and to. actorName is the person who triggered it, or null when the change came through the API.
{
"version": 1,
"event": "deal.stage_changed",
"occurredAt": "2026-07-06T14:03:11.204Z",
"workspace": { "id": "ws_3xR2", "name": "Northwind Supply" },
"data": {
"deal": {
"id": "deal_9f2a",
"title": "Northwind annual renewal",
"valueCents": 480000,
"status": "open",
"source": "referral",
"stageName": "Negotiation",
"pipelineName": "Sales",
"contactName": "Dana Reyes",
"companyName": "Northwind Supply",
"expectedCloseDate": "2026-08-01",
"createdAt": "2026-06-20T09:12:00.000Z"
},
"fromStageName": "Proposal",
"toStageName": "Negotiation",
"actorName": "Sam Okafor"
}
}Four events are sent today:
- deal.created — a new deal was added.
- deal.stage_changed — a deal moved between stages.
- deal.won — a deal was marked won.
- deal.lost — a deal was marked lost.
Delivery behavior
- Any 2xx response acknowledges the delivery. Anything else, a timeout, or a redirect counts as a failure and is retried with backoff.
- Each request times out after 10 seconds.
- Redirects are never followed. Point the webhook at its final URL.
- After 20 consecutive deliveries exhaust their retries, the webhook is disabled and you are notified.
- Deliveries are not ordered. If sequence matters, order by occurredAt.
The signing secret (it starts with nk_whsec_) is shown only once, when you create the webhook. If you lose it, delete the webhook and create a new one.
Spotted something out of date? Email hello@neokivo.com.