# Paiduay — the LINE channel (Login via Clerk, OA push via n8n, the seller's button)

2026-09-17 · decision-grade · base = spec §9.58 (Wave 1 as shipped) · reads with `docs/paiduay/plans/notifications.md`, `apps/paiduay/docs/CLERK-SETUP.md`, `docs/paiduay/plans/launch-runbook.md`

**One line.** "LINE" names three unrelated products here: a **sign-in button** (LINE Login, a Clerk social connection), a **way for us to buzz a seller** (an Official Account's Messaging API push), and **the seller's own chat as the card's one big button** (shipped — and broken for two common inputs). Turn on the sign-in button (dashboard only, zero code), buzz sellers by hand until it hurts, and fix the button before the tweet.

## 1. The three LINE things

| | LINE Login | LINE OA / Messaging API push | The seller's own LINE (the card's button) |
|---|---|---|---|
| Answers | who is signing in | how *we* reach her | how a *customer* reaches her |
| Whose account, what we own | hers; a **LINE Login** channel | **ours** (`@paiduay`); a **Messaging API** channel | hers; nothing |
| Cost | free | free to 300 push/mo, then paid | ฿0 |
| State today | off in practice — Clerk's shared dev credentials are on, so the consent screen says "Clerk": unusable in public | not built | **shipped** (`components/card/links.ts`) |
| Work to make it real | ~40 min of dashboard, **no code** | ~7 h (§4) | ~1 h (WO‑L0) |

