Browse documentation

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.

HeaderValue
X-Neokivo-Signaturev1=<hex> — the HMAC-SHA256 signature
X-Neokivo-TimestampSend time, in Unix seconds
X-Neokivo-EventThe event name, e.g. deal.created
X-Neokivo-DeliveryA 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

javascript
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)
}
Recomputes the signature and compares it in constant time, then checks the delivery is recent.

Wire it into an endpoint

javascript
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
})
Express example. express.raw keeps the body as bytes so the signature check sees what NeoKivo signed.

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.

json
{
  "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"
  }
}
A deal.stage_changed delivery. The fromStageName and toStageName keys appear only on stage changes.

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.