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 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
})
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 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.

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

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.