Linear went Live in the Elido integrations catalog on 2026-05-22. The first event we shipped was broken_link_hook - when our scanner finds a dead short link, it files a Linear issue in the team you picked at Connect time, with click metrics in the body and labels routed by tag. This post is the engineer's walkthrough: how the auth works, what the JSON payload looks like, and how we extended the same pipe to click-threshold spikes so on-call gets a ticket instead of a 3am page.
If you maintain hundreds or thousands of short links in production, you already know the failure mode. Marketing rotates a campaign destination, the new URL 404s, and nobody notices until a customer screenshots a dead link on Bluesky. Linear is where your team already triages bugs, so that is where we put the ticket.
Connecting Linear via Personal API Key#
The Linear integration uses a Personal API Key, not OAuth. We made that call for three reasons: API Keys are scoped to the workspace, they survive admin turnover better than OAuth tokens tied to a single user, and Linear's API: Authentication docs explicitly recommend them for server-to-server jobs.
Generate the key in Linear: Settings, API, Personal API keys, Create key. Name it elido-integration so you can revoke it later without guesswork. Copy the key (it starts with lin_api_) and paste it into the Linear integration card on the Elido dashboard.
What happens next: we make a viewer query to validate the key, then a teams query to populate the team picker. You select a default team. That choice writes a row into integration_configs in Postgres, including the team ID Linear assigned. If you have multiple teams, you can add tag-based routing in the same screen - more on that below.
POST /v1/workspaces/:id/integrations/linear/connect
{
"api_key": "lin_api_<redacted>",
"default_team_id": "TEAM_a1b2c3",
"default_priority": 2,
"labels": ["short-link", "auto-filed"]
}
Behind the scenes, the api-core service stores the key encrypted at rest via the envelope-encryption scheme from ADR-0036. The decrypted key only lives in memory during the actual GraphQL call. We never log the raw value, and the integration logs UI shows the last 4 characters only.
One gotcha: Linear's Personal API Keys are tied to the user who created them. If that user leaves your company and you offboard their Linear seat, the key dies with them. Best practice is to create a service-style user in Linear (we use [email protected]) and generate the key from that account.
The broken_link_hook event - what fires it and what's in the body#
Our url-scanner service runs a weekly crawl of every active short link in your workspace. For each link, it does an HTTP HEAD on the destination, then a GET if HEAD is unsupported, then validates the TLS chain. Four conditions trip the broken-link state:
- HTTP 4xx or 5xx on two consecutive probes (we double-check to absorb transient 500s)
- TLS expired or self-signed where it was valid the week before
- DNS NXDOMAIN - the destination host no longer resolves
- Parked-domain fingerprint match - the destination resolves but the response body matches a known squatter template (we maintain a small fingerprint set)
When any of those four fire, the scanner publishes a link.broken event to Redpanda. The webhook-dispatcher consumes it, looks up your active integrations, and for Linear it materializes the payload below.
Here is a real broken_link_hook payload, captured from our staging env (some fields elided):
{
"event": "link.broken",
"link_id": "01J9V7QXMZ8K2Y3N4P5R6T7W8Z",
"short_url": "https://s.elido.me/spring-launch",
"destination_url": "https://oldcampaign.example.com/landing",
"failure_type": "http_5xx",
"failure_detail": "502 Bad Gateway, 2 consecutive probes",
"last_working_at": "2026-05-28T14:22:00Z",
"detected_at": "2026-06-04T03:11:42Z",
"clicks_last_7d": 2841,
"clicks_last_24h": 412,
"top_referrers": [
{"host": "linkedin.com", "clicks": 1203},
{"host": "twitter.com", "clicks": 488},
{"host": "direct", "clicks": 612}
],
"tags": ["campaign-spring-2026", "paid"],
"owner_email": "[email protected]"
}
The Linear adapter in services/api-core/internal/integrations/linear/broken_link_hook.go takes that payload and builds a GraphQL mutation against Linear's Issues API. The issue title follows a fixed pattern so on-call can grep:
[Elido] Broken link: /spring-launch (502 Bad Gateway)
The body is structured Markdown with five sections: link details, last working timestamp, click delta vs 7-day baseline, top three referrers, and a suggested fix block. The suggested-fix block looks at failure_type and picks a templated suggestion - for http_5xx, "Check if the destination is rate-limiting or in deploy"; for parked_domain, "Domain may have expired or been squatted, archive this link"; and so on.
Labels are assigned from two sources: your default label set (configured at Connect) and dynamic labels derived from the tag list. If a tag matches paid or organic, we add it as a label so PMs can filter their Linear views.
Dedup, rate limits, and the dead-letter queue#
We dedupe broken_link_hook events by destination host for 24 hours. If oldcampaign.example.com died and 800 short links point at it, you get one Linear ticket with all 800 short URLs listed in the body, not 800 separate tickets. This was a hard lesson from the early beta - the first customer to hit a dead domain got buried.
Linear's GraphQL endpoint has a global rate limit per workspace. Our webhook-dispatcher tracks the Retry-After header and uses exponential backoff with full jitter, up to five attempts. After five, the event lands in a dead-letter queue. You can see DLQ entries at Settings, Integrations, Linear, Failed events, and replay any of them with one click. The DLQ is also exposed via the webhooks feature for programmatic replay.
Click-threshold and custom triggers#
The same Linear adapter consumes click_threshold_hook events. You define thresholds per link or per campaign in the Elido dashboard, and we file a Linear issue when a link crosses a band. Two band types are supported today:
- Spike: clicks in the last hour exceed N times the trailing 7-day hourly baseline (default N is 3). Useful for catching virality or, less happily, bot traffic.
- Cliff: clicks in the last hour fall below 10% of the trailing baseline. Useful for catching dead campaigns - if a paid ad got paused upstream, you see a Linear ticket before the marketing standup.
Here is a click_threshold_hook payload:
{
"event": "link.click_threshold",
"link_id": "01J9V7QXMZ8K2Y3N4P5R6T7W8Z",
"short_url": "https://s.elido.me/spring-launch",
"band": "spike",
"current_hour_clicks": 8421,
"baseline_hourly_clicks": 612,
"multiplier": 13.76,
"top_referrers": [
{"host": "news.ycombinator.com", "clicks": 6203},
{"host": "direct", "clicks": 1488}
],
"tags": ["campaign-spring-2026"],
"triggered_at": "2026-06-04T11:14:00Z"
}
For a spike, the suggested-fix block reads: "Verify this is organic traffic, not a referrer-spoofing campaign. Check the referrer breakdown above." For a cliff: "Confirm the campaign is still live upstream. If it was paused, archive this link."
Tag-based routing across multiple teams#
The default team picker is fine for a 20-person workspace. For larger orgs, you want a Linear ticket on a marketing link to go to the Marketing team, and a ticket on a docs link to go to the Documentation team. Tag-based routing handles that.
Routing rules live in integration_configs.routing_json and are evaluated top-down. A rule looks like:
[
{"tag_glob": "campaign-*", "team_id": "TEAM_growth", "labels": ["growth", "urgent"]},
{"tag_glob": "docs-*", "team_id": "TEAM_docs", "labels": ["docs"]},
{"tag_glob": "internal-*", "team_id": "TEAM_internal", "labels": ["internal"]},
{"default": true, "team_id": "TEAM_a1b2c3"}
]
The first rule whose glob matches at least one tag on the link wins. If nothing matches, the default rule takes the event. Glob syntax is the same as Linear's saved-view filters, so PMs already know it.
You can also route by failure_type. Some teams want all TLS failures to go to the platform team since they usually indicate certificate misconfiguration on a tenant custom domain. Add a rule keyed on failure_type: tls_expired and you are done.
Custom triggers via webhooks#
Not every team wants to file Linear tickets for every event type we publish. The full event catalog is documented at the webhooks feature page, but the common pairings teams set up alongside Linear are:
link.createdto a Linear team for new-link audits (rare, usually for compliance teams)domain.takeover_detectedfor TLS surprises on custom domainslink.scan_completefor weekly summary tickets (one issue per scan run, listing all flagged links)
If the event you want is not in the catalog, you can build your own using the generic webhook target and our observability guide. Or just file a feature request on our public Linear board - meta but recursive.
Pricing and what you get on which plan#
Linear integration is included on the Pro tier and above. On Free, you can connect Linear but you only get broken_link_hook (no click-threshold or custom triggers). See pricing for the full matrix. If you are a larger team thinking about this for compliance reasons - say, GDPR Article 32 requires you to detect data leaks from broken redirects pointing at squatter domains - the Enterprise solutions page covers what we offer at scale.
Related reading#
- Webhooks for link events: a developer's guide - the underlying event bus that powers all integrations, including Linear.
- Wiring Sentry across 12 Go services - how we monitor the dispatcher that fires Linear events, so we know when the dispatcher itself is sick.
- Link rotting prevention strategy - the broader operational story behind why we built broken-link detection in the first place.
The full integrations catalog lists 43 vendors as of June 2026, with Linear among the 20 Live ones. If your team uses Jira instead, that adapter is in beta - email us and we will flip you on.
Wypróbuj Elido
Wklej URL, otrzymaj krótki link
Bez rejestracji. Link działa 30 dni. Zarejestruj się, aby zachować go na zawsze.
Za darmo, bez rejestracji · 2 dziennie