Elido
6 min readEngineering

Shipping the TinyURL migration: Pro/Bulk REST, no free-tier path

How we built one-click TinyURL Pro/Bulk imports for Elido — why public TinyURL has no API, the alias-vs-slug terminology, and the limit we shipped on purpose.

Marius Voß
DevRel · edge infra
Pipeline diagram: TinyURL Pro/Bulk API on the left flowing through the Elido import worker into the links table, with a side panel listing the numeric guarantees (50k cap, 30 min budget, 100 per page, Pro/Bulk plans only)

The fourth migration source in our Tier-3 rollout shipped today. Paste a TinyURL Pro or Bulk API token, pick a target Elido domain, click Start. Four to seven minutes later every TinyURL alias sits on your Elido domain with the alias preserved where it didn't collide.

This post is the engineering write-up — what's specific to TinyURL, the deliberate limit we shipped, and why "free-tier TinyURL migration" is not a thing we can build.

The free-tier problem#

Public TinyURL has no API and never has. The classic tinyurl.com/<slug> you create without an account is a fire-and-forget redirect — the user creates it via the homepage form, gets a slug back, and the slug never re-appears in any account dashboard. There's no per-user listing because there's no per-user binding.

This is well-known, but it's worth surfacing on the /migrate-from/tinyurl landing page because the search query "migrate from TinyURL" doesn't disambiguate Pro from free. We shipped:

  • A clear "Pro/Bulk only" callout on the landing page hero.
  • An FAQ entry that points free-tier users at the /docs/guides/bulk-create form for paste-list-of-destinations bulk shortening.
  • A token-validation step in the launcher that fails fast with "this token isn't on a Pro or Bulk plan" rather than letting the run silently 401 mid-pagination.

The reasoning: every other migration source we ship has a happy path for "every user who searches for it". TinyURL is the exception — free-tier users need a different mental model and we should set that expectation before they paste anything.

Pro/Bulk REST API shape#

The TinyURL Pro API is straightforward: bearer token, JSON responses, 100 aliases per page. Pagination uses a page query-string parameter that's 1-indexed; the response includes data.aliases (the link array) and meta.has_more (the continue signal).

const tinyurlPageSize = 100

page := 1
for {
    resp, err := w.fetchPage(ctx, opts.Token, page)
    if err != nil { /* mark failed */ return }
    if len(resp.Data.Aliases) == 0 { break }
    for _, alias := range resp.Data.Aliases { /* import */ }
    if !resp.Meta.HasMore { break }
    page++
}

Each alias carries url (the long destination), alias (the custom slug or auto-generated short code), description (an optional TinyURL field we preserve as the Elido link title), and a domain (TinyURL allows branded domains on Bulk plans).

Terminology — alias vs slug#

TinyURL calls them "aliases". We call them "slugs". Same thing — the character sequence after the host in the redirect URL. The migration preserves the alias 1:1 where the target Elido domain has no collision; if there's a collision, the standard suffix/skip/fail conflict strategy applies.

We considered renaming "slug" to "alias" in the launcher to match the source vendor's terminology and rejected it for consistency reasons. Every other Elido surface — link list, API, SDK, dashboard — uses "slug". Importing terminology asymmetry into one launcher would make the post-import experience confusing.

The launcher does drop a one-line label saying "TinyURL calls these aliases" above the conflict-strategy radio, so users searching the recipe page for "alias" find the right control without reading every word.

Branded domains and the DNS handover#

TinyURL Bulk plans support branded domains — your own hostname routing through TinyURL's infrastructure. When you migrate to Elido, the slug imports clean, and the DNS side is one CNAME change.

The interesting case is "I have a branded domain on TinyURL Bulk and I want to keep the same hostname after migration". We handle this the same way as the Short.io migration:

  • Migration completes. Imported links sit on s.elido.me/<alias> by default (or your existing Elido custom domain).
  • You add the TinyURL branded domain as an Elido custom domain via /docs/guides/custom-domains.
  • You point the CNAME at Elido. Caddy's on-demand TLS issues a certificate on first request; domain-manager is the allow-list source-of-truth so unauthorised hostnames get rejected.
  • TinyURL's surface stops resolving for that hostname; Elido's takes over.

