Campaign launches don't start in a dashboard. They start in a spreadsheet someone shared on Slack. The URLs live in column A, UTM metadata fills columns B through G, the slugs are in column H, and the brief says the launch is tomorrow. The slowest part of the whole process is copying each row into a shortener UI one link at a time — not because anything is technically hard, but because there's no reason to do it that way.
This post is the direct workflow: what the sheet looks like, how it maps to Elido's bulk-import endpoint, three import paths depending on row count, the dry-run step that catches mistakes before they're in production, and an Apps Script snippet that automates the whole thing on a trigger. For the broader context on end-to-end UTM hygiene, the UTM tracking cornerstone covers workspace templates and server-side conversion forwarding in depth. This post is the sheet-to-short-links slice of that pipeline.
TL;DR#
- Keep one sheet per campaign:
target_url,slug,utm_source,utm_medium,utm_campaign,tagsas named columns. UTM columns that are blank get filled from your workspace template. - Three import paths: paste rows into the UI (up to 1,000 rows), CSV upload (up to 10,000 rows), or the API via script (unlimited, repeatable).
- Always run with
dry_run=truefirst. The preview shows the resolved short link and fully-rendered UTM query string without committing anything. - Prefix campaign slugs (
q2-,jun-) to namespace them. Collisions surface in the dry run, not mid-import.
Sheet shape#
The required columns are target_url and one of slug or auto_slug. Everything else is optional but has a defined interpretation when present.
| Column | Required | Notes |
|---|---|---|
target_url | yes | Full destination URL including scheme |
slug | one of two | Preferred — gives you predictable short URLs |
auto_slug | one of two | Set to true and Elido generates a slug |
utm_source | optional | Overrides workspace template value |
utm_medium | optional | Overrides workspace template value |
utm_campaign | optional | Overrides workspace template value |
utm_content | optional | Usually the creative variant |
utm_term | optional | Paid keyword or audience segment |
tags | optional | Comma-separated, applied to the link |
title | optional | Shown in the dashboard link list |
The UTM rule is simple: if target_url already contains a ?utm_source= (or any utm_*) query parameter, those values are passed through unchanged. No overwriting, no merging. If the destination URL has no UTM parameters, Elido builds them from the UTM columns, falling back to your workspace template for any column that's blank. This matters in practice — some teams keep pre-tagged destination URLs for their email service provider, and a bulk import tool that silently re-tags them produces broken analytics. Elido warns on mixed-mode rows (some UTMs present, some missing) and asks you to confirm.
The tag column deserves its own note. Values are comma-separated strings: campaign:q2-spring, channel:paid-social, variant:hero-a. That three-part shape (dimension:value) gives you filterable axes in the dashboard without needing a separate taxonomy config. More on this in the tag taxonomy section below.
The three import paths#
Paste rows into the bulk-import UI (up to 1,000 rows)#
For anything under 1,000 rows, the fastest path is to copy the sheet range and paste into the bulk-import text area. Elido's UI auto-detects tab-separated values from a spreadsheet paste and maps columns by header. You don't export a CSV; you just paste.
This works well for the most common case: a campaign brief that already lives in Sheets, a launch deadline an hour away, and no appetite for scripting. The UI shows a preview of all rows before commit (the same dry-run you'd get from the API) and lets you fix any failed rows inline before proceeding.
One gotcha: if your sheet has merged cells or complex formatting, the paste may produce garbled output. The safe move for any sheet with a non-trivial structure is to copy to a clean sheet first (paste-as-values), then paste the cleaned range into the import UI.
CSV upload (up to 10,000 rows)#
For launches with more than 1,000 rows (large catalog campaigns, event codes, personalised links), the CSV upload path handles up to 10,000 rows. Export the sheet as CSV (File > Download > CSV) and upload it in the import dialog. The column-header mapping is the same; the difference is that large uploads process asynchronously and report their status via a webhook or poll endpoint.
Google Sheets' CSV export via the API (accessed 2026-05-12) supports exporting a named range rather than the whole sheet, which is useful when your campaign sheet has multiple tabs or header rows you don't want to clean up manually.
API call from a script (more than 10,000 rows, or repeated runs)#
For large catalogs, or for campaigns that run weekly and need the same process automated, the API path is the right one. Two common implementations: Apps Script (no local tooling required, runs in the browser) and Python (better for teams with existing data pipelines). The endpoint is the same either way.
curl -X POST \
https://api.elido.app/v1/links/bulk \
-H "Authorization: Bearer $ELIDO_TOKEN" \
-H "Content-Type: multipart/form-data" \
-F "csv=@q2_spring_links.csv" \
-F "campaign_id=cmp_8a2f" \
-F "dry_run=false" \
-F "on_conflict=skip"
The on_conflict parameter controls what happens when a slug already exists: skip leaves the existing link in place and records a warning, fail aborts the entire import on the first collision, and replace updates the existing link's destination. For most campaign imports, skip is the right default: a re-run of the same CSV won't overwrite links you already created.
The API accepts up to 10,000 rows per call. For larger catalogs, batch in 5,000-row chunks; each call is independent and idempotent if you use stable slugs.
Pre-import dry run#
Run every import with dry_run=true before the commit. The response is identical to the live import (every row shows its resolved short link, the parsed UTM query string, the tag list, and any warnings) but nothing is written to the database.
The things dry-run catches that you won't catch any other way before launch:
- A slug in row 14 that collides with an existing link in your workspace (surfaced as a conflict warning)
- A UTM column that was accidentally left blank (Elido flags
utm_mediummissing as a warning, not a hard error, but one you want to know about before launch) - A
target_urlwith a trailing space that survived the spreadsheet copy (the resolved URL looks fine in the CSV but the actual destination has%20appended) - Tag values that exceed 32 characters (silently truncated; the dry run makes the stored value visible)
The dry-run response is paginated in the same format as a real import result. Open the first page, spot-check row 2 (the first data row after your likely-perfect row 1) and the last row. Then look at any rows that flag a warning. Two minutes of review catches the mistakes that would otherwise surface as "why is this campaign link 404-ing?" the morning after launch.
Slug conflicts#
Slug conflicts happen when a slug you're trying to import already exists in your workspace or on your custom domain. The import surfaces them in the dry-run response with the conflict type (same_workspace, same_domain, reserved) and the existing link's destination URL.
The practical fix is namespacing. Prefix campaign slugs with a short identifier: q2-, jun26-, sm- (for social media), em- (for email). A slug like q2-spring-hero-a is unlikely to collide with anything from a previous campaign. Prefixes also make the dashboard filter obvious — all links tagged q2-* belong to one campaign quarter.
One case worth calling out: if you're migrating from another shortener and want to preserve legacy slugs, import those first without a prefix, then use prefixed slugs for new campaign content. The Elido bulk import will tell you in the dry run if any of the legacy slugs conflict with ones already in the workspace.
Tag taxonomy#
Tags applied at import time get the same three-part structure as the sheet columns that drove them: campaign:q2-spring, channel:email, variant:hero-a. When you open the dashboard later and filter by channel:email, you're not sifting through free-text strings — you're querying a consistent taxonomy.
The dimension names (campaign, channel, variant) come from your team's convention, not from any Elido-enforced schema. The constraint is the format: a colon separator, no spaces in the key, values under 32 characters. Teams that enforce this in the sheet (a column tags that a formula builds as "campaign:"&E2&", channel:"&F2) never have malformed tags in the dashboard. Teams that let the tags column be free-text have a cleanup problem within three campaigns.
For the campaigns feature overview, the tag-based grouping is the primary way Elido groups clicks by campaign dimension in the analytics panel — so the taxonomy you define in the sheet is the taxonomy you'll filter by when reporting.
Apps Script automation#
For teams that run the same campaign structure weekly (newsletter links, paid social links, email variants), the right move is to automate the import entirely. Google Apps Script runs in the browser, has access to the sheet data, and can trigger on a time-based or form-submit cron.
The pattern: a trigger fires, the script reads any sheet rows that don't have a short_link value in column I, POSTs them to the bulk-import API, and writes the created short links back into column I. On the next trigger, already-imported rows are skipped because column I is populated.
// Google Apps Script — bulk import new rows via Elido API
// Trigger: time-driven, every hour (or on form submit)
// Docs: https://developers.google.com/apps-script/guides/triggers (accessed 2026-05-12)
function importNewLinks() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Q2 Spring");
const data = sheet.getDataRange().getValues();
const headers = data[0];
const urlCol = headers.indexOf("target_url");
const slugCol = headers.indexOf("slug");
const srcCol = headers.indexOf("utm_source");
const medCol = headers.indexOf("utm_medium");
const campCol = headers.indexOf("utm_campaign");
const tagsCol = headers.indexOf("tags");
const doneCol = headers.indexOf("short_link"); // write back here
const newRows = [];
const rowIndexes = [];
for (let i = 1; i < data.length; i++) {
const row = data[i];
if (!row[urlCol] || row[doneCol]) continue; // skip empty or already imported
newRows.push({
destination: row[urlCol],
slug: row[slugCol] || undefined,
utm_source: row[srcCol] || undefined,
utm_medium: row[medCol] || undefined,
utm_campaign: row[campCol] || undefined,
tags: row[tagsCol] ? String(row[tagsCol]).split(",").map(t => t.trim()) : []
});
rowIndexes.push(i);
}
if (!newRows.length) return;
const payload = JSON.stringify({
links: newRows,
campaign_id: "cmp_8a2f",
on_conflict: "skip"
});
const resp = UrlFetchApp.fetch("https://api.elido.app/v1/links/bulk", {
method: "post",
contentType: "application/json",
headers: { "Authorization": "Bearer " + PropertiesService.getScriptProperties().getProperty("ELIDO_TOKEN") },
payload: payload,
muteHttpExceptions: true
});
const result = JSON.parse(resp.getContentText());
const created = result.links || [];
// Write short links back into column I
created.forEach((link, idx) => {
if (!link.short_url) return;
const sheetRow = rowIndexes[idx] + 1; // 1-indexed
sheet.getRange(sheetRow, doneCol + 1).setValue(link.short_url);
});
}
A few implementation notes:
Store the API token in PropertiesService.getScriptProperties(), not hardcoded in the script. The Apps Script triggers documentation (accessed 2026-05-12) covers both time-driven and event-driven trigger setup. For a campaign sheet that a team fills out collaboratively, an onEdit trigger fires when column A is populated; the short link appears in column I within seconds of typing the destination URL.
The muteHttpExceptions: true flag is important. Without it, a 422 from the API throws a script-level exception and the trigger stops retrying. With it, you get the error body and can log it instead.
For a heavier integration (a Python script, a CI step that reads a sheet via the Sheets API, or a scheduled job in your existing data pipeline), the Sheets API's spreadsheets.values.get endpoint (accessed 2026-05-12) gives you JSON directly. From there the shape of the bulk import call is identical to the curl example above.
Common mistakes#
Trailing whitespace in slugs. A slug copied from a spreadsheet cell can have a trailing space that's invisible in the UI. Elido allows it (the slug is technically valid), but go.example.com/q2-promo with a trailing space is an ugly URL and the clipboard copy from a browser address bar usually strips it, so the person who pastes the short link later gets a 404. The fix is a =TRIM(H2) formula on the slug column before export.
Missing utm_medium. Elido warns but doesn't block on a missing utm_medium because some campaigns intentionally skip it. But a missing medium is nearly always a mistake: GA4 routes anything without one to the (none) channel, which makes channel attribution useless. The GA4 URL builder canonical reference (accessed 2026-05-12) lists utm_medium as required for campaign attribution to work correctly. If your workspace template has a utm_medium default, blank cells in the column inherit it; if not, the dry-run warning is your last chance to catch it.
Tag values over 32 characters. Elido silently truncates tag values that exceed 32 characters. The truncation is invisible in the dry-run warnings unless you look for it (the response shows the stored value, not the original). Long tag values usually come from pasting UTM campaign names into the tags column: spring-2026-dach-email-reactivation-week3 is 42 characters and will become spring-2026-dach-email-reactivation-we in the dashboard. Keep tag dimension values short; move the verbose metadata into the link title instead.
Forgetting dry_run=true on re-runs. If you re-run a CSV upload against a campaign that already has links, on_conflict=skip is safe but on_conflict=replace will update destination URLs on any slug that appears in both the old and new CSV. On a campaign where the destination URLs haven't changed, this is harmless. On a campaign where you've updated landing page URLs mid-flight, it's what you want. Know which mode you're in before you commit.
The setup-to-launch summary#
The most complete version of this workflow: build the sheet once with stable column names, define a workspace UTM template so blank UTM columns inherit sensible defaults (covered in setup-branded-short-links), run the dry-import to catch conflicts and warnings, commit, and write the Apps Script trigger so the next campaign requires zero manual steps.
For the attribution layer that closes the loop after the click, server-side conversion tracking covers how to wire the click_id from Elido's redirect response through to Meta CAPI and GA4, the server-side piece that survives Safari ITP and ad-blocker interference. That post and this one together give you a complete picture of the solutions/marketers campaign workflow, from sheet to short link to attributed conversion.
The full campaign URL management surface — templates, bulk import, campaign grouping, conversion forwarding — is on the features/campaigns page.