Skip to Content
Beta: Hubz API v1 is being rolled out to production. Endpoints and limits may change; every change is listed in the Changelog.

Hubz API v1 (beta) · Hubz v0.7.0 · 2026-11-06

Webhooks: Zalo message events for your CRM

Events

EventWhen
message.createdA new message in a conversation, from the customer or from staff. data.firstInbound is true for the first message the customer sends in the conversation: use it to open a new lead or ticket.
message.updatedA message changed, for example its sending status.
conversation.updatedA conversation changed: status, assignee, latest message, unread count.

Registering a webhook

PUT /v1/webhook with your https address (not an internal address) and the events you want; the key needs the events scope. The signing secret is returned when the webhook is created, when its address changes or with rotateSecret. POST /v1/webhook/test sends a ping event; DELETE /v1/webhook removes the webhook.

curl -X PUT https://api.hubz.vn/v1/webhook \
  -H "Authorization: Bearer hbz_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://crm.example.com/hubz/webhook", "events": ["message.created", "conversation.updated"]}'

What Hubz sends

A JSON POST request { id, type, version, createdAt, data } with the X-Hubz-Event, X-Hubz-Delivery (the event id, for de-duplication) and X-Hubz-Signature headers:

POST /hubz/webhook
Content-Type: application/json
X-Hubz-Event: message.created
X-Hubz-Delivery: 1042
X-Hubz-Signature: t=1793956502,v1=5d41402abc4b2a76b9719d911017c592…

{
  "id": "1042",
  "type": "message.created",
  "version": 1,
  "createdAt": "2026-11-06T09:15:02Z",
  "data": {
    "accountId": "…",
    "message": {"id": "…", "conversationId": "…", "direction": "in", "kind": "text", "text": "…", "status": "sent", "source": "customer"},
    "conversation": {"id": "…", "accountId": "…", "threadType": "user", "status": "open", "assigneeId": null},
    "contact": {"id": "…", "name": "…", "phone": "…"},
    "firstInbound": true
  }
}

Responses and retries

  • Answer with a 2xx status within 10 seconds. For slow work, acknowledge first and process later.
  • On an error or a timeout, Hubz retries after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours.
  • After 24 hours of continuous failures the webhook is paused. Events stay available through GET /v1/events for 7 days.
  • An event may arrive more than once: de-duplicate on X-Hubz-Delivery.

Verifying signatures

X-Hubz-Signature looks like t=<unix>,v1=<hex>, where v1 = HMAC-SHA256(secret, "<t>.<request body>"). Compute it over the raw request body (before parsing JSON), compare in constant time, and reject requests whose t is more than 5 minutes off.

Python

import hashlib
import hmac
import time


def verify_hubz_signature(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
    """header: the X-Hubz-Signature value; body: the raw request body, before any JSON parsing."""
    parts = dict(item.split("=", 1) for item in header.split(",") if "=" in item)
    timestamp, signature = parts.get("t"), parts.get("v1")
    if not timestamp or not signature or abs(time.time() - int(timestamp)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Node.js

import crypto from "node:crypto";

// header: the X-Hubz-Signature value; rawBody: the raw request body (Buffer), before JSON parsing.
export function verifyHubzSignature(secret, header, rawBody, tolerance = 300) {
  const parts = Object.fromEntries(header.split(",").map((item) => item.split("=")));
  const timestamp = Number(parts.t);
  if (!timestamp || !parts.v1 || Math.abs(Date.now() / 1000 - timestamp) > tolerance) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

PHP

<?php
// $header: the X-Hubz-Signature value; $body: file_get_contents('php://input'), before json_decode.
function hubz_verify_signature(string $secret, string $header, string $body, int $tolerance = 300): bool
{
    $parts = [];
    foreach (explode(',', $header) as $item) {
        [$name, $value] = array_pad(explode('=', $item, 2), 2, '');
        $parts[$name] = $value;
    }
    if (empty($parts['t']) || empty($parts['v1']) || abs(time() - (int) $parts['t']) > $tolerance) {
        return false;
    }
    $expected = hash_hmac('sha256', $parts['t'] . '.' . $body, $secret);
    return hash_equals($expected, $parts['v1']);
}

Hubz is not affiliated with Zalo or VNG; Hubz does not use Zalo’s official API. Hubz is not affiliated with Odoo S.A.; Odoo is a trademark of Odoo S.A.