Skip to main content

Bulk Import

Wayd can load Product Management data in bulk from CSV files through a set of import endpoints. These are useful for standing up a catalog, migrating from another tool, or seeding realistic data. Each import is a single POST of one CSV file (multipart/form-data, field name file), and each is gated by a permission.

Screen or API

Every import described here can be run from Settings → System → Imports, which lists each file's columns and hands over a template, taking the same CSV described here. The endpoints can also be called directly — with curl, a script, or anything else — and their exact request/response shapes are in the API reference the API serves at /scalar.

How imports work​

A few rules apply to every import:

  • References are by name where a name identifies one record, otherwise by id. Product types, statuses, tags and environments are referenced by name — each is unique within its own scope. Products, versions and packages are referenced by id: their names and version strings are not uniquely indexed, so a label may match more than one record. A run reports the id of the record each applied row created, so one import's results are what the next file references.
  • Every record an import creates is attributed to the import. The record carries the account that ran it as the origin, with the mechanism recorded as Import rather than as that person editing each row by hand. In a record's Status History the entry names the account that ran the import; in its Activity the actor is labelled Import. A change made by a signed-in person names their linked employee instead, and one whose author is no longer linked to an employee record shows as Unknown. Nothing here is attributed to System: that label is reserved for the platform acting on its own behalf, which no import or product management action does.
  • A successful post returns the run, not the records it created. Every file is applied by a background worker, and the post waits a few seconds for it: a small file comes back finished (200 OK) with its counts, and a large one comes back still queued or running (202 Accepted) with a Location to follow. Either way the body is the same run, and its page under Settings → System → Imports shows what became of each row. Importing from the app tells you the outcome in the dialog, and takes you to that page when a file is still running.
  • A file can be checked before it is imported. Add validateOnly=true to the post, or choose Check File in Settings → System → Imports, and every row goes through the import's own checks without anything being created: each comes back passed or rejected with its reason, and a row kept out only because another row in its group was rejected is reported the same way the real run would. It is advice, not a promise — the data can change before you import — and a finished check can be imported for real from its page without posting the file again.
  • Files posted together can be grouped. Every import endpoint takes an optional submissionGroupId query parameter — any GUID you choose — and records it on the run, so a set of files posted under one value shows as one batch in Settings → System → Imports, with summed counts and each run's page linking to the others. Leave it out to submit a file on its own. An empty GUID is rejected rather than treated as a group.
  • ImportId is yours, and its cells may be blank. Any value you like — a row number, a key from whatever system the file came out of — provided it is unique within the one file, compared case-insensitively. Row outcomes are reported against it, rows reference each other by it (a child product names its parent's, a manifest line its package's), and leaving it blank uses the row's position instead. It is never written onto the records the import creates.
  • Group by group, or row by row. A rejected row — a reference that does not exist or is inactive, a duplicate, or something the domain refuses — keeps out its own group and nothing else, and the run names the row and the reason. What a group is differs by import; deployments have none and apply row by row. See Limits.
  • Every column must be present, even the optional ones. Required below means the cell needs a value; it does not mean the column can be left out. A missing header fails the file with Header with name 'X' was not found. Include every column and leave unused cells blank.
  • Multi-value columns are semicolon-separated. A comma would collide with the CSV field delimiter.
  • Dates are ISO yyyy-MM-dd. A date column takes a date, not a timestamp: a cell carrying a time is rejected rather than truncated. The columns that are genuine moments — a version's CutAt and ReleasedAt, a package's ReleasedAt, a deployment's timestamps — say so, and carry their offset: see timestamps.
  • Names are matched case-insensitively and surrounding whitespace is ignored, so Application and application resolve to the same type.
  • A missing file returns 422, not 400 — the file is a validation failure like any other.

Imports create records through the normal domain rules, exactly as if you had used the app — so the same constraints apply.

Import order​

Because records reference each other, imports must be run in dependency order:

Product types and tag categories (seeded, or created beforehand)
→ Products
→ Dependencies
→ Versions
→ Release packages
→ Releases

Environments (created under Product Management → Environments, or imported)
→ Deployments (after versions and packages)

Reference data comes first. Product types are seeded, and any others are added through the product types API — they have no screen — while tag categories and tags are managed under Settings → Product Management → Product Tags; neither is imported. Create anything the file references there first — a type or tag that does not exist, or that has been deactivated, fails the batch rather than being created on the fly. Environments are reference data too, but they can be imported, because a historical backfill of deployments routinely names environments that have since been retired, and defining those by hand one at a time is the tedious part.