You can keep both surfaces alive in parallel until your TinyURL subscription ends, then the cutover is just letting the TinyURL hostname expire. No urgency, no day-of-cutover risk.

What we don't migrate#

Click history. TinyURL Pro/Bulk's analytics are separate report endpoints that aren't structured for export. The Bulk plan exposes per-link click counts in the dashboard but doesn't surface them via the migration-friendly API; new clicks land in Elido analytics from cutover.

QR styling and Bulk-tier UTM templates. Same story as every other migration source — the slug imports, the surrounding presentation layer is rebuild-in-Elido. Tagged imported:tinyurl for bulk follow-up via campaigns.

Free-tier TinyURL links. As discussed above, public TinyURL has no API. The mitigation is the bulk-create form, not a migration job.

Token handling#

Same one-shot semantics as Bitly, Rebrandly, and Short.io:

bgCtx := context.WithoutCancel(r.Context())
go h.tinyurl.Run(bgCtx, job.ID, imports.TinyURLJobOptions{
    Token: req.Token,
})

source_token_id stays NULL. The token lives in api-core process memory for the worker run and is dropped at completion. No persistence, no service_tokens row, no ADR-0036 envelope encryption — those are for Tier-2 integrations where the user wants recurring vendor calls.

The token-validation step at job start hits TinyURL's /account/domains endpoint — cheap call, returns a list of domains the token can see. If it 401s, we fail fast with "token is invalid or not on a Pro/Bulk plan" rather than letting the user wait two minutes for a mid-pagination 401 and a less-helpful error message.

Conflict resolution#

Identical to every other migration vendor — suffix walks myalias-2, myalias-3, … on collision; skip leaves the existing Elido link alone and logs the source row; fail aborts on first conflict.

func (w *TinyURLWorker) resolveSlug(ctx context.Context, domainID int64, desired, strategy string) (string, error) {
    if _, err := w.links.GetByDomainSlug(ctx, domainID, desired); err != nil {
        if errors.Is(err, pgx.ErrNoRows) { return desired, nil }
        return "", fmt.Errorf("slug lookup: %w", err)
    }
    // suffix/skip/fail branching identical to bitly.go
}

The lookup is one indexed read per row. We pay an extra read but get deterministic suffix walks and friendlier error messages than fishing for uniqueness violations.

The worker contract#

MaxLinksPerImport=50_000, ImportRunBudget=30*time.Minute, progressEvery=50, errorLogCap=1_000. Shared across all five migration vendors. These constants are the contract the dashboard polling UI assumes.

A 2,000-alias TinyURL Pro account hits the API 20 times and finishes in 3–5 minutes. A 20,000-alias Bulk account takes 200 round-trips and finishes in 15–20 minutes. Above 50,000 aliases the worker hard-fails with an instruction to email [email protected] for a chunked migration; the chunked migration path is concierge-only in v1.

Resumability and the deploy problem#

Same trade as the first three migrations. Worker is in-process; a mid-import deploy kills the goroutine. The stuck-sweep cron flips any running row with no progress in 30 minutes to failed. Re-running is idempotent under suffix and skip.

For accounts north of 10,000 aliases, resumability would earn its keep — we'd record the TinyURL page cursor in import_jobs.source_filter and resume from the last completed page. The four other migration vendors will benefit from the same change once we ship it; the design is shared.

CSV fallback#

For users on Bulk plans with an exported CSV who no longer have an active API token, we run one-off CSV jobs from the inbox — email [email protected]. We didn't ship a self-serve CSV upload form because the REST path covers the common case and the CSV path needs per-account schema-massaging that's better done by hand than by a brittle generic parser.

What's next#

One more vendor to land:

  • Dub.coGET /api/links?projectSlug=…&limit=100. Folders flatten into tags. The cleanest API of the five.

After Dub, the Tier-3 rollout is done. Five migration landings, five engineering blog posts, one shared worker scaffolding, one shared dashboard polling UI.

If you've been holding off because the TinyURL migration was undocumented, it's documented now. Try it — Pro/Bulk token to last imported link in under seven minutes for typical accounts.

Try Elido

EU-hosted URL shortener with custom domains, deep analytics, and an open API. Free tier — no credit card.

Tags
tinyurl migration
url shortener
go worker
data migration
engineering
tier 3 integrations

Continue reading