Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.mivicall.com/llms.txt

Use this file to discover all available pages before exploring further.

Cada webhook tem o header X-Mivicall-Signature no formato:
X-Mivicall-Signature: t=1778649600,v1=5257a869e7ecebc0d5ea1ec...
Onde:
  • t é o timestamp Unix em segundos (quando enviámos)
  • v1 é o HMAC-SHA256 hex de {t}.{rawBody} usando o webhook secret
Compatível com o esquema Stripe — se já têm verify para Stripe, é literalmente o mesmo código.

Algoritmo

1

Parse do header

Separar t= e v1= na string do header.
2

Anti-replay

Comparar t com now(). Se diferença > 5 min, rejeitar (evita replay attacks).
3

Construir payload

payload = {timestamp}.{rawBody} (note o ponto separador, e usar raw bytes do body, não JSON re-stringified).
4

Calcular HMAC

expected = HMAC-SHA256(secret, payload) em hex.
5

Comparar constant-time

crypto.timingSafeEqual(expected, v1) — nunca usar === ou equals() simples (vulnerável a timing attacks).

Implementações

import crypto from 'node:crypto'
import express from 'express'

const app = express()
const SECRET = process.env.MIVI_WEBHOOK_SECRET!
const MAX_AGE_SECONDS = 300 // 5 min

// IMPORTANTE: precisamos do raw body, não do parsed JSON
app.post('/webhooks/mivicall', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.header('X-Mivicall-Signature')
  if (!sig) return res.status(401).send('missing signature')

  const parts = Object.fromEntries(sig.split(',').map(p => p.split('=')))
  const ts = Number.parseInt(parts.t, 10)
  const v1 = parts.v1

  if (Math.abs(Date.now() / 1000 - ts) > MAX_AGE_SECONDS) {
    return res.status(401).send('timestamp too old')
  }

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${ts}.${req.body}`)
    .digest('hex')

  const ok = crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
  if (!ok) return res.status(401).send('invalid signature')

  const event = JSON.parse(req.body.toString())
  // processar event.type ...

  res.status(200).end()
})

Erros comuns

Quase sempre é por re-stringification do JSON. Se o vosso framework parseia o body antes da verificação, o re-stringify pode reordenar chaves ou mudar whitespace. Solução: usar raw body bytes, sem parsing intermédio. Express: express.raw(). FastAPI: await request.body(). Spring: @RequestBody String.
O verify rejeita se diff > 5 min. Causa #1 é clock skew. Verifiquem com ntpdate ou systemd-timesyncd. Se o vosso servidor está sempre 10 min adiantado, todos os webhooks vão falhar.
Quando a clínica rotaciona o webhook secret no dashboard, têm 24h para actualizar — durante esse período aceitamos signatures de ambas as keys.