Each step then needs the one above it:

ImportNeedsWhy
ProductsTypes, tagsA product's type must exist and be active.
DependenciesProductsBoth ends are named by id.
VersionsProductsA version is cut against a product, named by id.
Release packagesProducts, versionsA manifest line names a product by id; where a version record matches its version string, the line is linked to it.
ReleasesPackages, versionsA release announces them, and cannot announce what does not exist.
Environments—Names must not already exist.
DeploymentsEnvironments, versions, packagesA deployment names its environment by name and the version or package it deployed by id.
Releases are last, and their contents must have shipped

A release marked with a ReleasedDate is refused while anything it carries has not shipped. This is the one cross-record rule in Product Management, and it is why releases come last.

So a released release needs its versions and packages imported first with their own released moments. Importing them unreleased and releasing them later leaves the release unimportable in between. A succeeded production deployment imported before the release also supplies those moments. See announcing a release.

Unlike PPM, there is no finalize pass. PPM imports historical programs active and closes them afterwards, because a program only accepts projects while active. Nothing here works that way: a version can be marked released without ever being cut, a package or release simply carries its released moment or date, and a deployment is walked to its outcome with the timestamps its row carries. Every record reaches its final state in one pass.

Permissions​

ImportPermission
ProductsImport Products
DependenciesImport Products
VersionsImport Delivery
Release packagesImport Delivery
ReleasesImport Releases
EnvironmentsImport Deployment Environments
DeploymentsImport Delivery

Limits​

Every import here applies group by group, except deployments, which apply row by row: the rows for one group go in together or not at all, and a rejected row keeps out only its own group. The other groups are still imported, and the run finishes as Partially succeeded with the reason on each row it refused. A row kept out only because another row in its group was rejected says so and names that row, so the fix is always to one row.

ImportA group isOne rejected row keeps out
Productsone tree — a top-level product and everything under itthat whole tree
Dependenciesone product, with every link it holdsthat product's links
Versionsone productthat product's versions in the file
Release packagesone package, with its manifestthat package
Releasesone release, with its contentsthat release
Environmentsone environmentthat environment
Deployments— (row by row)that deployment

Products group by the tree because a child names its parent's ImportId in the same file, and a child whose parent was rejected has nowhere to hang. One rejected product therefore keeps out its siblings as well as its descendants. Versions group by the product so the file's history for a product goes in whole, never with a gap where one version was refused. A package's manifest and a release's contents are read onto their row before the run starts, so they always go in with it.

Every import here except dependencies accepts at most 10,000 rows per file; dependencies accept 50,000. For the two-file imports (packages with their manifest, releases with their contents) the cap applies to the first file; the second is read alongside it. A file over the cap is rejected before anything is queued, with a message naming the cap. Applying group by group saves in stages rather than in one transaction, so the cap is not what bounds a run; it is held where it is until a larger file has been proven end to end.

Splitting a larger file follows the same grouping: split a products file on top-level products, keeping each tree whole, and a versions file on product boundaries. Post the parts one at a time, waiting for each run; one submissionGroupId across them keeps the runs findable as one import.

The rules every file shares — request size, ImportId length, how long the post waits, retries, stalls and retention — are in Settings → System → Imports → Limits and rules.


Products import​

POST /api/product-management/products/import

Loads the product catalog — the whole tree in one file, parents and children together.

It loads the tree, which is what a product is made of. The dependencies between products — what relies on what — are loaded by their own dependencies import, after this one.

ColumnRequiredNotes
ImportIdNoThe row's key within this file only. Never stored. Children name it as their ParentImportId, so a file with a tree should supply it rather than relying on row position.
NameYesMax 128 chars. Need not be unique — see names repeat.
DescriptionNoMax 1024 chars.
ProductTypeNameYesThe type by name. Must exist and be active.
ParentImportIdNoThe ImportId of another row in this file. Empty makes the product a root.
ExternalIdNoThe product's identifier in the system that owns it. Max 256 chars.
StatusNoA product status by name. Defaults to the workflow's initial status.
TagsNoSemicolon-separated Category|Tag pairs — see tags.

Names repeat, import ids do not​

