Campaign Tracking Links (Redirect Mapping / Encrypted Token)
Overview
Every URL and image in a campaign message has to be swapped for a "tracking link" before it goes out, so the system knows who clicked what and when. Two modes are supported:
- legacy — calls the universal-redirect-service to pre-create a short URL (one API call per recipient)
- builtin — mints a self-contained encrypted token (AES-256-GCM) that embeds the destination plus full tracking context, with no external API call and no row created ahead of time
The builtin mode directly fixes a scale problem: a campaign sent to 2 million people used to mean 2–4 million redirect-service calls and roughly 8 million rows created, about 85% of which were never clicked.
Business Flow
- During delivery (broadcast/multicast) it calls
extendService.createRedirectMappings(ctx, campaign, userID) - It checks
CAMPAIGN_REDIRECT_MODE- Not
builtin→ the old path: call universal-redirect-service to create the mapping builtinandCAMPAIGN_LINK_KEYS/CAMPAIGN_LINK_ACTIVE_KEY_IDare both set →buildBuiltinMappings(a broken config logs once and falls back to legacy — the send never fails because of it)
- Not
- It extracts the URL list (
ExtractUrls) and image URLs (ExtractImageUrls) from the rich message content - If the OA has Google Analytics identity enabled, an identity parameter (e.g.
`?ga_id=<lineUid>`) is appended to the destination before encryption (same behavior as the originalappendGaIdentityParam) - If the destination is
liff.line.me, it mints the link under the OA's LIFF base and sets flagfso it opens as a LIFF window instead of a bare in-app browser - It builds the token: bytes laid out as
keyId (1 byte)+nonce (12 bytes)+ciphertext+tag (16 bytes), encrypted with AES-256-GCM using AAD"campaign-link-v1", then encoded as unpadded base64url. The payload is a compact JSON object with keysc, o, l, m, i, u, t, d, eand optionallyf— meaning campaignId, orgId, lineOaId, richMessageId, index, lineUid, type (url/asset), destination, and expiry - The result is a URL shaped like
`https://<CAMPAIGN_LINK_HOST>/c/<token>`, returned as a mapping (original URL → tracking URL) forTransformMessageObjectsto substitute into the message - When the user taps it: client-web's
`/c/[token]`route proxies to client-api-go, which decrypts the token, writestracking_logasynchronously, and replies with a 302 to the destination (or streams the image) — the worker is not involved at click time at all - After expiry (the
efield, 30 days by default), the endpoint still redirects normally but stops writing tracking data — the link never dies, and old keys can be retired safely
Key Files & Functions
internal/campaignlink/token.go—Payload,seal(),Builder.Build(),marshalPayload()(disables HTML escaping so the bytes match Node'sJSON.stringify)internal/campaignlink/config.go—Mode(),IsBuiltin(),ExpiryDays(),NewBuilderFromEnv()internal/campaignlink/token_test.go— a cross-language test vector (must match client-api's)internal/linemessageapi/extend.go—extendService.createRedirectMappings(),buildBuiltinMappings(),campaignLinkBuilder(),getGaSettings(),getLineLiffId(),isLiffURL()internal/redirectx/redirectx.go— the universal-redirect-service client (legacy path)- Full spec docs:
docs/campaign-link-token-spec.mdanddocs/campaign-delivery-tracking-redesign.mdin the worker repo
Connections to Other Services
- Worker-side ENV:
CAMPAIGN_REDIRECT_MODE,CAMPAIGN_LINK_KEYS(a JSON map of keyId to a base64 32-byte key),CAMPAIGN_LINK_ACTIVE_KEY_ID,CAMPAIGN_LINK_HOST,CAMPAIGN_LINK_EXP_DAYS - client-api-go — must hold the exact same
CAMPAIGN_LINK_KEYS, byte-for-byte, to decrypt (endpoint`/api/c/:token`); a key must never be removed while any link using it is still alive - client-web — the
`/c/[token]`route is a thin proxy that holds no keys - universal-redirect-service — still used for legacy links and other consumers
- Tables: reads
line_oa(GA setting, LIFF id); a click writes totracking_log(on client-api) - cms-api needs no changes — it still creates campaigns and reads reports from
tracking_logthe same way under both modes