Both channels we own must sit under **one provider**: a `userId` is issued per provider, and LINE states that a Login channel and a Messaging API channel under the same provider return the same userId for the same person ([FAQ](https://developers.line.biz/en/faq/#what-are-userid-groupid-and-roomid)). Get it wrong and "sign in with LINE" can never pre-fill "push to my LINE" — and it is **not fixable later**: the provider assigned to an Official Account can never be changed or de-assigned ([getting started](https://developers.line.biz/en/docs/messaging-api/getting-started/)). Create the provider first, both channels inside it.

### 1.1 LINE Login — the sign-in button

Steps, all in a browser (~40 min, no code):

1. developers.line.biz → **create the provider** ไปด้วย (§5).
2. Inside it → **new channel → LINE Login**, app type Web, icon = the wordmark.
3. **LINE Login tab → Callback URL:** Clerk's `https://clerk.<domain>/v1/oauth_callback` (Clerk prints the exact value; it is bound to the production instance, hence the domain — runbook Day‑0 items 1 and 2 first).
4. **Publish.** A Developing channel admits only its listed testers; publishing needs **no review** and cannot be undone ([step 6](https://developers.line.biz/en/docs/line-login/getting-started/#step-6-publish-your-channel-optional)).
5. Clerk → *Social connections → LINE* → **Use custom credentials** → Channel ID + secret.
6. **Email:** LINE returns an address only with the `email` scope — *Basic settings → OpenID Connect → Apply*, with a screenshot of the screen that tells the user why ([applying](https://developers.line.biz/en/docs/line-login/integrate-line-login/#applying-for-email-permission)); no reviewer or SLA is published. Until granted, leave Clerk's "require email for social" OFF — a LINE-only seller then has no email and LINE is her only way back in. Phone is never available (Profile+ is partner-only).

**What she sees.** `/sign-up` prints the LINE button first; Clerk's prebuilt `<SignUp>` renders it and the v3 appearance already sizes it, so nothing to write. Tap → LINE's consent screen titled ไปด้วย → `/me`. No password, no code in an inbox she may not have on that phone. The DM playbook already promises it: *"สมัครด้วยอีเมลหรือ LINE"*.

**Email first, LINE later — account linking.** An email seller has a Clerk user and a card bound to that `userId` (`Card.ownerUserId`; the party comes from `upsertPartyByContact(ctx, "clerk", userId)` in `lib/cards/repo.ts`). If she later taps LINE, Clerk links it to the **existing** user only when a verified email matches; with no email from LINE (the normal case) Clerk creates a **second** user with a new `userId`, and `/me` looks empty because `listCards({ownerUserId})` is keyed on the first. So `/me` offers "เพิ่ม LINE เป็นวิธีเข้า" as a connect action from *inside* her session (Clerk user profile → connected accounts), never as a second sign-in door; the repair path stays the runbook's `/admin/card` re-bind.

### 1.2 The OA push — us buzzing a seller

Mechanics, and why they bite:

- **Friend-first.** A push reaches a user only by `userId`, learned only from a webhook event she caused — simplest, the `follow` event when she adds `@paiduay`. No lookup by LINE ID, phone or email exists; the docs say "do not use the LINE ID found on LINE". Push is allowed to friends, or to someone who messaged the OA **within 7 days** ([push message](https://developers.line.biz/en/reference/messaging-api/#send-push-message)). She must add our OA; that is the whole cost of this channel.
- **A blocked push looks like success:** sending to a blocker or non-friend returns **200 and silently does not deliver** (and is not counted), so we can never detect an unlink — which decides §3's copy.
- **New friction, since 1 April 2026.** LINE Thailand retired the grey "unverified" shield: an unverified OA now carries **no badge**, and LINE shows an anti-fraud interstitial **before Add Friend** ([announcement](https://lineforbusiness.com/th/news/20260227_1)). We would be asking a scam-wary seller to walk past a scam warning. The blue badge is **฿888 once, ex‑VAT**; an unregistered individual *may* apply, under a person's name, answer in ~7 business days, discretionary ([verified account](https://lineforbusiness.com/th/service/line-oa-features/verified-account)).
- **Money.** Free plan **300 push/month, hard-capped** in Thailand — no overage purchasable, 429 past it; cut from 500 on 2024‑08‑01. Basic ฿1,280/15,000 (+฿0.10); Pro ฿1,780/35,000 (+฿0.06), ex‑VAT ([TH plans](https://lineforbusiness.com/th/service/line-oa-features/broadcast-message)). Counted **per recipient**, not per send; no per-user cap exists. **Replies are free and unmetered** ([pricing](https://developers.line.biz/en/docs/messaging-api/pricing/)) — an OA that only answers costs ฿0; only push consumes quota.
- **LINE Notify is dead** (ended 2025‑03‑31, [closing notice](https://notify-bot.line.me/closing-announce)). Do not build on it.
- **The link step, from `/me`.** She taps "เชื่อม LINE"; we show the OA's QR, `line.me/R/ti/p/%40paiduay`, and a **6-character one-time code**; she adds the OA and sends the code. LINE's webhook (`follow` carries `source.userId` and **no** display name — a name needs a separate `GET /v2/bot/profile/{userId}` call) hits **n8n**, which POSTs `{code, userId}` to `/api/line/link`; we resolve the code to her owner party and store the `userId`. No OAuth, no LINE SDK, no new inbound endpoint LINE must reach in our app.
- **n8n vs. code in the app.** n8n wins both halves: a push is one HTTP node (`POST api.line.me/v2/bot/message/push`, bearer = the channel access token), so that token lives in n8n credentials, not in our `.env` or image. What belongs in the app is only the binding and its consent record — the part that must be auditable and deletable. n8n = transport, app = identity.

### 1.3 The card's button — shipped, and two real bugs

`linkHref()` in `apps/paiduay/components/card/links.ts` maps a `line` link to `https://line.me/ti/p/~<id>` after stripping a leading `@` or `~`. The editor's hint invites exactly the inputs that break it (*"ไอดี หรือลิงก์ line.me ก็ได้"*, `content/copy-editor.ts`):

| She types | We emit | Result |
|---|---|---|
| `mint.bkk` (a personal LINE ID) | `line.me/ti/p/~mint.bkk` | works in practice, but **LINE documents no `~id` form anywhere** — it is community lore, and it dies silently if she never set a LINE ID or has "allow others to add me by ID" off |
| `@mintcafe` (an **OA** id) | `line.me/ti/p/~mintcafe` | **broken** — an OA needs `line.me/R/ti/p/%40mintcafe` (a bare `@` still works but is [deprecated](https://developers.line.biz/en/docs/messaging-api/using-line-url-scheme/#sharing-line-official-account)) |
| `lin.ee/aBcD` (the OA Manager short link, pasted without a scheme) | `line.me/ti/p/~lin.ee/aBcD` | **broken** |
| a full `https://line.me/…` or `https://lin.ee/…` | passed through | correct |
| a phone number (kind `phone`) | `tel:0812345678` | works everywhere, webviews included — but it prints her number on a public page and invites calls at 02:00. Offer it, never default to it |

Both broken rows are a dead booking button on a live card — the most expensive bug in the product — and the first row rests on an undocumented URL shape. The robust input is the one **LINE itself generates**: Home → QR code → My QR code → **Copy link**, an opaque `line.me/ti/p/<token>` needing no ID and no privacy setting. Ask for that first, accept an id as fallback (WO‑L0). `line://` is deprecated; the documented schemes are iOS/Android only, never LINE for PC.

**In-app browsers — the sharpest unknown in the product.** LINE publishes nothing here; it documents only the reverse (`?openExternalBrowser=1`, escaping *LINE's own* browser). The vendor consensus for deep links generally is that embedded webviews (X, Threads, TikTok, Instagram) **suppress the OS universal-link handoff**, so an `https://line.me/…` tap can land on a web page instead of opening LINE — usual mitigations an "open in browser" line or a QR. **Unverified for LINE specifically; no test report exists and it is not knowable from documentation.** It is the product's one revenue click, so it is a real-device test on Day 0 (runbook item 7), not an assumption.

## 2. Which first, for first revenue

Neither of the two we would *build* is on the critical path this week. Ordered by baht per hour:

**0 · Nothing new — buzz her by hand.** The runbook's operator loop already does this ("One LINE line to her if she left an id", §4) and it is the honest first version of seller push: after approving a ฿499 slip, James opens her card, taps her own LINE button, types one line. ~1 min per approval, and a *better* trust-at-the-money-moment than a bot — a human confirming the money. Build the OA when this hurts: above ~5 approvals a week.

**1 · LINE Login (~40 min, dashboard only).** It sits on the money path: the funnel is tweet → `/c/new` → sign-up → publish → `/pro`, and *sign-up is the only step asking her for something she does not already have*. Email + password + a code, on a phone, from a stranger's link, is the biggest drop in that chain; LINE is already open. It costs no extra day (gated on exactly what Day 0 does anyway — domain, Clerk production) and **no code**.

**2 · The OA push (~7 h, §4) — when a seller asks, or above ~5 approvals/week.** Two findings invert the working default. First, **as shipped there is almost nothing to push to a seller**: a card sends the customer to *her own* LINE, so no request event exists for a card seller at all; the meetup `request.*` events belong to the legacy provider pages (`/p/[slug]`, `/portal`), and every card-era event — `card.published`, `pro.requested`, `pro.approved`, `report.created` — carries the default audience `["operator"]`, i.e. James. The one seller-facing push worth having is `pro.approved` (plus "your card was hidden"), precisely the one a human does better at five sellers. (Runbook §4 says "she gets the `pro.approved` buzz path"; she does not. Correct that line.) Second, the ask itself now costs trust: an unverified OA shows no badge and LINE warns about fraud before Add Friend (§1.2), so "add our account" is a scam-shaped request to a scam-wary audience unless ฿888 buys the badge first.

So: **Login first, a hand-typed LINE message as the push, the OA third.**

## 3. PDPA consent lines (TH first, EN on its own line)

PDPA B.E. 2562 needs a stated purpose, a named controller and a withdrawal route. Store the text's **version** with the binding, not a boolean.

**At the link step (`/me`, above the button):**

```
ไปด้วยจะใช้ LINE นี้เพื่อส่งข้อความเกี่ยวกับการ์ดและการชำระของคุณเท่านั้น ไม่ส่งโฆษณา ไม่ส่งต่อให้ใคร
ยกเลิกได้ทุกเมื่อ — กด "ยกเลิกการเชื่อม" ที่หน้านี้ หรือบล็อก LINE ของไปด้วย
ผู้ดูแลข้อมูล: <ชื่อ James> · <อีเมลซัพพอร์ต>
```
```
Paiduay uses this LINE only to message you about your card and your payment — no marketing, never shared.
Unlink any time: the "Unlink" button on this page, or block Paiduay on LINE.
Data controller: <James's name> · <support email>
```

**On the button itself:** `เชื่อม LINE เพื่อรับแจ้งเตือน` / `Link LINE for notifications`.

**The first message the OA sends after the code matches** (the §25 notice, in the channel itself):

```
เชื่อม LINE เรียบร้อยแล้ว จากนี้ไปด้วยจะส่งเฉพาะเรื่องการ์ดและการชำระของคุณ
ไม่ต้องการแล้วพิมพ์ "ยกเลิก" หรือบล็อกบัญชีนี้ก็ได้
```
```
Your LINE is linked. From now Paiduay messages you only about your card and your payment.
Don't want it? Send "ยกเลิก" or block this account.
```

**Failure copy.** LINE returns 200 even when she has blocked the OA (§1.2), so we can never honestly say "delivery failed" — and must not invent it. `/me` says only what is true:

```
ถ้าคุณบล็อกไปด้วยใน LINE ข้อความจะไม่ถึง และเราจะไม่รู้ — เชื่อมใหม่ได้ที่นี่ทุกเมื่อ
```
```
If you block Paiduay on LINE the messages stop and we cannot tell. Re-link here any time.
```

## 4. Work orders (Opus)

**WO‑L0 — the button (1 h, before the tweet).** `components/card/links.ts`: a value starting `@` → `https://line.me/R/ti/p/%40<id>`; a bare `lin.ee/…` or `line.me/…` → prefix `https://`; `~`/bare ids stay on `line.me/ti/p/~<id>`. `content/copy-editor.ts` (append-only) asks for her **Copy link** first — "LINE → QR code → ลิงก์ของฉัน → คัดลอก" — with the id as fallback. Tests: the four rows of §1.3 in the existing `links` unit test. Live: `/c/mint` from the X in-app browser, iPhone and Android — and if the handoff fails there, add an "เปิดในเบราว์เซอร์" line and a QR under the button before the tweet.

**WO‑L1 — let the audience out of the box (1 h).** Found live: `enqueueOutbox` stores `audience`, but `deliverOutbox` POSTs `payload` **verbatim** and sends only `x-outbox-id`, `-event`, `-attempt`, `-signature` (`packages/core/src/outbox/outbox.ts` ≈ L219–227) — so an n8n Switch on audience is impossible today. Add one header, `x-outbox-audience: <comma-joined>`: the kernel already owns that column, so it stays vertical-blind (rule 1); do **not** put it in the payload, which the module owns. Then set `audience: ["owner","operator"]` on `pro.approved` and the hide notice (`lib/pro/requests.ts`, `lib/cards/repo.ts`). The shipped vocabulary is `provider`, not `companion`, so `owner` is a third value, not a rename. Tests extend `outbox.test.ts` + `outbox.db.test.ts`.

**WO‑L2 — the binding (3 h).** *Where it lives:* the **owner party** — not the card (one owner, many cards), not a new table. `upsertPartyByContact(ctx, "line", userId, …)` is **wrong**: it finds-or-creates, minting a *second* party for a human who already has the `clerk` one that `record_parties.owner` points at. The kernel cannot add a contact to an existing party, so add `setPartyContacts(ctx, partyId, contacts)` to `packages/core/src/data/parties.ts`, mirroring `setPartyTags` (audit `party.contacts_updated`, no bus event) — one function covers link *and* unlink, and `findPartyByContact`'s jsonb containment keeps a party with both contacts findable by either. The pending code is a `line_link` record in `kernel.records` (zero DDL, mirrors §9.58b): `data = {codeHash, ownerUserId, consentVersion}`, `pending|linked|expired`, 30 min, single use; consent + `linkedAt` in the party's `custom.lineConsent`. Plus `lib/line/link.ts`, `app/api/line/link/route.ts` (HMAC body with `PAIDUAY_LINE_LINK_SECRET`, else 401). Tests `line-link.db.test.ts`: code matches once, reuse refused, expiry, unlink removes only the `line` contact, a second LINE identity refused.

**WO‑L3 — the `/me` block + unlink (2 h).** `app/me/page.tsx`, `app/me/actions.ts`, `components/owner/line-link.tsx`, `app/me/copy.ts` (append-only). Unlinked → §3 consent text, OA QR, `line.me/R/ti/p/%40paiduay`, the code; linked → "เชื่อมแล้ว" + ยกเลิกการเชื่อม + §3's blocked line (no "failed" state exists to render — 200 means nothing). Live: two phones, th/en, screenshots to `apps/paiduay/docs/screenshots/line/`.

**WO‑L4 — n8n (James, ~30 min, no repo change).** Inbound: LINE webhook → filter `follow`/`message` → POST `/api/line/link`. Outbound: the outbox webhook → Switch on `$json.headers['x-outbox-audience']` → contains `owner` **and** an owner `lineUserId` in the payload → POST `api.line.me/v2/bot/message/push` (token in n8n credentials, never our `.env`) → else today's path. Dedupe on `x-outbox-id`. Live: approve a test Pro request → the push lands on a second phone. Note for the doc: break the token and the outbox row still reads `sent` — delivery to n8n succeeded, and the LINE failure is visible only in n8n. Do not fake a round trip.

**WO‑L5 — docs (1 h).** Spec §9; tracker row; `03_ENTRYPOINT.html` if a screen changes; correct runbook §4's `pro.approved` line; the published-channel steps into `CLERK-SETUP.md`.

## 5. What only James decides

1. **The provider, and it is permanent.** One provider named ไปด้วย, created before either channel — a provider can never be de-assigned from an OA, and its name is what both consent screens print. Not a personal account name.
2. **The OA.** A new `@paiduay`, or the Messaging API channel his n8n already uses? A new one keeps the seller-facing identity clean and his personal notify channel out of sellers' chats — recommended, and free for an individual with no company.
3. **฿888 for the blue badge?** Unverified now means no badge plus a fraud warning before Add Friend (§1.2); an unregistered individual may apply, under a person's name, answer in ~7 business days, approval discretionary. Buy it with the OA or skip the OA.
4. **Budget line.** ฿0 to 300 push/month — hard-capped, no overage — then ฿1,280/mo Basic. Say now whether that line exists; at the cap the alternative is to stop pushing, not to pay quietly.
5. **Publishing a LINE Login channel is irreversible** — a name in LINE's directory under his own identity until a company exists.
6. **Emailless sellers.** Accept LINE-only accounts (LINE is then the only way back in), or apply for the email permission first and delay the button?
7. **The controller name** in §3 — the same name as `/policy` and the PromptPay receiver.