A product tree legitimately holds the same name in more than one place. A catalog with Platform A → API and Platform B → API is perfectly ordinary, so names cannot identify a row.

Each row therefore carries its own ImportId, and children point at their parent's:

ImportId,Name,Description,ProductTypeName,ParentImportId,ExternalId,Status,Tags
1,Platform A,,Platform,,,,
2,Platform B,,Platform,,,,
3,API,,Service,1,,,
4,API,,Service,2,,,

Every column appears even where it carries nothing, because a missing header fails the file.

The import id is a label for this file and nothing else. It is not stored, it does not appear in the app, and it has no relationship to the product Key Wayd assigns. Any text works — 1, PL-1, a.2 — as long as it is unique within the file.

Rows can be in any order​

A child may appear above its parent. Wayd orders the batch so every parent is created first, so a file exported from somewhere else can be imported as-is without sorting it.

A parent reference that points in a circle — two rows naming each other, directly or through a chain — fails the batch.

Parents must be in the same file​

ParentImportId can only name another row in the file being imported. A product already in the catalog cannot be named as a parent.

This import is for standing a catalog up, not for grafting single products onto an existing one. To add a product beneath one that already exists, use Add Product on that product's page, which is quicker than authoring a file for it.

Tags​

The Tags column holds semicolon-separated pairs, each naming the axis and the tag:

Platform|ios;Platform|android;Compliance|pci-scope

The axis travels with each tag because a tag name is unique only within its axis — two axes can each hold a gold, so gold alone identifies nothing. This is also how tags read in the app, where they display as Category | tag.

Both halves must exist and be active. An unknown category, an unknown tag on that category, or an inactive one fails the batch.

An axis that allows one value

Where a category allows only one tag, a row naming two from that axis keeps the last one. The domain treats a second tag on a single-value axis as a correction rather than an error — "this is a mobile app, not a web app" — and the import inherits that behaviour rather than refusing.

Status​

Status names a status from the product workflow, matched case-insensitively. A status belonging to some other workflow is refused, so a typo cannot quietly place a product in an unrelated state.

Leave the cell blank for the workflow's initial status, which is what a product created in the app starts in. The column itself still has to be there.

Attribution​

Every product an import creates is attributed to the import, carrying the account that ran it — see how imports work. That is distinct from a person creating products one at a time: the product's status history names the account that ran the import, and its Activity labels the actor Import rather than presenting that person as having edited each row by hand.

Running it twice​

The import is additive and does not check whether a product of the same name already exists — names legitimately repeat in a tree, so it cannot. Running the same file twice creates a second copy of everything in it.

That matters after a partially succeeded run: the trees that went in are saved, so posting the corrected file again duplicates every one of them. Fix and resubmit only the rejected trees, or retry them from the run's page in Settings → System → Imports.

Dependencies import​

POST /api/product-management/products/dependencies/import

Loads which products rely on which — a relationship export from a service catalog or CMDB, say, or a map kept in a spreadsheet. Each row is one dependency: ProductId relies on DependsOnProductId.

ColumnRequiredNotes
ImportIdNoThe row's key within this file. Outcomes are reported against it.
ProductIdYesThe product that has the dependency, by id.
DependsOnProductIdYesThe product it relies on, by id. Cannot be the same product, or anything above or below it in the tree.
StrengthYesHard or Soft — see strength. There is no default.
InteractionStylesNoSynchronous, Asynchronous, or both separated by a semicolon — see interaction. Blank records nothing, which is not the same as recording that there is neither.
DescriptionNoWhat it is relied on for. Max 1024 chars.
StartsOnNoyyyy-MM-dd. The day it began. Blank means today. Cannot be in the future.
EndsOnNoyyyy-MM-dd. The last day it held. Blank means it still holds. Cannot be before StartsOn — or before today, when StartsOn is blank — or in the future.

Record the most specific product you know — the service, not the platform it belongs to. The platform's own lists include everything beneath it, as the screens explain.

Product by product​

The import applies one product at a time: every row whose ProductId names the same product is saved together or not at all, and the other products are still imported. A product's links are never half recorded — a strength change whose ended half went in without its new half would leave a gap in its history. A row kept out only because another row for the same product was rejected says "Not applied: another row for the same product was rejected", naming that row, so the fix is always to the one row. Retrying the failed rows from Settings → System → Imports puts each rejected product through again.

History is rows with dates​

