← All posts
Tutorial

I Pointed Devin Desktop at the Webhook Docs and Had It Build a LINE Inbound Handler in 20 Minutes

Devin Desktop — the rebrand of Windsurf that shipped in early 2026 with the SWE-1.6 model pushing 950 tokens per second — is the AI coding tool most developers I know are reaching for right now. The pitch is simple: point it at a reference doc, describe what you want, and iterate until it runs. I wanted to see how far that workflow goes when the reference is a webhook API, not a framework guide.

The task: build a Python server that receives LINE messages as webhook events and logs them in a structured format — the kind of handler a small team would deploy behind a queue or a ticketing system. No LINE Official Account, no Messaging API credentials, no channel access token management. Just inbound messages arriving as normalized JSON.

What you end up with

A FastAPI server (about 40 lines) that:

  1. Accepts message.received events from UnifyPort’s unified webhook
  2. Verifies every delivery with HMAC-SHA256 against your signing_secret
  3. Logs each message in structured JSON — provider, sender, text, timestamp
  4. Returns 200 for all events, 401 for invalid signatures

Time: about 20 minutes. You need a UnifyPort workspace with a LINE account connected (QR code scan from the LINE app) and Python 3.10+.

Why not the LINE Messaging API directly?

The official path:

  1. Create a LINE Official Account — requires a LINE Business ID, which requires a company or verified individual identity
  2. Enable the Messaging API in the LINE Developers console — configure a channel, generate a channel access token, set a webhook URL
  3. Parse LINE’s webhook payload: events arrive as {"events": [{"type": "message", "message": {"type": "text", "text": "..."}, "source": {"userId": "U..."}}]} — a nested, LINE-specific structure
  4. Verify the signature using the channel secret with x-line-signature — LINE uses Base64-encoded HMAC-SHA256, not hex
  5. Handle reply tokens: each event includes a replyToken valid for 30 seconds — miss the window and the reply silently fails

The code works, but it is LINE-only. The webhook payload shape, the signature format, the reply-token mechanism — none of it transfers to WhatsApp or Telegram. Adding a second platform means a second integration from scratch.

UnifyPort’s unofficial interface connects a personal LINE account — QR scan, no Official Account required — and delivers every inbound message as a normalized message.received event. Same shape as WhatsApp, Telegram, TikTok, Zalo, and X. One handler covers all six.

Setup: feed Devin Desktop the API reference

Open Devin Desktop and start a new session. Before the first prompt, give it context:

  1. Paste the message.received event payload from the UnifyPort webhook docs:
    {
      "event": "message.received",
      "account_id": "acct_7kQnWx",
      "provider": "line",
      "from": "user_a3f82c",
      "text": "Can I reschedule to Friday?",
      "timestamp": 1751270400,
      "message_id": "line_msg_5e9d21"
    }
  2. Paste the HMAC-SHA256 verification section — the x-unifyport-signature header, the hex digest computed over the raw request body using the signing_secret

Devin indexes the pasted content as part of its session context. With SWE-1.6 Fast processing at 950 tok/s, the docs digest takes seconds. Cursor, Claude Code, and Copilot work the same way — paste or attach. The tool is interchangeable; the docs are what matters.

The build, prompt by prompt

First prompt — the handler skeleton:

Using the UnifyPort webhook reference I just pasted, write a FastAPI server with a POST /webhook route. When the event is “message.received”, log the provider, from, text, and timestamp as structured JSON using Python’s logging module. Return 200 for all events. Use uvicorn to serve it.

Devin reads the reference context and produces:

from fastapi import FastAPI, Request
import logging, json

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("inbound")

app = FastAPI()

@app.post("/webhook")
async def webhook(request: Request):
    evt = await request.json()
    if evt.get("event") == "message.received":
        logger.info(json.dumps({
            "provider": evt["provider"],
            "from": evt["from"],
            "text": evt["text"],
            "timestamp": evt["timestamp"],
            "message_id": evt["message_id"],
        }))
    return {"status": "ok"}

Twelve lines of handler. No LINE-specific imports, no linebot SDK, no channel access token. The event shape is flat enough that there is nothing to parse.

