← All case studies

Contactless Guest Check-in Mini App

Project Director · cross-party delivery governance · 2026

  • LINE LIFF
  • BFF
  • AWS CDK
  • S3 KMS
  • React

The channel that closed at checkout

An operator runs unmanned accommodation across Japan, vacation rentals and small hotels with no front desk and nobody on site. A guest checked in through a lobby tablet or a one-time web form, and the moment that form was submitted the connection was gone. If the same guest needed the door PIN at eleven at night, or the Wi-Fi password on the second morning, they had to dig out the original link and walk the entire check-in again from the top. The operator had the opposite problem: after checkout there was no way back to the guest, no channel to message them and nowhere to keep stay information where it would stay put.

The fix had to live where the guests already were. In this market that is one messaging app, so the product was built as a LINE Mini App rather than another web page nobody keeps open. Two constraints shaped everything underneath it. Guest data included door codes and Wi-Fi credentials, so PII handling could not be an afterthought, and the operator wanted the same product to reach international guests on other platforms later, which meant the messaging layer and the business logic had to come apart cleanly.

Identity you get for free

The strategic move is small and easy to miss. When the Mini App boots it initializes the LIFF SDK, and if the guest is signed into LINE it hands back a stable user id with no login screen at all. That id is the whole reason a one-time check-in turns into a relationship, because the operator now holds a durable handle to reach the same guest for the rest of the stay and after it.

Fig. 1 · Identity in, reservation out
01 · LINE app guest taps the Mini App in the chat 02 · LIFF SDK liff.init then getProfile: a stable id 03 · React + TanStack Query booking code entered, lookup fired 04 · /api/v1/reservations/lookup axios GET, the code as the only param 05 · Go + Gin BFF validate code, rate-limit on the LINE id 06 · Reservation returned upstream stay API, cached a few minutes

The frontend is React 19 on TanStack Router and Query, with the booking-code form checked by Zod before anything leaves the phone. Every reservation call carries the booking code to a single backend, and the types on both ends are generated from that backend’s OpenAPI schema, so a field renamed in Go breaks the TypeScript build instead of a guest’s screen.

That same LINE id earns a second job on the way in. The rate limiter keys on it when it is present and falls back to the client IP when it is not, with the two namespaced under line: and ip: prefixes so a shared office address can never spend a real guest’s budget. It runs a sliding window, twenty requests a minute, held in memory for now behind an interface a Redis version can slot into later without touching the callers.

A thin server in front of a thick one

Between the phone and the operator’s core sits a Go and Gin backend-for-frontend, and its job is translation. The core is an older machine-to-machine API that speaks in its own shapes and its own failure modes. The BFF folds several of those calls into the one clean object a screen actually needs: it authorizes the booking code, pulls each reservation’s detail, and fetches the listing separately for the address, then composes check-in and check-out into ISO timestamps in JST before the phone ever sees them.

The front door is the booking code, the only credential a guest types, so it gets cleaned hard before it travels anywhere.

reservation.go Go
var bookingCodeRegex = regexp.MustCompile("^[A-Za-z0-9]{1,30}$")

// The booking code is the only credential a guest types, so it is
// normalized before it ever touches the upstream API: trim stray
// whitespace, reject anything that is not 1-30 alphanumerics, then
// uppercase so "abc123" and "ABC123" resolve to the same stay. A bad
// code returns a typed error, never a bare 500, so the UI can show the
// exact message (the strings ship localized for the Japanese market).
func validateBookingCode(raw string) (string, *model.AppError) {
code := strings.TrimSpace(raw)
if code == "" {
	return "", model.InvalidBookingCode("予約コードを入力してください")
}
if !bookingCodeRegex.MatchString(code) {
	return "", model.InvalidBookingCode("予約コードは英数字1~30文字で入力してください")
}
return strings.ToUpper(code), nil
}

One code can unlock more than one room. A family on a single booking comes back as several reservations, and the response flags that so the UI offers a picker instead of guessing. Behind each reservation, the detail screens read from six key-management types the operator’s core supports (RemoteLock, Keybox, Keycafe, TTLock, a fixed front-desk key, and an unknown fallback), all normalized into one key-info shape so the guest just sees a PIN or a box number, not the brand of lock on the door. When Keycafe reports that identity verification is not finished, the BFF surfaces that as its own UI state rather than an error, because the guest is not broken, they have one more step to do.

Caching had to respect the credential. Reservation detail is cached for a few minutes on the reservation id, but that id on its own would let one code read data another code paid for, so every cache hit compares a SHA-256 hash of the presented booking code against the hash that filled the entry. A mismatch skips the cache and refetches. The speed is shared, the authorization is not.

The seams that let it move

Two seams were cut on purpose so the product could travel. The identity provider sits behind an Authenticator interface, with LINE as the first implementation, so pointing the same backend at a different sign-in is a change in one file rather than a rewrite threaded through the handlers. And the messaging layer stays a thin client over the BFF, so a later WhatsApp or WeChat build reuses the reservation logic wholesale. PII transfers are encrypted with AWS S3 KMS, and the staging and production infrastructure is defined in AWS CDK, so standing up a second environment is a code change rather than a console afternoon.

Delivering across a boundary

This shipped as a multi-party engagement. A bridge consultancy ran product, UI/UX, and QA, our team ran engineering, and I governed the plan across that seam so the two halves stayed in step. The milestones went in order: environment and scaffolding first, then the reservation lookup and its display, a staged client review on staging, the detail screens plus the redirect into the existing web check-in where registration is still required, full UAT, and finally production with a documented handover.

Risk lived at the boundaries between the teams, so the controls leaned on evidence rather than trust. Each milestone was reviewed on a staging environment, feedback was collected and re-tested in a loop rather than waved through, and the app went through UAT on both iOS and Android before anyone signed off a go-live. Launch closed with a smoke-and-performance check, a completion report, and the documentation the operator would need to run it without us.

Outcomes

Engineering Outcomes

≤1.5s
First Contentful Paint on a 4G / LTE network
≤2 taps
from Mini App launch to the stay info
IaC
AWS CDK for staging and production, KMS-encrypted PII

Directorial Outcomes

5
milestones governed across a cross-party delivery
100%
guests keep a persistent channel to their stay info
Ready
one BFF seam left open for WhatsApp / WeChat