A dependency that has stopped is a row with an EndsOn. One whose strength changed is two rows on the same pair, the first ending the day before the second starts — the way the screens record Change Terms:

ProductIdDependsOnProductIdStrengthStartsOnEndsOn
Storefront WebSearch ServiceSoft2025-02-012026-03-31
Storefront WebSearch ServiceHard2026-04-01

(Ids shown as names for readability.) A product's rows are applied in date order whatever order the file lists them in. Two rows on the same pair cannot overlap, and a pair can have only one open dependency.

Running it twice​

A second run of the same file is refused rather than duplicated: every row overlaps the dependency the first run recorded. A product whose rows failed the first time applies once they are fixed.

Versions import​

POST /api/product-management/versions/import

Loads versions against products that already exist. Import the catalog first — a version names its product by id, which that run's results report, and an unknown one fails the run.

ColumnRequiredNotes
ImportIdNoThe row's key within this file only. Never stored. Outcomes are reported against it.
ProductIdYesThe product this version was cut against, by id. Its type must be releasable.
NumberYesThe version as you write it — 4.8.2, 2026.04. Free text, never parsed. Max 64 chars.
NameNoAn optional human name. Max 128 chars.
TargetDateNoyyyy-MM-dd. When it is expected to ship.
CutAtNoAn ISO-8601 timestamp with its offset — 2026-09-18T02:30:00Z or 2026-09-17T21:30:00-05:00. The build or tag that froze scope; copy it from the CI/CD system as-is. A value with no offset is refused. Supplying it makes the version Ready.
ReleasedAtNoAn ISO-8601 timestamp with its offset, as for CutAt. When production received it. Supplying it makes the version Released. Cannot be before CutAt.
SequenceNoA manual ordering override, for backports where chronology misleads.
NotesNoEngineering notes for this version.

The dates decide the status​

There is no status column. What you supply decides where each version ends up, and Wayd applies the same steps in the same order it would have if you had recorded each one as it happened:

Dates suppliedResulting status
NeitherPlanned
CutAt onlyReady
CutAt and ReleasedAtReleased
ReleasedAt onlyReleased

That last row is deliberate. A version can be marked released without ever having been cut — a shipment recorded after the fact often has no record of when scope froze, and demanding one would make historical import impossible.

Because the transitions are replayed rather than a final status assigned, an imported version has the same status history a hand-entered one would.

A version is identified by its product and its number​

Version strings are only meaningful within one product, so two products may each hold a 1.0.0 and both import fine. What is refused is the same number twice for one product, whether within the file or against a version that already exists — that is a mistake rather than an ordinary repeat, so re-running a file is refused instead of silently duplicating shipments.

This is the opposite of products, where names legitimately repeat and no such check is possible.

Release packages import​

POST /api/product-management/release-packages/import

Loads release packages and their manifests. This import takes two files, because a package cannot exist without a manifest — an empty one says nothing about what shipped, and the domain refuses it.

Post them together as file (the packages) and manifestFile (the lines). In the app both dropzones appear in the same dialog.

The packages file​

ColumnRequiredNotes
ImportIdNoThe row's key within this file only. Never stored. Manifest lines name it as their PackageImportId, so supply it rather than relying on row position.
VersionYesThe package's own version — WAYD-2026.09. Free text, never parsed. Max 64 chars.
NameNoAn optional human name. Max 128 chars.
TargetDateNoyyyy-MM-dd. When it is expected to ship.
ReleasedAtNoAn ISO-8601 timestamp with its offset — 2026-09-18T02:30:00Z or 2026-09-17T21:30:00-05:00. When the pipeline run that shipped it completed; copy it from the CI/CD system as-is. A value with no offset is refused. Supplying it makes the package Released.

A package has no product — it spans them — so its version is its only identifying field, and a version repeated within the file or already in Wayd fails the batch.

There is no cut step: a package is assembled and then ships, so a row with no released moment is assembled and one with a released moment has shipped.

The manifest file​

ColumnRequiredNotes
PackageImportIdYesThe package this line belongs to, matching an ImportId in the first file.
ProductIdYesThe component product, by id.
VersionNumberYesThe component's version in this package. Free text. Max 64 chars.
KindYesChanged or CarriedForward (case-insensitive).

Every line must find its package. A line naming one that is not in the packages file is a mistyped reference rather than a line to skip, so it fails the whole file before anything is queued. A component may appear only once per manifest — one component cannot ship at two versions in one box.