Second prompt — signature verification:

Add HMAC-SHA256 signature verification. The header is x-unifyport-signature, the secret comes from UNIFYPORT_SIGNING_SECRET in the environment, and the HMAC runs over the raw request body bytes — not re-serialized JSON. Return 401 if verification fails. Use a timing-safe comparison.

Devin adds the verification layer:

import hmac, hashlib, os

SIGNING_SECRET = os.environ["UNIFYPORT_SIGNING_SECRET"]

async def verify_signature(request: Request) -> bytes:
    body = await request.body()
    sig = request.headers.get("x-unifyport-signature", "")
    expected = hmac.new(
        SIGNING_SECRET.encode(), body, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(sig, expected):
        raise HTTPException(status_code=401, detail="invalid signature")
    return body

It uses hmac.compare_digest for timing-safe comparison and operates on request.body() — the raw bytes — rather than re-serializing request.json(). The prompt specified “raw request body bytes,” and Devin handled it correctly without a correction round.

Third prompt — combine and clean up:

Merge the signature verification into the webhook handler. Parse the verified body as JSON after the check passes. Add a health check at GET /health. Include a uvicorn.run block for port 8000.

The complete server.py:

from fastapi import FastAPI, Request, HTTPException
import hmac, hashlib, os, json, logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("inbound")

SIGNING_SECRET = os.environ["UNIFYPORT_SIGNING_SECRET"]

app = FastAPI()

@app.get("/health")
async def health():
    return {"status": "ok"}

@app.post("/webhook")
async def webhook(request: Request):
    body = await request.body()
    sig = request.headers.get("x-unifyport-signature", "")
    expected = hmac.new(
        SIGNING_SECRET.encode(), body, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(sig, expected):
        raise HTTPException(status_code=401, detail="invalid signature")

    evt = json.loads(body)
    if evt.get("event") == "message.received":
        logger.info(json.dumps({
            "provider": evt["provider"],
            "from": evt["from"],
            "text": evt["text"],
            "timestamp": evt["timestamp"],
            "message_id": evt["message_id"],
        }))
    return {"status": "ok"}

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

Under 40 lines. Signature-verified, structured logging, ready to deploy behind any reverse proxy.

Run it and watch a LINE message arrive

Connect a LINE account in your UnifyPort workspace — LINE supports QR code auth, so you open the LINE app, scan, and the account is linked in seconds. No LINE Official Account, no Business ID, no Messaging API channel setup.

Register a webhook endpoint pointed at your server with subscribed_events: ["message.received"] and a signing_secret. Start the server:

UNIFYPORT_SIGNING_SECRET=your_secret python server.py

Send a test message to your LINE account from a friend. The event arrives:

{
  "event": "message.received",
  "account_id": "acct_3Xk1wL",
  "provider": "line",
  "from": "user_a3f82c",
  "text": "Can I reschedule to Friday?",
  "timestamp": 1751270400,
  "message_id": "line_msg_5e9d21"
}

Your server verifies the signature and logs the structured JSON. If the signature check fails — 401 — verify that UNIFYPORT_SIGNING_SECRET matches the value set on the webhook endpoint.

Add WhatsApp without changing a line

Connect a WhatsApp account in the same workspace, subscribe the same webhook endpoint. A WhatsApp message arrives in the same shape:

{
  "event": "message.received",
  "account_id": "acct_7kQnWx",
  "provider": "whatsapp",
  "from": "user_d4f29a",
  "text": "Order confirmed, shipping Monday",
  "timestamp": 1751270460,
  "message_id": "wa_msg_8b3e71"
}

Same handler. Same structured log. The provider field switches from "line" to "whatsapp", but the code path is identical. A LINE Messaging API handler would need a completely separate WhatsApp integration — different webhook payload, different signature format, different SDK. This handler serves six platforms because the event shape does not change.

That is the workflow: paste the UnifyPort API reference into Devin Desktop, describe the handler, iterate until it runs. The tool writes the code; the normalized webhook makes the code work for every channel. Swap Devin for Cursor, Claude Code, or Copilot — the reference stays the same, and the handler stays under 40 lines.