Zalo QR Authorization: Fix qrcode_expired and Receive Signed Webhooks
If a Zalo QR authorization flow returns qrcode_expired, do not reuse the old QR material. Start a fresh POST /v1/accounts/{account_id}/auth/qr/start, keep polling POST /v1/accounts/{account_id}/auth/qr/check, and make sure your webhook endpoint already exists so auth and message events have a destination. Once the account is authorized, Zalo inbound messages arrive as signed message.received events.
Key takeaways
- In UnifyPort, Zalo authorization is QR-only: create a Zalo messaging account with
auth_mode: "qrcode"and no provider credentials up front. - Register the webhook first. Authentication updates and later inbound messages are delivered through the event stream.
- Treat
qrcode_expiredas a normal retry state: callqr/startagain, render the new QR material, and continue polling. - Verify
X-Device-SignatureoverX-Device-Timestamp + "." + raw bodybefore parsing JSON. - Use the official Zalo OA webhook only when your product must run on a Zalo Official Account; this tutorial covers the UnifyPort unofficial interface for an existing messaging account.
If you are still deciding between account models, read Zalo Official Account API vs personal-account webhook first. For the wider Southeast Asia support pattern, keep one webhook for LINE, Zalo, and X open beside this checklist.
What this flow is—and is not
Zalo’s own developer documentation has an Official Account API and an OA webhook model for Zalo Official Accounts. That is the right route when the OA identity, OA Manager operations, or official platform relationship is the requirement.
The UnifyPort flow is different. You connect a Zalo messaging account by QR authorization, then receive normalized events through UnifyPort’s webhook delivery layer. The product fact to anchor on is simple: the Zalo authorization guide documents QR login only, and notes that a webhook endpoint is required so authentication and message events can be delivered.
Step 1: create the signed webhook before scanning
Create a stable HTTPS receiver first. Subscribe to the auth events you want to display in your setup UI plus message.received for the actual inbox stream.
curl -X POST https://api.unifyport.ai/v1/webhook-endpoints \
-H "X-Api-Key: $UNIFYPORT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://inbox.example.com/unifyport/zalo",
"status": "active",
"subscribed_events": ["account.auth.succeeded", "account.auth.required", "message.received"],
"signing_secret": "zalo-support-2026"
}'
The deep reference is Create webhook endpoint: url must be absolute, status is active or inactive, and subscribed_events can be exact public event names or ["*"]. For a first Zalo setup, explicit event names make debugging easier.
Step 2: create the Zalo account
Create one account record for the Zalo login. Keep provider and auth_mode explicit so your onboarding code does not accidentally share assumptions from WhatsApp, Telegram, or TikTok flows.
curl -X POST https://api.unifyport.ai/v1/accounts \
-H "X-Api-Key: $UNIFYPORT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Zalo Support Inbox",
"provider": "zalo",
"region": "global",
"status": "active",
"auth_mode": "qrcode",
"capabilities": ["receive_message"],
"provider_data": {},
"metadata": { "workflow": "support-intake" }
}'
Store the returned account_id. The QR endpoints below use it; do not paste production account IDs into public issue trackers or AI prompts.
Step 3: start QR authorization and handle qrcode_expired
Start the QR flow with an empty JSON body:
curl -X POST "https://api.unifyport.ai/v1/accounts/$ACCOUNT_ID/auth/qr/start" \
-H "X-Api-Key: $UNIFYPORT_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'
Then poll the QR check endpoint until the flow succeeds, fails, or returns fresh QR material:
curl -X POST "https://api.unifyport.ai/v1/accounts/$ACCOUNT_ID/auth/qr/check" \
-H "X-Api-Key: $UNIFYPORT_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'
When the response or webhook state says qrcode_expired, discard the old QR display and call qr/start again. Do not ask the user to keep scanning an expired screen. A good UI shows three states: “waiting for scan,” “expired—generate a new QR,” and “authorized.”
Step 4: verify the delivery signature
UnifyPort’s webhook delivery reference defines the signed string as:
<X-Device-Timestamp>.<raw request body>
A minimal Express receiver should keep the raw bytes intact:
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const signingSecret = process.env.WEBHOOK_SIGNING_SECRET;
app.post('/unifyport/zalo', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('X-Device-Timestamp') ?? '';
const signature = req.get('X-Device-Signature') ?? '';
const expected = crypto
.createHmac('sha256', signingSecret)
.update(timestamp + '.')
.update(req.body)
.digest('hex');
if (signature.length !== expected.length) return res.sendStatus(401);
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
if (event.type === 'message.received' && event.provider === 'zalo') {
// Store event.id, event.account_id, event.occurred_at, and event.data before routing.
}
res.sendStatus(202);
});
If your framework parses JSON before this code runs, the signature check can fail because the bytes changed. Use a raw-body route for the UnifyPort endpoint.
Step 5: store the normalized Zalo event
A text message uses the same standard envelope as the other providers:
{
"id": "evt_2f9c1a4b7e",
"type": "message.received",
"provider": "zalo",
"account_id": "acc_8c21d0",
"occurred_at": "2026-06-08T12:34:56Z",
"data": {
"conversation": { "id": "5005", "type": "user" },
"sender": { "id": "4004", "type": "user", "name": "Minh Nguyen" },
"message": {
"id": "3003",
"text": "Sản phẩm này còn hàng không?",
"direction": "inbound",
"sent_at": "2026-06-08T12:34:55Z"
}
}
}
Store first, route second. A 2xx response acknowledges delivery; Slack notifications, CRM updates, AI classification, and human assignment can run after the event is durable. If you need the receiver pattern in more detail, the webhook-first inbound integration checklist covers the same store-before-route rule.
Limitations and trade-offs
The account owner must scan the QR code, and the session can later require a new authorization step. Surface account.auth.required in your admin UI rather than silently dropping messages. Provider availability can also vary by account or region, so keep an operational fallback.
Choose the official Zalo OA API when OA identity, OA-native features, or official support is required. Choose the UnifyPort unofficial interface when your job is to receive messages sent to an existing Zalo account and route them into your own system.
FAQ
What should I do when Zalo returns qrcode_expired?
Call POST /v1/accounts/{account_id}/auth/qr/start again, render the new QR material, and continue polling auth/qr/check. Do not reuse the expired QR.
Do I need Zalo developer credentials for this UnifyPort flow?
No provider credentials are needed up front for the documented Zalo QR flow. The account identity is discovered after the intended user scans the QR code.
Should I subscribe to ["*"] or specific events?
Use specific events for the first test: account.auth.succeeded, account.auth.required, and message.received. Move to ["*"] only when your receiver handles the wider event catalog.
Is this the same as the official Zalo OA webhook?
No. Zalo’s official webhook belongs to the Official Account developer model. This tutorial describes UnifyPort’s unofficial interface for receiving normalized events from a connected messaging account.
Next step
Open the Zalo authorization guide and the webhook delivery reference side by side, then connect a test account and send one inbound Zalo message before wiring downstream automations.
Sources
Official sources checked on 2026-09-04:
Turn messaging integration into a stable product pipeline.
Start by sending through one API, then bring every inbound message back into your business system with standard events.