An unmatched version string is not an error​

VersionNumber is recorded as text, and linked to a version record only where one exists for that product. Where none does, the string stands on its own.

That is deliberate rather than a gap. A carried-forward component was often already running and was never cut in Wayd at all, and recording the string regardless is what lets a manifest answer what was running on this date for every component rather than only the changed ones.

Because a version number is only unique within its product, the match is made on both — two products may each hold a 4.10.0, and each line links to its own.

Releases import​

POST /api/product-management/releases/import

Loads releases — what was announced to customers, as distinct from what was built. Takes two files: file for the releases, and an optional contentsFile for what each one announces.

The releases file​

ColumnRequiredNotes
ImportIdNoThe row's key within this file only. Never stored. Contents rows name it as their ReleaseImportId, so supply it rather than relying on row position.
VersionYesThe release as you announce it — 2026.07. Free text, never parsed. Max 64 chars.
NameNoAn optional human name. Max 128 chars.
ProductIdNoThe product it is announced under, by id. Usually a product line.
TargetDateNoyyyy-MM-dd. When it is expected to be announced.
ReleasedDateNoyyyy-MM-dd. Supplying it announces the release — see below.
SequenceNoA manual ordering override.
NotesNoProduct notes, written for customers.

ProductId is optional by design. A release spanning product lines has no single owner, so requiring one would force a misleading choice — and a release identified partly by product would be unresolvable for exactly the releases the model exists to allow. A release is therefore keyed on Version alone, and a version repeated within the file or already held by a release in Wayd is refused, so re-running a file does not duplicate announcements.

The contents file​

Optional. Omit it entirely for releases that announce nothing — an empty release is a legitimate state, not a draft: a repackaging or a pricing change is announced with nothing deployed.

ColumnRequiredNotes
ReleaseImportIdYesThe release this row belongs to, matching an ImportId in the first file.
KindYesPackage or Version (case-insensitive).
PackageIdConditionalRequired when Kind is Package. The package, by id.
VersionIdConditionalRequired when Kind is Version. The version, by id.

Both routes share one file because the rule below spans them. Both are named by id — the ids the versions and release packages runs report.

A version is announced once, by one route

A version shipping inside one of the release's packages cannot also be carried directly. Otherwise one shipment would be announced twice and what did 2026.07 contain would have two answers.

Unlike a package manifest, where an unmatched version string is the carried-forward case and perfectly legitimate, a release's contents must resolve. A release announcing something Wayd does not hold is a mistyped reference rather than a record of history, so it fails the batch.

Announcing a release​

Supplying a ReleasedDate announces the release — and is refused while anything it carries has not shipped. Telling customers that 2026.07 is out while a version inside it has not gone anywhere is the one claim a release can make that its own contents contradict.

The import refuses the batch rather than quietly importing the release unannounced, and the error names what is holding it back:

Release '2026.07' is marked as released but carries content that has not shipped:
'4.12.0'. Import those with their ReleasedAt first, or leave this release's
ReleasedDate empty.

That is the practical reason releases come last, and why the versions and packages they carry need their own released moments in the same run.

Environments import​

POST /api/product-management/deployment-environments/import

Loads environments, the targets deployments are recorded against. This is the one piece of reference data with an import, because a historical backfill of deployments names every environment that ever existed — including the ones long since decommissioned — and defining those by hand is the tedious part.

ColumnRequiredNotes
ImportIdNoThe row's key within this file only. Never stored.
NameYesWhat the organization calls it — Production, prod-eu, QA2. Max 64 chars. Must not already exist.
CategoryYesDevelopment, Testing, Staging, Production or Other (case-insensitive). What every production-scoped measure counts on.
RingOrderYesPosition in a progressive rollout, lowest first. Zero or more.
IsActiveNoBlank or true leaves the environment in use. false creates it and then retires it — see below.

A name is the key​

Environment names are unique across the organization, which is what lets a deployment row name its environment rather than carry an id. A name repeated within the file, or one that already exists, fails the batch. There is no update path: an environment that exists is edited under Product Management → Environments.

Retired environments are importable​

An environment marked false is created and then retired through the same transition Retire on the Environments page would make, so the retirement is recorded as an event the way one made by hand would be. It is not offered as a deployment target, but a deployments import can still record finished deployments against it — which is the reason to import it at all.

