Skip to main content

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

  1. During delivery (broadcast/multicast) it calls extendService.createRedirectMappings(ctx, campaign, userID)
  2. It checks CAMPAIGN_REDIRECT_MODE
    • Not builtin → the old path: call universal-redirect-service to create the mapping
    • builtin and CAMPAIGN_LINK_KEYS / CAMPAIGN_LINK_ACTIVE_KEY_ID are both set → buildBuiltinMappings (a broken config logs once and falls back to legacy — the send never fails because of it)
  3. It extracts the URL list (ExtractUrls) and image URLs (ExtractImageUrls) from the rich message content
  4. 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 original appendGaIdentityParam)
  5. If the destination is liff.line.me, it mints the link under the OA's LIFF base and sets flag f so it opens as a LIFF window instead of a bare in-app browser
  6. 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 keys c, o, l, m, i, u, t, d, e and optionally f — meaning campaignId, orgId, lineOaId, richMessageId, index, lineUid, type (url/asset), destination, and expiry
  7. The result is a URL shaped like `https://<CAMPAIGN_LINK_HOST>/c/<token>`, returned as a mapping (original URL → tracking URL) for TransformMessageObjects to substitute into the message
  8. When the user taps it: client-web's `/c/[token]` route proxies to client-api-go, which decrypts the token, writes tracking_log asynchronously, and replies with a 302 to the destination (or streams the image) — the worker is not involved at click time at all
  9. After expiry (the e field, 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.goPayload, seal(), Builder.Build(), marshalPayload() (disables HTML escaping so the bytes match Node's JSON.stringify)
  • internal/campaignlink/config.goMode(), IsBuiltin(), ExpiryDays(), NewBuilderFromEnv()
  • internal/campaignlink/token_test.go — a cross-language test vector (must match client-api's)
  • internal/linemessageapi/extend.goextendService.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.md and docs/campaign-delivery-tracking-redesign.md in 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 to tracking_log (on client-api)
  • cms-api needs no changes — it still creates campaigns and reads reports from tracking_log the same way under both modes