← All posts
Tutorial

I Had Claude Code Build a Zalo Webhook Receiver by Pointing It at the API Docs — UnifyPort

Zalo has no public DM API. If you’re building for the Vietnamese market — or for any cross-border team that talks to Vietnamese customers — that’s the first wall you hit. There’s the Official Account route, but that’s a broadcast apparatus: verification, templates, interaction windows, per-message billing. If all you need is to receive the messages customers send you, the OA path solves a bigger problem than you have.

So here’s what I did instead. I opened Claude Code, pasted the UnifyPort webhook reference into context, and asked it to build a Zalo webhook receiver in Python. Fifty minutes later I had a Flask server that verifies every delivery signature, logs inbound Zalo messages to a structured JSON file, and forwards them to a Slack channel. The whole thing is about 60 lines.

The demo you’ll end up with

A Python Flask server that:

  1. Accepts message.received events from UnifyPort’s webhook
  2. Verifies each delivery with HMAC-SHA256 against your signing_secret
  3. Logs every Zalo message to a structured JSON file
  4. Posts a summary to a Slack channel via an incoming webhook

Time investment: under an hour, most of it Claude Code’s. You’ll need a UnifyPort workspace with a Zalo account connected (QR code scan — no credentials, no business verification) and a Slack incoming webhook URL.

Setup: get the docs into Claude Code’s context

Before prompting, get the API surface into the agent’s context. Claude Code can read files directly, so the quickest path is:

  1. Copy the webhook event reference — specifically the message.received event shape, the signature verification section, and the POST /v1/webhook-endpoints creation call — into a file called unifyport-reference.md in your project root.
  2. Or paste the relevant sections directly into the chat when you start prompting.

The key sections Claude Code needs to see: the message.received event payload, the x-unifyport-signature header, and how HMAC-SHA256 verification works with the signing_secret. Without these, the agent will guess at field names — and it’ll guess wrong.

Alternative tools work the same way. In Cursor, use @Docs to point at the reference. In Windsurf, use the docs panel. The mechanic is identical: real field names in, correct code out.

The build, prompt by prompt

First prompt — the skeleton:

Read unifyport-reference.md. Write a Flask server in app.py with a POST /webhook route. When the event field is “message.received”, log the provider, from, and text fields to stdout. Return 200 for all events.

Claude Code reads the reference file and produces:

from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route("/webhook", methods=["POST"])
def webhook():
    evt = request.get_json()
    if evt.get("event") == "message.received":
        print(f"[{evt['provider']}] {evt['from']}: {evt['text']}")
    return "", 200

if __name__ == "__main__":
    app.run(port=3000)

Seven lines of handler. Because the event shape is normalized, there’s one branch on one field — no provider-specific parsing.

Second prompt — signature verification:

Each webhook delivery includes an x-unifyport-signature header. Verify it using HMAC-SHA256 with the signing_secret from the environment. The HMAC must run over the raw request body, not re-serialized JSON. Reject with 401 if verification fails. Use a timing-safe comparison.

This is the prompt that separates a toy from something you’d run in production. Claude Code wires up the verification correctly because it read the reference:

import hmac
import hashlib
import os

SIGNING_SECRET = os.environ["UNIFYPORT_SIGNING_SECRET"]

def verify_signature(req):
    signature = req.headers.get("x-unifyport-signature", "")
    expected = hmac.new(
        SIGNING_SECRET.encode(),
        req.get_data(),
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, expected)

req.get_data() returns the raw bytes before Flask parses the JSON — that’s the detail that matters. If the agent had used request.get_json() and re-serialized it, the HMAC would never match. Prompted with “raw request body,” it got this right on the first try.

Third prompt — structured logging and Slack forwarding:

Add two things: (1) append each message.received event to a file called messages.jsonl, one JSON object per line, with timestamp, provider, from, and text. (2) Post a one-line summary to a Slack incoming webhook URL from the SLACK_WEBHOOK_URL environment variable.

Claude Code adds both in one pass. Here’s the complete app.py:

import hmac
import hashlib
import json
import os
import requests
from flask import Flask, request

app = Flask(__name__)
SIGNING_SECRET = os.environ["UNIFYPORT_SIGNING_SECRET"]
SLACK_WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL", "")

def verify_signature(req):
    signature = req.headers.get("x-unifyport-signature", "")
    expected = hmac.new(
        SIGNING_SECRET.encode(),
        req.get_data(),
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, expected)

@app.route("/webhook", methods=["POST"])
def webhook():
    if not verify_signature(request):
        return "", 401

    evt = request.get_json()
    if evt.get("event") == "message.received":
        record = {
            "timestamp": evt["timestamp"],
            "provider": evt["provider"],
            "from": evt["from"],
            "text": evt.get("text", ""),
            "message_id": evt["message_id"],
        }
        with open("messages.jsonl", "a") as f:
            f.write(json.dumps(record) + "\n")

        if SLACK_WEBHOOK_URL:
            requests.post(SLACK_WEBHOOK_URL, json={
                "text": f"[{evt['provider']}] {evt['from']}: {evt.get('text', '')}"
            })

    return "", 200

if __name__ == "__main__":
    app.run(port=3000)

Sixty lines, signature-verified, with structured logging and Slack forwarding. The entire handler is platform-agnostic — evt["provider"] tells you it’s Zalo, but the code never branches on it.

Run it and watch a Zalo message arrive

Connect a Zalo account in your UnifyPort workspace — Zalo uses QR code auth, so you scan from the Zalo app and you’re connected in seconds. No business verification, no OA registration, no credentials to manage. Register a webhook endpoint pointed at your server with subscribed_events: ["message.received"] and a signing_secret.

Start the server, then send a message from a Zalo contact. The event lands looking like this:

{
  "event": "message.received",
  "account_id": "acct_4Xn9kL",
  "provider": "zalo",
  "from": "user_7b2e4f",
  "text": "Sản phẩm này còn hàng không ạ?",
  "timestamp": 1750262400,
  "message_id": "zalo_msg_9d3a1c"
}

Your server verifies the signature, appends the record to messages.jsonl, and posts the summary to Slack. If the signature check fails, you get a 401 — check that UNIFYPORT_SIGNING_SECRET matches what you set on the webhook endpoint.

Extend it: add WhatsApp with zero new handler code

Here’s where the normalized webhook earns its keep. Connect a WhatsApp account in the same workspace, subscribe the same webhook endpoint, and the exact same /webhook route handles both platforms. A WhatsApp message arrives in the same message.received shape — different provider field, identical structure:

{
  "event": "message.received",
  "account_id": "acct_2Rm7wQ",
  "provider": "whatsapp",
  "from": "user_5c8d3a",
  "text": "Do you ship to Thailand?",
  "timestamp": 1750262460,
  "message_id": "wa_msg_6f1b2e"
}

No new handler. No conditional import. No WhatsApp-specific parsing. The messages.jsonl file now contains both Zalo and WhatsApp messages in the same schema, and the Slack channel shows both streams. Adding LINE or Telegram later is the same story — connect the account, and the handler you already built processes everything.

The takeaway isn’t that Claude Code wrote your server for you. It’s that an AI coding agent finishes cleanly when the API surface is simple enough to fit in its context. One event shape, one signature scheme, one send endpoint — that’s the kind of target a prompted agent doesn’t fumble. Point your tool at the UnifyPort API reference, give it the message.received schema, and you’ll have a verified, logging, forwarding webhook receiver before lunch — then add every other channel without touching the handler.