Deployments import​

POST /api/product-management/deployments/import

Loads deployments — one version or package reaching one environment, with its start, its outcome and when it got there. Import the environments, versions and release packages first: a deployment names all three, and an unknown one fails the run.

ColumnRequiredNotes
ImportIdNoThe row's key within this file only. Never stored.
VersionIdConditionalThe version deployed, by id. Exactly one of this and PackageId.
PackageIdConditionalThe package deployed, by id. Exactly one of this and VersionId.
EnvironmentNameYesThe environment reached, by name. A retired environment is accepted only for a row that has an outcome.
ArtifactIdNoThe build that actually shipped — 4.8.2.008 where the version number is 4.8.2. Free text, never parsed. Max 128 chars.
StartedAtYesWhen the deployment began. An ISO-8601 timestamp with its offset — see timestamps.
OutcomeNoSucceeded, Failed or RolledBack (case-insensitive). Blank leaves the deployment in flight.
CompletedAtConditionalRequired with an outcome, refused without one. When it succeeded or failed; for a rollback, when it succeeded. Cannot be before StartedAt.
RolledBackAtConditionalRequired for RolledBack, refused for anything else. Cannot be before CompletedAt.
ReasonNoWhy it failed or was rolled back. Max 1024 chars.

The outcome decides the status​

There is no status column. Outcome says what happened, and Wayd applies the same steps, in the same order, that recording it as it happened would have:

OutcomeSteps replayedResulting status
BlankStartedIn Progress
SucceededStarted, succeeded at CompletedAtSucceeded
FailedStarted, failed at CompletedAtFailed
RolledBackStarted, succeeded at CompletedAt, rolled back at RolledBackAtRolled Back

A rollback goes through success first because the domain permits a rollback from nowhere else: a failed deployment never reached its environment, so there is nothing to revert. That is why a rolled-back row carries both timestamps — the completion is when it went live, and the rollback is when it was pulled.

Because the transitions are replayed rather than a final status assigned, an imported deployment has the same status history a hand-recorded one would, and counts toward deployment frequency and change failure rate exactly as it would have. The statuses come from the deployment workflow by meaning, so renaming an outcome does not break the import.

Timestamps carry their offset​

A version's CutAt and ReleasedAt, a package's ReleasedAt, and a deployment's StartedAt, CompletedAt and RolledBackAt are instants, not dates, and each must say what zone it is in: 2026-03-01T14:30:00Z, or 2026-03-01T09:30:00-05:00. A value with no offset is refused with a 422 naming the row, rather than read in the server's zone — which would shift every historical record by whatever that zone happens to be, and by a different amount on a different host.

A version or a package, never both​

A deployment carries either a version or a package, for the reason given in Release Packages: where a package exists it is the unit that shipped, so one pipeline run counts once rather than once per component. A row naming both, or neither, is refused before the run starts.

Rows name their version or package by id. A build number is never resolved to a version: 4.8.2.005 belongs to 4.8.2 by a convention that differs between organizations and between products, and that rule lives in adapter configuration rather than in this import. Put the build in ArtifactId, where it is kept as text.

Retired environments​

A row with an outcome may name a retired environment, because a backfill routinely records deployments into environments that no longer exist. A row still in flight may not — nothing can be deploying into a target that is no longer deployed into — and meets the same refusal starting one by hand would.

Each deployment freezes the environment's category as it stands when the file is imported. What the environment counted as when a historical deployment actually ran is not recoverable, so an environment reclassified since then will have its past deployments counted under today's category. Import deployments before reclassifying, where that matters.

A production deployment marks what it shipped as released​

A row that succeeded — or succeeded and was then rolled back — into an environment whose category is Production marks what it shipped as released at its CompletedAt, as recording the success by hand does: its version, or its package and every version that changed in that package. A released moment already recorded is never replaced, and a withdrawn version or package is left alone. So import deployments oldest first, or the earliest production success in the file will not be the one that sets the moment. Rows into any other category mark nothing released.

Running it twice​

A deployment has no natural key — two builds of one version reaching one environment are two deployments — so the import is additive and cannot tell a re-run from a second shipment. Running the same file twice records everything in it again.

That matters after a partially succeeded run: the rows that went in are saved, so posting the corrected file again duplicates every one of them. Fix and resubmit only the rejected rows, or retry them from the run's page in Settings → System → Imports.