Webhooks

Subscribe to inbound messages and delivery status events via Qyvo webhooks, including signature verification.

Webhooks let you push events out of Qyvo into your stack as they happen, instead of polling triggers. Today Qyvo emits two event families: message events and status events. Configure them in Settings → Webhooks.

Setup

  1. Open Settings → Webhooks
  2. Click Add webhook, paste the destination URL (must be https://)
  3. Pick the events you want to receive (you can pick more than one)
  4. Save — Qyvo immediately fires a webhook.test event to confirm reachability
  5. Copy the signing secret that's displayed once — Qyvo will sign every payload with HMAC-SHA256

The signing secret is shown for 90 seconds, then masked forever. If you lose it, rotate the webhook (it gets a new secret) — never log the secret server-side.

Event types

Event When fired Body shape
webhook.test Right after creating the webhook {event, webhook_id, timestamp}
message.received An inbound text/media message arrives from a contact {event, message: {...}, contact: {...}}
message.status.sent Outbound message accepted by Meta {event, message_id, whatsapp_message_id, timestamp}
message.status.delivered Meta confirms delivery Same shape
message.status.read Recipient opened the message Same shape
message.status.failed Meta rejected — the message ends up in the dashboard inbox with status: failed Same shape, plus error_code and error_message

The message object on message.received matches the response shape of the new-message-received trigger.

Payload shape

Headers always include:

Content-Type: application/json
X-Qyvo-Signature: t=1730918400,v1=4f3a...
X-Qyvo-Event: message.received
X-Qyvo-Webhook-Id: 01J4K...

A message.received body:

{
  "event": "message.received",
  "timestamp": "2026-05-07T08:14:23+00:00",
  "tenant_id": "01HZX9...",
  "message": {
    "id": "01J4M...",
    "direction": "inbound",
    "type": "text",
    "content": { "body": "Hi, do you ship to Canada?" },
    "status": "received",
    "contact_id": "01J1Y..."
  },
  "contact": {
    "id": "01J1Y...",
    "phone": "+14155550123",
    "name": "Romain"
  }
}

Verify the signature

Qyvo signs the raw request body with HMAC-SHA256 keyed by your webhook's signing secret. The header is X-Qyvo-Signature: t=<timestamp>,v1=<hex_signature>.

Compute HMAC_SHA256(secret, t + "." + raw_body) and compare with v1. Reject if they don't match, or if t is more than 5 minutes old (replay protection).

import crypto from 'crypto';

function verifyQyvoSignature(secret, header, rawBody) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex');
  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))) {
    throw new Error('bad signature');
  }
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) {
    throw new Error('stale timestamp');
  }
}
function verifyQyvoSignature(string $secret, string $header, string $rawBody): void
{
    parse_str(strtr($header, [',' => '&']), $parts);
    $expected = hash_hmac('sha256', $parts['t'].'.'.$rawBody, $secret);
    if (!hash_equals($expected, $parts['v1'])) {
        throw new RuntimeException('bad signature');
    }
    if (abs(time() - (int) $parts['t']) > 300) {
        throw new RuntimeException('stale timestamp');
    }
}
import hmac, hashlib, time

def verify_qyvo_signature(secret: str, header: str, raw_body: bytes) -> None:
    parts = dict(p.split('=') for p in header.split(','))
    expected = hmac.new(
        secret.encode(),
        f"{parts['t']}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    if not hmac.compare_digest(expected, parts['v1']):
        raise ValueError('bad signature')
    if abs(time.time() - int(parts['t'])) > 300:
        raise ValueError('stale timestamp')

Retries

If your endpoint returns anything other than 2xx (or times out after 10 seconds), Qyvo retries with exponential backoff: 1 min, 5 min, 30 min, 2 h, 12 h, 24 h — then drops the event. The webhook delivery log in the dashboard shows attempts, the response code, and the body Qyvo received back.

Idempotency: each event has an immutable X-Qyvo-Webhook-Id header. If you process events into a database, write INSERT … ON CONFLICT (webhook_id) DO NOTHING against that id to make retries safe.

Local development

The fastest way to receive webhooks against a laptop is ngrok http 3000 (or cloudflared tunnel). Paste the public URL into Settings → Webhooks → Add webhook, fire webhook.test, and you're set.