The third migration source in our Tier-3 rollout shipped today. Paste a Short.io API key, pick the Short.io source domain (e.g. example.short.gy), pick the Elido target domain, click Start. Three to six minutes later every link sits on your Elido domain with the slug preserved.
This post is the engineering write-up — what's specific to Short.io, what surprised us about their REST API, and why we ended up exposing per-domain jobs rather than a per-account batch.
Per-domain, not per-account#
Short.io's data model has one twist that shaped the entire launcher UX: links are organised under domains, and the /links endpoint paginates per-domain. There's no "give me every link across every domain in this account" call.
We considered a few designs:
- A. Iterate every domain server-side, present one job to the user. Faster from a click-count perspective; harder to expose progress and per-domain conflict-strategy choice.
- B. One Elido job per source domain. Slower in clicks (the user runs N jobs for N domains), but every job has a clear contract: one source domain → one target domain → one conflict strategy.
- C. List every domain, let the user multi-select, queue N jobs server-side.
We shipped B and left C for the rollout-plan iteration. The launcher asks for the source domain hostname as a text field (no dropdown — Short.io's /domains list is cheap to call but adds a round-trip and the user always knows their own domain hostname). One job per domain, queued from the dashboard one at a time.
The page size win#
Short.io paginates at 150 links per call by default — the most generous of our five migration sources. Compare:
- Bitly: 100 per page
- Rebrandly: 25 per page
- TinyURL: 100 per page (Pro/Bulk)
- Dub.co: 100 per page
- Short.io: 150 per page
A 5,000-link Short.io domain takes 34 round-trips. A 5,000-link Rebrandly account takes 200. The worker spends most of its wall time waiting on HTTP responses, so this matters — Short.io is empirically the fastest migration source we support.
const shortioPageSize = 150
page := 1
for {
resp, err := w.fetchPage(ctx, opts.Token, opts.DomainID, page)
if err != nil { /* mark failed */ return }
if len(resp.Links) == 0 { break }
for _, link := range resp.Links { /* import */ }
if !resp.HasMore { break }
page++
}
HasMore is a boolean Short.io returns explicitly — no cursor parsing, no last-id chasing. Their API is one of the better-designed of the five vendors we support.
Private links — what we do#
Short.io has a "private" flag per link. We import private links as Elido links with is_active=false so the slug doesn't resolve at the edge. The user flips them on selectively from the dashboard after spot-checking the import.
The reasoning: if a Short.io link was private at the source, the user's intent was to not have it resolve publicly. Importing it as is_active=true would surface URLs that were deliberately gated. Importing it as is_active=false keeps the slug reserved but unreachable until the user decides — strictly safer than the alternative.
isActive := !link.Private
linkID, err := w.links.InsertImported(ctx, sqldb.InsertImportedLinkParams{
WorkspaceID: job.WorkspaceID,
DomainID: job.TargetDomainID,
Slug: slug,
DestinationURL: link.OriginalURL,
Title: truncate(link.Title, 250),
Tags: append(link.Tags, "imported:shortio"),
IsActive: isActive,
CreatedByUserID: createdByUserID,
})
This is a small surface difference from Bitly (no equivalent flag) and Rebrandly (no equivalent flag). Worth surfacing in the post-import recipe so the user understands why some imported links don't resolve immediately.
What we don't migrate#
Short.io's A/B split-test configurations have no clean export — they're an in-app builder that doesn't surface a deterministic JSON shape via the REST API. Rebuild as Elido smart-link rules post-import; the syntax is more expressive but the mental model is the same.
Per-click history is the universal limit across every migration source. Short.io's per-click data lives in their analytics export, which is Team-plan-only (accessed 2026-05-22) and surfaces as aggregated counters rather than per-click events. New clicks land in Elido analytics from cutover.
QR designs and the per-link UTM presets — same story as Bitly and Rebrandly. Tagged imported:shortio, ready for bulk follow-up via Elido campaigns.
Domain handover#
The interesting Short.io use case is "I'm running a branded domain on Short.io and want to move it to Elido without changing the URL". The migration handles the link side cleanly; the DNS side is one CNAME change.
We document the handover sequence on the /migrate-from/shortio landing — keep both surfaces resolving in parallel until your Short.io subscription ends, then point the DNS at Elido. There's no urgency to take Short.io down the day the import finishes.
Custom domains in Elido use Caddy's on-demand TLS with domain-manager as the allow-list source, so the cutover is a CNAME change plus a domain-verification API call. No certificate dance on the user's side.
Conflict resolution and the worker contract#
Identical to Bitly and Rebrandly — suffix walks mylink-2, mylink-3, … on collision; skip leaves the existing Elido link alone and logs the source row; fail aborts on first conflict. The lookup is one indexed read per row.
The worker contract — MaxLinksPerImport=50_000, ImportRunBudget=30*time.Minute, progressEvery=50, errorLogCap=1_000 — is shared across all five vendors. These constants do most of the work and they aren't config knobs. They're the contract the dashboard polling UI assumes.
Token handling#
bgCtx := context.WithoutCancel(r.Context())
go h.shortio.Run(bgCtx, job.ID, imports.ShortioJobOptions{
Token: req.Token,
DomainID: req.DomainID,
})
source_token_id stays NULL. Same one-shot semantics as Bitly and Rebrandly — the user pastes the token once, the worker runs, the token is dropped from memory at completion. We don't persist it because the value of persistence (recurring use) doesn't apply to migrations.
context.WithoutCancel keeps the worker alive past the HTTP request that kicked it off. Same pattern as every other migration vendor in this rollout.
Comparison with the CSV-export path#
Short.io exposes a CSV export on Team plans. We chose REST over CSV because:
- REST preserves Short.io tags structurally. CSV flattens them into a comma-separated string that requires post-parse splitting.
- REST exposes the
privateflag. CSV doesn't include it consistently. - REST gives us deterministic progress (links seen / links remaining). CSV is a one-shot file upload with no mid-flight progress signal.
- REST is plan-agnostic — every Short.io plan exposes
/links. CSV export is Team-only.
The CSV path stays in our back pocket for users on legacy Short.io accounts whose API token has been revoked but who still have a CSV from the last export.
Resumability and the deploy problem#
Same trade as the first two migrations. Worker is in-process; a mid-import deploy kills the goroutine. The import_jobs.last_progress_at field plus the 5-minute stuck-sweep cron flips any running row with no progress in the last 30 minutes to failed. Re-running is idempotent under suffix and skip.
For accounts north of 10,000 links across multiple Short.io domains, the per-domain job design helps here — each domain is bounded by the 30-minute budget independently, so a deploy mid-third-domain doesn't lose work from the first two.
What's next#
Two more vendors to land:
- Dub.co —
GET /api/links?projectSlug=…&limit=100. Folders flatten into tags. The cleanest API of the five. - TinyURL — Pro/Bulk REST API at 100 per page. Free TinyURL has no API and never has; that stays a manual path.
After Dub and TinyURL, the Tier-3 rollout is done. The five migration landings (/migrate-from/bitly, /migrate-from/rebrandly, /migrate-from/shortio, /migrate-from/dub, /migrate-from/tinyurl) and the five engineering blog posts cover every vendor-from query a Bitly-alternative searcher might land on.
If you've been holding off on a Short.io comparison because the migration story was undocumented, it's documented now. Try it — API key + domain to last imported link in under six minutes for typical accounts.