Trigger links
An organization with CRM read permission can list named links and click counts in Settings → Developer → Trigger links. CRM manage-fields permission creates, edits and archives links; CRM edit permission renders a URL for a contact. The same REST permissions apply to agents through MCP. Copy the merge key into a supported template and call expandTriggerLinks with the organization, contact and public API base URL before delivery. The outreach renderer is unchanged; it does not yet call this helper.
Links have a stable org-unique slug and a saved HTTP(S) destination, with no credentials and at most 2000 characters. Editing the saved destination changes where existing rendered links redirect. A query parameter cannot change it. Archiving invalidates every rendered link.
mintLinkUrl(db, apiPublicUrl, orgId, linkId, contactId|null) returns /v1/public/l/tl1.<43 base64url characters>. HMAC-SHA256 under a domain-separated key derived from the server's outreach suppression secret yields the same opaque token for the same link/contact. Only SHA256 of that token is stored. The token is a bearer capability: anyone holding it can register a click against its bound contact. It contains no readable contact or organization identifier. Changing the server secret affects future minting; existing stored hashes remain valid until the link is archived.
GET records one click and redirects 302 to the stored destination. HEAD resolves and redirects without any write. Invalid or archived tokens return the app's ordinary 404. URLs are scrubbed by the proposed tl1. credential pattern; overlong tokens are rewritten before routing. Click records retain only a browser/scanner/other user-agent class, no IP or raw user-agent.
A live, unsuppressed contact click publishes trigger_link_clicked, type trigger_link.clicked, contact subject, and {linkId, linkKey} data in the click transaction. Its deterministic event ID deduplicates a fixed UTC 10-minute bucket per link/contact. Every click is still counted. This bounds mail-scanner duplicates; it cannot distinguish scanners from people and the first eligible GET can trigger the workflow. Anonymous, erased, archived, merged or suppressed contacts still redirect and are counted, with no tag or event. An optional click tag is applied using the existing CRM tag service. Link conditions use kind: event, key linkId or linkKey, with the existing comparison operators.
Org routes under /v1/orgs/:orgId/trigger-links: GET list; POST create; GET/PATCH/DELETE /:linkId; GET /:linkId/stats; POST /:linkId/mint with {contactId: uuid|null}. API schemas include OpenAPI summaries and validated bodies. MCP tools expose list, read, stats, create and archive. MCP has no destination-update tool; REST updates always require the same live manage-fields permission as members.
fromGhlLinks accepts synthetic {links: [...]} or {link: {...}} envelopes with GHL id, name, redirectTo, fieldKey, locationId. It records highlevel provenance and preserves the exact merge field; source IDs deduplicate within the target organization. A GHL variable URL goes to needs_review rather than being imported. Each call targets one already-authorized GoodQA organization; it does not resolve GHL location-to-org mappings or call a provider.
References checked 2026-10-22: GHL 2021 API, GHL 2023 API. The API permits variables in redirectTo; this importer requires an absolute destination. Fastify redirect and dark-route behavior follows its Reply reference.
Shared registration and additive SQL live in docs/ops/ghl-trigger-links-20261022/REGISTRATION-PROPOSAL.md. ROOT applies them and generates the final snapshot at its allocated ordinal. Follow-up: attach this mapper to the switchover-core migration envelope after its integration; no envelope coupling is introduced here.