# Instructions for AI assistants: convert a link export into the Revolink import CSV

You are given a client's export of short links (from Bitly, Short.io, Rebrandly, a spreadsheet, a database dump, anything). Convert it into a CSV that can be uploaded to Revolink's link import without any manual column mapping.

Human-readable guide: https://docs.revolink.link/guides/import
Template: https://docs.revolink.link/revolink-import-template.csv

## Output

One CSV file, UTF-8, comma-separated, one link per row, with **exactly this header row, in this order, lowercase**:

```
title,destination_url,short_link,domain,tags,created_at,expiration_date,clicks,rules,ab_test,routing_mode
```

Never rename, translate, add or drop columns. The headers are how Revolink recognizes the file and maps it automatically. Keep all eleven columns even when a column is empty for every row. `rules`, `ab_test` and `routing_mode` are optional and are empty for most links.

Example row with no rules and no A/B test (the last three cells are empty):

```
Spring campaign,https://acme.inc/spring-sale?utm_source=email&utm_medium=newsletter&utm_campaign=spring,spring-sale,acme.inc,marketing;email;spring,2026-03-01,2026-12-31,1250,,,
```

A row with a country/device rule and an A/B test is shown in [Rules and A/B tests](#rules-and-ab-tests-columns-rules-and-ab_test) and in the template.

## Column rules

| Column | Required | Rule |
|---|---|---|
| `destination_url` | **Yes** | Full URL starting with `http://` or `https://`. This is where the link redirects visitors — **not** the short link. Keep the whole query string (UTM parameters etc.) untouched. |
| `title` | No | The link's display name. Empty is fine: Revolink names the link `Imported link for <destination domain>`. A cell with only spaces is rejected. Max 255 characters. |
| `short_link` | No | Only the **back-half** of the short link: `spring-sale`, not `acme.inc/spring-sale` and not `https://acme.inc/spring-sale`. Allowed characters: letters (case matters: `Promo` and `promo` are different), digits and hyphens. Hyphens must be single and between other characters (no `--`, none at the start or end). **No underscores.** Max 100 characters. |
| `domain` | No | Only the host the short link lives on: `acme.inc`. No scheme, no path, no trailing slash. |
| `tags` | No | All tags of a link in one cell, separated by `;` (preferred) or `,`. Max 50 characters per tag (longer ones are cut). No leading `#`. |
| `created_at` | No | Original creation date, `YYYY-MM-DD`. An unreadable value is ignored. |
| `expiration_date` | No | Date the link expires, `YYYY-MM-DD`. **Must be in the future**: a link with a past or unreadable expiration date is rejected (row Invalid), not imported as expired. If the source date has already passed, leave the cell empty and tell the user. |
| `clicks` | No | Historical click count as a plain non-negative integer (`1250`, not `1,250` or `1.2k`). |
| `rules` | No | JSON array of conditional routing rules (country, device, …). Empty = none. See [Rules and A/B tests](#rules-and-ab-tests-columns-rules-and-ab_test). |
| `ab_test` | No | JSON array of A/B variants with weights summing to 100. Empty = none. See [Rules and A/B tests](#rules-and-ab-tests-columns-rules-and-ab_test). |
| `routing_mode` | No | `rules` or `ab_test`. Only matters when both cells above are filled. See [Which one is on](#which-one-is-on-routing_mode). |

The only mandatory value is `destination_url`. A row without a valid one is skipped by the import.

## Structure of a Revolink link

Know what a Revolink link is before you map anything onto it. A link has:

- **Identity:** `domain` + `short_link` (back-half). One link = one short URL.
- **Display fields:** `title`, `tags`, `created_at`, `expiration_date`, past `clicks`.
- **A default destination:** `destination_url`. Every visitor who matches no other rule goes here. Revolink builds this "default rule" itself from `destination_url`.
- **Conditional rules (optional):** an ordered list of extra destinations, each used only for visitors who match its conditions (country, device, …). This is the `rules` column.
- **An A/B test (optional):** a list of destinations with weights; each visitor is sent to one of them at random in those proportions. This is the `ab_test` column.

How a click is resolved:

1. If the link has an **enabled** A/B test, the visitor gets a random variant by weight. **An enabled A/B test overrides everything: conditional rules are not evaluated at all.** So a link uses either its rules or its A/B test, never both at once. Which one is on is the **client's** decision, see [Which one is on](#which-one-is-on-routing_mode).
2. Otherwise the conditional rules are checked **top to bottom, in the order of the array**, and the **first rule whose conditions all match** wins. Put the most specific rules first.
3. If no rule matches, the visitor goes to `destination_url`.

Inside one rule, all of the conditions you set must match (AND). Inside one condition, any listed value matches (OR): `"country":["UA","DE"]` means Ukraine **or** Germany; `"country":["UA"],"device":["mobile"]` means Ukraine **and** mobile. A rule with no conditions never matches, so do not emit one.

## Rules and A/B tests (columns `rules` and `ab_test`)

The `rules` and `ab_test` columns hold **JSON inside one CSV cell**. Write the JSON, then put the whole cell in double quotes and **double every `"` inside it** (a CSV library does this for you; use one). Leave the cell empty when the link has none. The Revolink template's headers map these columns automatically; with other header names the user has to map them by hand in the wizard, so keep the template headers.

The whole cell is validated in the wizard (the whole JSON, every rule and variant), and every problem is shown at once as "Column "X" cannot be used for Redirect rules / A/B test (row N): reason". See [Error messages](#error-messages-and-what-to-tell-the-user).

### `rules` — conditional rules only

A JSON array of rule objects. **Never include the default rule**: it is built from `destination_url`. Do not add `id` or `isDefault` (they are ignored).

| Key | Required | Value |
|---|---|---|
| `targetUrl` | **Yes** | Full valid URL where matching visitors go. |
| `name` | No | Human-readable rule title. |
| `country` | No* | Array of ISO 3166-1 alpha-2 codes, uppercase: `["UA","DE"]`. Unknown codes fail the row. |
| `region` | No* | Array of region/state names as text, e.g. `["California"]`. Matched by exact string, not by ISO code. Prefer using it together with `country`. |
| `city` | No* | Array of city names. **Requires `country`**, and each city must be a real city of one of those countries from Revolink's list, otherwise the row fails. When unsure, drop the city and use `country` only. |
| `device` | No* | Array, allowed values: `desktop`, `mobile`, `tablet`, `console`, `smarttv`, `wearable`, `embedded`. |
| `os` | No* | Array, allowed values: `Windows`, `macOS`, `Linux`, `iOS`, `Android`. |
| `browser` | No* | Array, allowed values: `Chrome`, `Firefox`, `Safari`, `Edge`, `Opera`, `Brave`. |
| `timeStart`, `timeEnd` | No* | Both or neither, 24h `"HH:MM"` (`"09:00"`). If start is later than end, the window wraps past midnight (`"22:00"`–`"06:00"`). |
| `daysOfWeek` | No* | Array of integers 0–6, **0 = Sunday** (`[1,2,3,4,5]` = Monday to Friday). |
| `password` | No | Not validated by the import. Needs the password-protection feature of the plan. **Do not generate it** unless the client explicitly asks. |
| `locale` | No | Visitor language, e.g. `["uk","de"]`. It works at click time but the import does **not validate it**, so a wrong value is accepted silently and simply never matches. Use it only for language codes you are sure of (`uk`, `de`, `pt-BR`); the client can check them in the editor. |

\* A rule needs at least one condition to ever match. Values are case-sensitive: `iOS`, not `ios`; `mobile`, not `Mobile`.

Example cell (already CSV-escaped) with two rules:

```
"[{""name"":""Ukraine mobile"",""targetUrl"":""https://acme.inc/ua-mobile"",""country"":[""UA""],""device"":[""mobile""]},{""name"":""Germany"",""targetUrl"":""https://acme.inc/de"",""country"":[""DE""]}]"
```

### `ab_test` — variants

A JSON array of at least **2** objects `{"url":"https://...","weight":50}`. Every `url` must be a valid URL, every `weight` a positive number, and the weights must sum to **exactly 100**. The test is switched on automatically when it is the only routing cell filled, or when the client chooses it (see below). Example cell:

```
"[{""url"":""https://acme.inc/spring-sale-a"",""weight"":50},{""url"":""https://acme.inc/spring-sale-b"",""weight"":50}]"
```

`destination_url` is still required for a link with an A/B test. Use the control variant's URL.

### Which one is on: `routing_mode`

A link can have both a `rules` cell and an `ab_test` cell, but only one of them can be active. **The client decides which, not you and not Revolink.**

- If the client's data contains **both** kinds of routing for a link, fill **both** cells. Do not drop one to avoid the conflict.
- Fill `routing_mode` (`rules` or `ab_test`, case-insensitive) **only when the client's data itself says which one is on**, for example the source marks the split test as active or as paused. Otherwise **leave `routing_mode` empty**. Never guess and never choose for the client.
- Only one cell filled: leave `routing_mode` empty, no choice is needed (an A/B-only link gets the test switched on).
- An unknown value (`both`, `all`) or a mode that points at an empty cell (`routing_mode=ab_test` with an empty `ab_test`) makes the row invalid.

What Revolink does with a row that has both cells:

- `routing_mode` set: that one is on. The other is saved on the link but inactive (with `rules` chosen the A/B variants are stored with the test switched off; with `ab_test` chosen the rules are stored but do not fire).
- `routing_mode` empty: the import wizard shows the row as **Choose one**, unchecked, and the user picks **Use the A/B test** or **Use the redirect rules** (there are bulk buttons for all such rows). A row nobody decided is **not imported**: it is skipped with "Both rules and an A/B test are filled in, and neither was chosen." There is no fallback to rules. The user's choice in the wizard beats the file's `routing_mode`.

Tell the user in your summary how many links have both cells and left `routing_mode` empty, so they know to expect the **Choose one** step.

### Plan limits

The same limits apply as when a person creates the link by hand. The **default rule counts toward the rules limit**, so the number of conditional rules you may put in `rules` is the limit minus one.

| Plan | Rules per link (incl. default) | A/B variants |
|---|---|---|
| Free | 2 (1 conditional) | not available |
| Starter | 10 (9 conditional) | not available |
| Pro | 50 (49 conditional) | unlimited |
| Agency, Business | unlimited | unlimited |

The wizard counts the largest number of rules per link (default rule included) and the largest number of A/B variants per link, not sums across links, and tells the user which plan the file needs. Older Starter subscriptions may have different numbers. A/B variants of a link whose rules were chosen are stored even on plans without A/B, but the test can only be enabled where the plan allows it. If you do not know the client's plan, keep to the smallest limits you can justify and warn the user: a row that exceeds its plan is **not created** and shows up in the errors CSV. Rows using an A/B test need at least Pro; on Free only 1 conditional rule per link fits.

### What happens to an invalid cell

The `rules` and `ab_test` cells are checked already in the import preview (bad JSON, unknown country, weights not summing to 100 …): the row is shown as **Invalid** with the reason, and if it is imported anyway it is counted as **failed**. The link is **not created at all** (not even without the rules). The reason is in the errors CSV, for example `rules is not valid JSON.`, `ab_test is not valid JSON.`, an unknown country code, a city that does not belong to the country, or weights that do not sum to 100. Validate every cell yourself before handing the file over: parse the JSON, check the values against the tables above, and check the weight sum.

### Existing links are not touched

Re-importing a link that already exists (same `short_link`) updates only its title and default destination. `rules`, `ab_test` and `routing_mode` in the file are **not applied** to an existing link; to change them, edit the link in Revolink.

## Converting a client's rules and split tests

Use this recipe when the source has routing or split-test data (Bitly and Short.io geo/device targeting, Rebrandly and Dub link targeting, custom exports, etc.).

1. **Decide the type per link.**
   - Source has conditions ("if country is …, go to …") → `rules`.
   - Source has a percentage split between several URLs → `ab_test`.
   - Source has both → fill **both** columns; set `routing_mode` only if the source says which one is active, otherwise leave it empty and tell the user they will be asked. Never pick for them.
2. **Choose `destination_url`.** It is the fallback for everyone who matches nothing: the source's default/main URL. For an A/B test, the control variant.
3. **Map each conditional rule to one object** in `rules`, in the source's priority order (if the source has none, put narrower rules first):
   - country name or code → ISO alpha-2 uppercase (`Ukraine` → `UA`, `United Kingdom` → `GB`);
   - state/province → `region` with the English name as GeoIP reports it (`California`); add the `country` too;
   - mobile / phone / tablet / desktop → `device` (`phone` and `smartphone` → `mobile`); Apple/Android device families → `os` (`iOS`, `Android`);
   - browser names → the six allowed spellings;
   - schedule → `timeStart`/`timeEnd` in 24h plus `daysOfWeek` (Sunday = 0). The time zone Revolink uses for the window is not configurable in the file; if the source states a specific time zone, tell the user so they can check the window.
4. **A source rule with several values for one condition** ("UA, PL, CZ") becomes one rule with an array (`"country":["UA","PL","CZ"]`), not several rules.
5. **A source rule that needs a different destination per value** ("UA → /ua, PL → /pl") becomes one rule per destination.
6. **Split test:** if the source marks it as paused/inactive and it has rules too, set `routing_mode` to `rules`; if it marks it active, `ab_test`. Turn percentages into weights that sum to exactly 100. Three variants at 33/33/33 → `34/33/33`. If the source uses relative weights (1:1:2) scale them to 100. Drop variants with 0 weight.
7. **Conditions Revolink cannot express: drop the condition and say so.** Examples: language/locale, referrer or UTM source, IP ranges, cookies or logged-in state, connection speed, device model or OS version, first-visit vs returning, click caps per rule, custom time zones you cannot convert, deep-link/app-scheme routing, city pairs Revolink does not know.
   - If dropping a condition would make the rule apply to **more visitors** than intended (the remaining conditions are broader), **drop the whole rule** instead, and report it. Never widen a rule silently.
   - If every condition of a rule is inexpressible, the rule is dropped.
   - List every dropped rule or condition in the final summary, per link (or per group of identical links).
8. **Sanity-check the result:** every `targetUrl`/`url` has a scheme; the number of conditional rules fits the plan; no rule is a duplicate of `destination_url` with no conditions; the JSON parses after CSV unescaping.

## What to leave empty

Leave a cell **empty** (not `null`, `N/A`, `-`, `0` or a placeholder) whenever the source has no value or you are not sure:

- `title` — if the source has no title. Do not invent titles or copy the URL into it.
- `short_link` — if the source has no custom back-half. Revolink then generates one. Never fabricate slugs.
- `domain` — if the source does not say which domain the link used, or it was the source service's own default domain (`bit.ly`, `rebrand.ly`, `tinyurl.com`, `t.ly`, `short.io`, `dub.sh` …). Those domains cannot move to Revolink; an empty domain puts the link on `rvlnk.link`. Fill `domain` only with a custom domain the client owns.
- `tags`, `created_at`, `expiration_date` — when absent in the source.
- `rules`, `ab_test`, `routing_mode` — when the source link has no such feature, or you cannot express it faithfully (see below).
- `clicks` — when absent. Do not put `0` as a guess.

## Converting the source

1. Read the header of the client's file and identify, for each Revolink column, which source column holds it. Typical sources:
   - destination: `long_url`, `longUrl`, `destination`, `original_url`, `url`, `target`, `originalURL`
   - short link: `short_url`, `shortUrl`, `link`, `bitlink`, `slug`, `alias`, `key`, `path`, `back_half`
   - domain: `domain`, `hostname`, `short_domain`
   - title: `title`, `name`
   - tags: `tags`, `labels`
   - dates: `created_at`, `createdAt`, `date_created`; `expires_at`, `expiration_date`, `expiresAt`
   - clicks: `clicks`, `total_clicks`, `click_count`
2. If the source has a combined short URL (`https://acme.inc/spring-sale`, `rebrand.ly/promo`), **split it**: host → `domain`, last path segment → `short_link`. Then apply the rule above about the source service's own domains (leave `domain` empty for those).
3. If `destination_url` is missing a scheme (`acme.inc/page`), prepend `https://`. If it is something that is not a web page (`mailto:`, `tel:`, `ftp://`, `javascript:`), leave the row out of the CSV and tell the user.
4. Join multiple tag columns or a JSON array of tags into one `;`-separated cell.
5. Normalize dates to `YYYY-MM-DD`, dropping the time. Convert Unix timestamps and Excel serial numbers to real dates. If the source uses `03/04/2026`-style dates, find a value in the column that settles day-first vs month-first (a day above 12) before converting; if nothing settles it, ask the user or leave the date empty rather than guess.
6. Convert click counts to plain integers (`1,234` → `1234`, `1.2k` → `1200` only if the exact number is unknown and the user accepts the approximation; otherwise leave empty).
7. Drop rows that are not links (totals, blank lines, repeated headers).
8. Quote every cell that contains a comma, double quote or line break, and double any inner `"`. Real CSV libraries do this for you — use one instead of joining strings by hand. URLs with `,` in a query string must be quoted.

## Limits — split the file if needed

- **5,000 rows** and **20 MB** per file. If the export is larger, produce several files (`revolink-import-1.csv`, `revolink-import-2.csv`, …) of at most 5,000 rows, and tell the user to import them one after another.
- One import runs at a time per workspace.

## Pitfalls

- **Duplicates collapse.** Revolink identifies a link by `domain` + `short_link`; when `short_link` is empty, by its `destination_url`. Two rows with the same identity are treated as the same link, and an already imported link is updated (title and default destination only) instead of duplicated. If the client really wants several separate links to the same URL, each needs its own distinct `short_link`. Warn the user when you detect this.
- **Short link already taken.** A `short_link` used by *another* link on the target domain is flagged **Short link taken** and left unchecked in the wizard. If the user imports it anyway, the link gets a generated short link and the original is kept in its import details. Nothing you can fix in the file; mention it if you see obvious collisions.
- **Case-sensitive short links.** `Promo` and `promo` are different links.
- **Same value for everything.** Routing mode, Tags, Domain and Expiration date can be typed once in the wizard ("Use "xyz" for all links") and applied to every link of the import. If the client wants one domain or one tag for all links, you may leave the column empty and tell the user about this option.
- **Never put the short URL in `destination_url`.** A wrong column mapping here makes every link redirect to itself or to the wrong page. Sanity-check a few rows: the destination should be the long, real page.
- **Do not put the destination in `short_link`.** It must be a bare back-half without `/`.
- **Slug characters.** Underscores, dots, slashes, double hyphens, hyphens at the ends, non-Latin letters and emoji in the back-half are not accepted (spaces are turned into hyphens by the importer). Do not silently "clean" them into a different slug; leave `short_link` empty for that row (a generated slug is used) and tell the user which rows were affected.
- **Custom domains cost plan slots.** Every distinct custom domain in `domain` must be verified in the workspace (or is moved to `rvlnk.link`). Do not invent or normalize domains to `www.` variants; copy the exact host from the source, lowercase.
- **Tags count against the plan's tag limit.** Do not create a tag per link (for example the link's title); only carry over tags that exist in the source.
- **Excel damage.** If the client saved through Excel, check for dates turned into `45123`-style serials, URLs turned into hyperlinks or truncated, IDs in scientific notation, and a UTF-8 BOM (a leading BOM is harmless; garbled characters are not).
- **Formula injection.** A cell starting with `=`, `+`, `-` or `@` may be read as a formula by spreadsheet apps. Titles that start that way should be kept as they are in the CSV, but warn the user before they open the file in Excel.

## What the import does NOT read

Do not try to carry these over: the columns do not exist and extra columns are ignored:

- description, active/paused status, archived state
- click limits, expired-URL redirects
- conversion tracking, GPS tracking
- Open Graph / social preview fields
- QR codes, colors, appearance
- passwords in a column of their own (a password can only sit inside a rule's JSON, and only on plans that have password protection; do not generate one unless asked)
- UTM parameters as separate fields (keep them inside `destination_url`)
- rule kinds and conditions listed as inexpressible in the recipe above

Tell the user about every such piece of data you found in the source and did not carry over, so they can recreate it in Revolink.

Every imported link without a `rules` cell gets a single default rule that sends every visitor to `destination_url`. If the source has several destinations per link and you did not encode them as `rules` or `ab_test`, keep the primary one in `destination_url` and tell the user that the rest has to be recreated.

## Error messages and what to tell the user

Use this section when the user pastes a message from the import wizard or the errors CSV (`row`, `reason`; row 1 = first data row). The texts are shown in the user's interface language; the English originals are below. Explain the cause in one sentence, then give the fix. Never tell the user the row "was imported anyway": rows with these reasons are **not created**.

**File level (when the file is dropped):**

| Message | Say |
|---|---|
| Unsupported file type - expected .csv, .tsv, .xls, .xlsx or .json. | Save as one of these types. |
| The file has N rows, but one import can bring in at most 5000. | Split into files of up to 5,000 rows. |
| The file is larger than 20 MB. | Split into smaller files. |
| JSON file must be an array of link objects. | The `.json` must be `[ {...}, {...} ]`. |
| The file has no rows to import. | There must be data rows under the header (Excel: on the first sheet). |

**Columns step (after Continue), form `Column "X" cannot be used for Redirect rules | A/B test | Routing mode (row N): reason`:**

| Reason | Say |
|---|---|
| the cell is not valid JSON. | Fix quotes/brackets; in CSV wrap the cell in `"` and double inner `"`. |
| the cell must hold a non-empty JSON list. / the list must contain only objects. | Use `[ {...} ]`, or empty the cell. |
| use "rules" or "ab_test". | Routing mode must be exactly `rules` or `ab_test` (any case), or empty. |
| an A/B test needs at least 2 variants. | Add a variant or empty the cell. |
| variant N: url is required and must be a valid URL. | Full `http(s)://` address. |
| variant N: weight must be a positive number. | Weight above 0. |
| the shares of the variants must add up to 100 (now S). | Make the weights sum to exactly 100. |
| rule N: targetUrl is required and must be a valid URL. | Full `http(s)://` `targetUrl`. |
| rule N: country must be a list of 2-letter codes / unknown country code "XX". | `"country":["UA","DE"]` with real ISO 3166-1 alpha-2 codes. |
| rule N: *field* must be a list. | `"device":["mobile"]`, not `"device":"mobile"`. |
| rule N: *device / os / browser* has invalid values ... Allowed: ... | Use only the allowed values, mind the case (`iOS`, `mobile`). |
| rule N: country is required when a city is set. | Add `country` or drop `city`. |
| rule N: the city "X" isn't in our list. / the city "X" is in C, which isn't among the countries of this rule. | Fix the spelling, add the city's country, or use only `country`. |
| rule N: every region must be a non-empty text. | Remove empty items from `region`. |
| rule N: timeStart and timeEnd must both be set or both left out. / must be in HH:MM format. | Both or neither, 24h `"HH:MM"`. |
| rule N: daysOfWeek must be whole numbers from 0 to 6 (0 = Sunday). | e.g. `[1,2,3,4,5]`. |
| rule N: the main rule (the destination link) can't have conditions. | Remove the default/main rule from `rules`; it comes from `destination_url`. |
| the value isn't valid, please check it against the import guide. | Fallback: compare the cell with the schemas above. |
| The destination URL column must hold valid http(s) links. | Map the right column to Destination URL. |
| The value "xyz" you entered for *field* is not valid: ... | The "same value for all links" input is wrong: a domain name with a dot (`go.example.com`), a future date (`2027-01-01`) or `rules` / `ab_test`. |

**Links step (red Invalid / status) and the errors CSV:**

| Reason | Say |
|---|---|
| Missing or invalid destination URL. | Needs a full `http(s)://` address. |
| The title is empty ... / The title is longer than 255 characters (N) ... | Empty is fine, but a title of only spaces is rejected: type a real one or empty the cell / shorten to 255. |
| The short link may only contain letters, numbers and single hyphens (not at the start or end) ... | No `_`, dots, slashes, `--`, or hyphen at either end; or leave empty. |
| The short link is longer than 100 characters (N) ... | Shorten to 100. |
| The expiration date isn't a valid date ... | Use `YYYY-MM-DD`. |
| The expiration date (YYYY-MM-DD) is in the past ... | Use a future date or empty the cell. The link is **not** imported as expired. |
| The destination is this link itself (same domain and short code). | Put the real page in `destination_url`, not the short URL. |
| Both rules and an A/B test are filled in, and neither was chosen. | Not a defect: the user picks "Use the redirect rules" or "Use the A/B test" for the row (or fills `routing_mode`). If left undecided the row is skipped. |
| A link with this short link already exists: domain/key. (Short link taken) | Leave unchecked to skip, or import it on purpose to get a generated short link. |
| Plan limit reached / rules or A/B beyond the plan | Upgrade the plan (the Plan step shows what fits), or reduce rules/variants. Waiting links are imported automatically after an upgrade within a limited time. |

Short link collisions and already imported links are not errors: an already imported link is **updated** (title and default destination only).

## Before you hand the file over

Check and report to the user:

1. The header is exactly `title,destination_url,short_link,domain,tags,created_at,expiration_date,clicks,rules,ab_test,routing_mode`.
2. Every row has a valid `http(s)://` `destination_url`.
   - Every non-empty `rules` and `ab_test` cell parses as JSON after CSV unescaping, uses only the allowed keys and values, and (for `ab_test`) has weights summing to exactly 100.
   - `routing_mode` is empty, `rules` or `ab_test`, and points at a filled cell. It is set only where the source data decides it.
3. No `short_link` contains `/`, `_`, spaces, `--` or other disallowed characters, none starts or ends with `-`, and none is longer than 100 characters. Titles are at most 255 characters.
4. Every `expiration_date` is in the future.
5. Row count is 5,000 or fewer per file, and the file is under 20 MB. File type: `.csv` (preferred), `.tsv`, `.xls`, `.xlsx` or `.json` (an array of objects).
6. A short summary: rows in the source, rows written, rows dropped (with reasons), rows where a value was changed or left empty, duplicate keys found, which source columns were ignored, and which rules, conditions or split-test variants were dropped because Revolink cannot express them, and how many links have both `rules` and `ab_test` with an empty `routing_mode` (the user chooses those in the wizard).

Then tell the user how to import: **Workspace settings → Imports → New import**, drop the file, review the list (decide the **Choose one** rows, if any), click **Import**. Rows that fail are listed on the result screen and can be downloaded as an errors CSV (`row`, `reason`; row 1 is the first data row); after fixing them, the same file can be uploaded again. Links that already exist are updated (title and default destination only), not duplicated, and never get their rules or A/B test from the file.
