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 names the record in event.data — deal.* events carry event.data.deal,
// invoice.paid carries event.data.invoice, requirement.met carries event.data.requirement,
// contact.created carries event.data.contact. See the webhooks guide for the full catalog.
console.log(event.event, event.data)
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 record the event is about (a deal, invoice, requirement, or contact — the example below is a deal.stage_changed delivery). 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. valueCents is in the deal’s own currency (its minor units — a ¥500,000 deal is 500000), named by the currency field; baseAmountCents is that value converted to the workspace base currency, or null when no exchange rate is available. currency and baseAmountCents were added to the version 1 envelope — purely additive, so existing consumers are unaffected.
{
"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,
"currency": "USD",
"baseAmountCents": 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"
}
}Seven events are sent today — see the webhooks guide for the full catalog and each family’s payload shape:
- 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.
- invoice.paid — an invoice transitioned to paid.
- requirement.met — a deal document requirement was fulfilled or waived.
- contact.created — a new contact was added.
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.