Hubz API v1 (beta) · Hubz v0.7.0 · 2026-11-06
Webhooks: Zalo message events for your CRM
Events
| Event | When |
|---|---|
message.created | A 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.updated | A message changed, for example its sending status. |
conversation.updated | A 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.