The second migration source in our Tier-3 rollout shipped today. Paste a Rebrandly API key, optionally filter to a workspace, click Start. Six to ten minutes later every slashtag sits on your Elido domain with the slug preserved where it didn't collide. The Bitly migration that landed two weeks ago set the scaffolding; Rebrandly is the second vendor to ride it.
This post is the engineering write-up — what's specific to Rebrandly, what we kept identical to the Bitly worker, and where Rebrandly's API forced a different shape.
What's shared with Bitly#
The whole feature was always going to be one table and one worker contract. Both held up.
CREATE TABLE import_jobs (
id BIGSERIAL PRIMARY KEY,
workspace_id BIGINT NOT NULL,
source_vendor TEXT NOT NULL,
target_domain_id BIGINT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
conflict_strategy TEXT NOT NULL DEFAULT 'suffix',
source_filter JSONB NOT NULL DEFAULT '{}'::jsonb,
-- counters + error_log + timestamps elided
);
source_vendor flips to rebrandly. source_filter carries {workspace_id: "..."} when the user filters; {} when they want every link the key can see. Everything else — the 30-minute budget, the 50k-links cap, the suffix/skip/fail conflict strategy, the imported:rebrandly tag — is identical to the Bitly path.
The dashboard launcher (apps/web/src/app/dashboard/integrations/[id]/rebrandly-migration-launcher.tsx) is structurally a copy of the Bitly one with the group dropdown removed — Rebrandly has workspaces, not groups, and we expose them as an optional text filter rather than a populated dropdown because the Workspaces endpoint is unauthenticated-paginated and the typical user has at most two.
Where Rebrandly's API differs#
Three things:
Page size. Rebrandly caps a single page at 25 links. Bitly does 100. So a 5,000-link account that finishes in 4–8 minutes on Bitly takes 6–10 on Rebrandly. The bottleneck is the vendor, not the worker.
Pagination. Rebrandly uses a last query-string parameter that takes the ID of the last item on the previous page. Bitly returns a pagination.next URL. Both are cursor-style; Rebrandly's is just slightly chattier. The whole loop is six lines:
last := ""
for {
page, err := w.fetchPage(ctx, opts.Token, opts.WorkspaceID, last)
if err != nil { /* mark failed */ return }
if len(page) == 0 { break }
for _, link := range page {
// ... resolve slug, insert, update counters ...
}
last = page[len(page)-1].ID
}
We trust the cursor. If Rebrandly returns the same last twice we'd loop forever; the 30-minute budget caps the damage.
Workspace scoping. Rebrandly's API key sees every link in every workspace the user belongs to. If you have an agency account with five client workspaces, you almost certainly want to import one at a time. The launcher exposes this as an optional text field — paste the workspace ID from Rebrandly's URL bar, or leave blank for "everything the key sees".
What we don't migrate#
Click history. Rebrandly's per-click data is Premium-tier-only and surfaces as aggregate counters per link, not per-click events. We surface this limit on every surface the user sees — the dashboard recipe page, the /migrate-from/rebrandly landing, the import progress UI, and the FAQ section. New clicks land in Elido analytics from the cutover moment forward.
Rebrandly UTM templates. They're a presentation-time feature in Rebrandly that doesn't have a clean API surface for export. Rebuild them as Elido campaign rules — the imported:rebrandly tag is the bulk-reassignment handle.
QR styling. Default Elido QR is generated for every imported link; custom designs need to be re-applied. Most users use the bulk tag-filter to assign a default Elido CTA overlay or campaign post-hoc.
Token handling#
Identical to Bitly. The token never lands on disk:
bgCtx := context.WithoutCancel(r.Context())
go h.rebrandly.Run(bgCtx, job.ID, imports.RebrandlyJobOptions{
Token: req.Token,
WorkspaceID: req.WorkspaceID,
})
source_token_id stays NULL. ADR-0036's service_tokens table is for the Tier-2 paste-token integrations (Mailchimp, Brevo, Klaviyo) where recurring use justifies persistence. For one-shot migrations, in-memory only is the right operational trade — the user pastes the token once, the worker runs, the token is gone.
context.WithoutCancel (Go 1.21+) keeps the context's values — logger, trace IDs, deadline — but strips its cancellation signal so the worker outlives the HTTP request that kicked it off. This is the same pattern as the Bitly worker and the same pattern every future migration vendor will use.
Conflict resolution#
Three strategies, identical to Bitly. The user picks when they kick the job off:
- suffix (default): walk
mylink-2,mylink-3, … up to 50 candidates. Past 50 we treat as a structural problem and surface an error. - skip: leave the existing Elido link alone, log the source row, count as skipped.
- fail: abort the whole job on the first conflict. For strict 1:1 semantics.
The slug lookup is one indexed read per row:
func (w *RebrandlyWorker) 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
}
We pay an extra read per row but get a deterministic suffix walk and a friendlier error message. The alternative — fishing for a uniqueness violation in pgx and parsing the constraint name out of the error string — is the worse trade.
What's measurable#
Same structured zap logs as Bitly. Workspace, target domain, conflict strategy, optional workspace filter. Job lifecycle events — start, complete, stuck-sweep flips — are pre-existing and the dashboard hits the polling endpoint every two seconds.
We aren't graphing the migration job metrics in production yet. The Bitly cohort gave us our first real-traffic baseline; Rebrandly's data should be directly comparable because the worker is mechanically identical and the differences are vendor-pagination shape. First alert candidate: stuck-sweep count > 0 in any one-hour window — that means a worker died and the user's UI is stuck on running.
Resumability and the deploy problem#
Same trade as Bitly. Worker is in-process; a mid-import deploy kills the goroutine. We accept that for v1 because:
- Most jobs finish in under ten minutes. Deploys are infrequent at the import-y times of day.
- The
import_jobs.last_progress_atfield plus a 5-minute stuck-sweep cron flips anyrunningrow with no progress in the last 30 minutes tofailedwith a clear reason. - Re-running is idempotent under suffix and skip strategies — already-imported links are detected on the second pass and resolved per the strategy.
For accounts north of 10,000 links, resumability earns its keep — we record the Rebrandly last cursor in import_jobs.source_filter and pick up where the last run left off. That's the next iteration; the four other migration sources will benefit from the same change once we ship it.
What's next#
Same scaffolding, three more vendors to land into the same import_jobs table.
- Short.io —
GET /links?limit=150&domain_id=…. Per-domain pagination; we ask the user to pick a source domain rather than a workspace. - Dub.co —
GET /api/links?projectSlug=…&limit=100. Folders + tags preserved; this is the cleanest of the four. - TinyURL — Pro/Bulk REST API. Free TinyURL has no API and never has; that path stays manual.
Each lands behind the same dashboard polling UI and the same imported:<vendor> tag pattern. The vendor-specific worker stays in services/api-core/internal/imports/<vendor>.go.
If you've been holding off on a Rebrandly comparison because the migration path wasn't documented, it's documented now. Try it — API key to last imported link in under ten minutes for typical accounts.