File Profiling

POST /ingestions/profile reads an uploaded file and reports what is in it. It ingests nothing: no ingestion task, no staged locations, no stored file, nothing in your history.

Use it to answer "what does this file actually contain, and can you place it" before committing to a run. A supplier sends you a spreadsheet; you do not necessarily know whether it holds plots or facilities, boundaries or points, or whether the coordinates are usable. This tells you, row by row.

It returns no verdict. A row is described, never rejected. There is no compliance judgement anywhere in the response β€” that is what the assessment endpoints are for. Every assessment toggle is deliberately absent from this surface, and passing one is a 400 rather than a silent no-op, so you can never believe you asked for screening and got none.

Quickstart

POST /ingestions/profile?limit=25
Accept: application/geo+json
Content-Type: multipart/form-data

file=@supplier-plots.geojson

Accepts CSV, TSV, Excel, JSON, GeoJSON, GeoPackage and zipped Shapefile, up to 100 MB. An unrecognised file type is a 415.

Response shape

A GeoJSON FeatureCollection with a non-standard top-level properties member. A spec-compliant FeatureCollection has no such member, but that block carries the file-level aggregates and is the point of the endpoint.

{
  "type": "FeatureCollection",
  "properties": {
    /* file-level aggregates β€” always complete */
  },
  "features": [
    /* per-row detail β€” best first, capped by `limit` */
  ],
}

The two halves answer different questions. properties describes the file and is never truncated, whatever limit you pass. features is the per-row detail and is capped, ordered most-precise-first.

properties β€” the file-level read

FieldMeaning
rows_profiledRows the profiler classified, after any geometry cleanup.
rows_locatedRows carrying a measurable footprint.
rows_needing_a_decisionRows whose kind the file does not settle (see Decisions).
distinct_locationsDistinct geometries, not rows. Repeated deliveries from one plot are one location.
looks_like_location_datafalse when the file is not location data at all β€” an invoice, a shipment list with no origin. A different message to the customer than "your export is broken".
groupsOne entry per detected kind, ordered by rank: count, geometry types, area quantiles, footprint, what it is measured on, which checks its footprint makes possible, and any decision.
columnsPer-column populated counts and fill rates. A column existing and a column holding data are different facts.
coordinatesCRS, null-island count, out-of-range count, certain axis swaps, and observed lat/lon ranges.
duplicatesRepeated rows, geometries shared by rows, and columns that could not be compared (a column holding objects or arrays cannot be hashed).
boundsBounding box of everything placed.
truncationrows_in_file / rows_read / truncated / max_features. Emitted on every load, so you never have to infer whether a read was complete.
field_coverageWhich EUDR statement fields the file carries, where, and whether every DDS needs each (see Statement field coverage).
detectedWhat was read out of the file: area_column, area_unit, area_detection, facility_leaning, identity.
appliedWhat shaped the classification β€” your arguments and how they resolved: location_type, commodity_type + commodity_supported + commodity_warning, country + country_resolved, eudr_lens, area_band_ha, sourcing_area_band_ha.
commodity_column_reportThe column the commodity was read from, and what it held (see The commodity column). Absent when no column is used.
eudr_payloadPrefill for a Due Diligence Statement. Only under the lens.

area_detection says how the area column was resolved, and each value is a different fact: resolved, no_area_column_found (we looked, your file has none), detector_unavailable (ours, not yours), detected_column_absent, and not_needed_no_point_rows. The last one is not a failure β€” a declared area only changes how a point is measured, so on a file of boundaries or addresses the column is never resolved and nothing in the profile depends on it.

Two row counts, on purpose

rows_profiled and truncation.rows_read are different quantities and are named apart so they cannot be confused:

  • truncation.rows_read β€” rows the loader returned.
  • rows_profiled β€” rows the profiler classified, after geometry cleanup.

They can differ after geometry cleanup changes the row count. The difference is not a count of rejected locations.

detected vs applied

detected is a fact about your file. applied is what you asked for and how it resolved. Keeping them apart means you can always tell "my file has no area column" from "the value I passed did not resolve".

features β€” the per-row detail

Each feature is your original row, with its geometry, plus:

FieldMeaning
detected_kindThe row's kind.
detected_rank1–8. Lower is a more precise footprint.
measured_onWhat this row's measurements would be taken on.
measured_area_haMeasured polygon area in hectares; null for anything not a polygon.
detected_notesAnything notable about the row, e.g. an unsupported geometry type.
supplier_unitWhich supplier unit this row was attributed to, matching the unit of an entry in supplier_clusters.clusters and of an entry in supplier_units.units. null where the row could not be attributed to one β€” it is not on the map, or the file states no CRS.

Data categories

Every row is classified on its own evidence. A mixed file is the common case, and reporting one verdict for the whole upload is how a customer misreads their own data.

checks_possible is not a compliance statement. It lists what can be measured on that geometry β€” before any measurement has run. A row with forest_loss in the list has not been checked for forest loss; it is merely capable of being checked.

The ranking is by measurable footprint β€” how precisely the row locates a thing on the ground, which is what decides the measurements it can carry. It is a capability ordering, not a quality grade: a mill at rank 4 is not a defective plot, it is a different kind of thing supporting different work. For cattle, a facility point is the required geolocation rather than a substitute.

RankKindMeasured onChecks possible
1plot_boundaryprovided_boundaryforest_loss, protected_area_overlap, land_cover_change, commodity_presence
2point_with_areadeclared_area_squareforest_loss, protected_area_overlap, land_cover_change, commodity_presence
3point_no_areanoneβ€”
4facilitypoint_catchmentsite_forest_cover, catchment_deforestation, port_proximity, water_access, building_density
5sourcing_areaprovided_areaarea_deforestation, land_cover_change
6address_onlynoneβ€”
7point_large_areadeclared_area_squareforest_loss, protected_area_overlap, land_cover_change, commodity_presence
8unusablenoneβ€”

What each one means:

  • plot_boundary β€” a polygon under the catchment band. The strongest thing a file can carry: measurements are taken on the boundary you supplied.
  • point_with_area β€” a point that states its own size. Measured on a square derived from the declared area.
  • point_no_area β€” a bare point with no area resolved and nothing indicating a facility. measured_on is none because the file supplies no footprint and inventing a square the customer never gave us would make the read untrue.
  • facility β€” an aggregation or processing site. Measured on a catchment around the point, and it supports a genuinely different set of checks rather than a reduced one. A facility that supplied its own boundary is measured on that boundary rather than on a catchment inferred from a point.
  • sourcing_area β€” a polygon at or above 100 ha. A catchment rather than a plot.
  • address_only β€” place text but no coordinates. Nothing can be measured until it is geocoded.
  • point_large_area β€” only under the EUDR lens. A point declaring an area at or above the band (4 ha by default), where the regulation requires a polygon. Without the lens there is no band, and a point that states its size is simply point_with_area whatever the number.
  • unusable β€” no footprint and no address.

So the core read sees seven kinds; the EUDR lens sees eight.

Footprint and identity

kind answers two questions at once, and they are not equally knowable:

Where it comes fromHow certain
Footprint β€” boundary, point_with_declared_area, point, nonethe geometry, plus any area the file declaresestablished
Identity β€” is this a production plot or a site?the column names, or location_type if you supply itinferred

Geometry settles the footprint outright and cannot settle the identity at all. A boundary tells you a site was surveyed; it says nothing about whether the thing inside it is a farm or a mill. Both are now reported in their own right β€” footprint on every group, identity under detected β€” while kind stays exactly as it was.

identity.basis says how the answer was reached:

BasisMeaning
declared_by_calleryou passed location_type; see applied for the effective type
column_evidencethe column names name one and not the other
conflicting_column_evidencethey name both, and nothing here can rank one over the other
no_evidencethe columns name neither

conflicting_column_evidence lists the columns in identity.conflicts. This is the case a boolean could not express: a file that names both a facility and a plot is not the same as one that says nothing, and treating them alike silently picked plot.

Why basis and not a confidence score

A caller building an upload-review step wants to know what to render. A number does not answer that: 0.5 cannot distinguish "the columns argue both ways, ask the user" from "the columns are silent, guess and let them correct it", and those need different screens β€” the first is a disambiguation prompt naming the conflicting columns, the second is a pre-selected default.

So basis is an enum of the four situations that actually occur, and each maps to one behaviour:

identity.basisWhat the caller should do
declared_by_callerUse the effective type reported under applied
column_evidenceState the result. Zero clicks, override still available
conflicting_column_evidenceAsk. identity.conflicts names the columns that disagree, and the group's decision carries the options
no_evidencePre-select the default and show it as a guess, not a fact

There is no numeric confidence and none is planned. If you need a score for telemetry, derive it from the basis at the call site rather than asking this endpoint to flatten four distinct states into one axis.

Decisions the file does not settle

Some rows are questions the file cannot answer. The profile states the situation and the options; it does not pick one.

KindReasonOptions
point_no_areano_area_attribute_resolvedassume_square (needs area_ha) Β· treat_as_facility
address_onlyno_coordinatesgeocode
point_large_areapoint_states_a_large_areasupply_boundary (needs geometry) Β· keep_as_point
any boundary, when identity.basis is conflicting_column_evidenceidentity_not_settled_by_columnstreat_as_plot Β· treat_as_facility Β· resolve_with_imagery (needs validate_locations)

The first three are about the footprint and depend only on the row. The last is about the identity and depends on the file as a whole, so it attaches to every boundary group in a conflicted file.

resolve_with_imagery names validate_locations because it is the only thing that can settle it. This endpoint reads columns and geometry and does so cheaply; when those disagree, deciding needs the satellite imagery check, which is what validate_locations runs. Passing location_type resolves it too, and costs nothing.

Where a row has both an open footprint question and an open identity question, the footprint one is reported: a missing area is more specific and more actionable than the identity question sitting behind it.

rows_needing_a_decision counts every row carrying any of these.

Statement field coverage

field_coverage reports which of the 14 EUDR Due Diligence Statement fields your file carries, and in which column. Keyed by the DDS statement field names, so you can line the report up against what you will eventually have to send.

Three states, not two:

  • found β€” the column is named.
  • absent β€” we looked, and your file has none.
  • undetermined β€” we could not look, or nothing detects this field yet.

The third state matters. A detector returning nothing otherwise conflates "we looked and your file has no producer column" with "we could not look". The first is a fact about your data, the second about our service, and reporting the second as the first is a lie.

Every entry also carries required β€” whether every DDS needs it. That is our own list, not a reading of the regulation, and it separates the core fields from the rest. Filing a DDS can ask for more, such as the operator's EORI and phone. Nine of the sixteen fields are required:

operator_name, operator_address, operator_email, operator_country, product_type, product_quantity, hs_code, production_country, supplier_name.

Two of those β€” operator_name and operator_address β€” are required and undetectable from a location file (a location file does not describe the operator placing goods on the EU market). operator_email and operator_country likewise. They report undetermined with reason: no_detector and required: true, so you can see up front that you will be asked for them.

plot_area is resolved on its own pathway rather than by the schema detector, and it reports that pathway's outcome. Where the area column was never looked for β€” no point rows to measure, or the detector unavailable β€” it reports undetermined carrying the same area_detection value, not absent, which would claim a polygon file states no area when nobody asked.

Schema detection only runs for CSV, XLS and XLSX. A GeoJSON, GeoPackage or shapefile upload therefore reports every field as undetermined β€” nothing was looked at, and saying absent would claim otherwise. species_common_name, species_scientific_name, operator_name and operator_address are always undetermined (reason: no_detector), listed so the report says "we did not look" rather than omitting them, which would read as absent.

The commodity column

The profiler and validation read each row's commodity from one column and report what they found:

"commodity_column_report": {
  "column": "Crop",
  "distinct_values": ["cocoa", "coffee"],
  "invalid": [{ "value": "hemp", "rows": 1 }],
  "blank_rows": 1
}
FieldMeaning
columnThe column the commodity was read from.
distinct_valuesThe supported commodities the column resolved to, sorted.
invalidEach non-blank value that resolves to no supported commodity, with how many rows hold it, sorted by value.
blank_rowsRows whose cell is empty.

POST /ingestions/profile returns the block before you upload, so you can fix an unsupported value at the source. Validation returns the same block for the same file.

Which column is used. The first of these that applies:

  1. The column you name with commodity_column. The name is matched ignoring case and surrounding spaces. For a shapefile, give the truncated DBF name.
  2. A column named exactly commodity_type.
  3. The column the profiler scores as the most likely commodity column. This step is skipped when you declare commodity_type.

commodity_column_report.column tells you which column was used. If you name a column the file does not have, POST /ingestions/profile returns 422 COMMODITY_COLUMN_NOT_FOUND listing the file's columns, and an upload with that column fails its task with the same message.

Each row is checked against its own commodity. A coffee row is checked against coffee's commodity layer, catchment, travel time, legality, natural hazard and land-use-change context, even in a file that is mostly cocoa. The commodity a row is stored with, its supplier unit, its record and its DDS commodity are the same value.

What a declared commodity_type does. It never overrides a cell. It is the file's commodity:

The row's cellStored and checked as
A supported commodity, e.g. coffeethat commodity (coffee)
Blankthe declared commodity
Unsupported, e.g. hempstored as written, checked against the declared commodity, and flagged
No commodity column (rules 1 and 2 do not apply)the declared commodity, every row

Without a declaration, the file's commodity is the most common value in the column, and it plays the same role.

Unsupported values are flagged per row. Such a row carries commodity_invalid: true, which reads back as assessments.core.flags.commodity_invalid on GET /ingestions/assets/:taskId?include=assessments. A blank cell is counted in blank_rows and is not flagged. The flag and the report appear whenever a column is used, whether or not you declare commodity_type.

Where the file lands

geography reports the countries the rows fall in. It is usually the fastest way to notice a region you did not expect in a file you did not create.

"geography": {
  "state": "found",
  "countries": [
    {"country": "Ghana", "rows": 62},
    {"country": "Cote d'Ivoire", "rows": 9}
  ],
  "rows_unresolved": 0,
  "rows_unplaced": 3
}

Derived from geometry, not from a country column. Of four real cocoa files only one names its country, so reading the column would answer for a quarter of uploads. Countries are ordered by row count, largest first.

Three outcomes, kept apart because they mean different things:

FieldMeaning
countries[]rows placed in a country
rows_unresolvedthe row has geometry, but it could not be placed β€” a coordinate in open ocean, or outside every boundary
rows_unplacedthe row has no geometry at all, so there was nothing to place

state is undetermined when the lookup did not run. That is a fact about us rather than about your file, and it is reported as such rather than as an empty country list, which would claim the file lands nowhere. rows_unplaced still counts, since it describes your geometry rather than the lookup.

How many suppliers the file describes

supplier_clusters answers the question a plot file rarely answers itself: how many supplier units does this describe? It groups parcels by geography, commodity and country. field_coverage and supplier_names separately report which columns name the producers.

Supplier names do not change this count. supplier_names supplies editable labels for the computed units. To preserve a reviewed partition, return the confirmed supplier_unit IDs; see the uploaded-unit rules below.

"supplier_clusters": {
  "basis": "geometry_clusters",
  "partition": "travel_time",
  "window_minutes": 120,
  "split_by": ["commodity", "country"],
  "supplier_count": 2,
  "site_supplier_count": 0,
  "cluster_supplier_count": 2,
  "address_supplier_count": 0,
  "cut_km": 50.0,
  "rows_unplaced": 1,
  "rows_ungrouped": 0,
  "clusters": [
    {"unit": "8f2c1a04e7b3", "plot_count": 178, "centroid": [-74.6977, -11.2533],
     "extent_km": 73.0, "bounds": [-75.1, -11.6, -74.2, -10.9],
     "commodity": "cocoa", "country": "Peru", "travel_unit": 1},
    {"unit": "b91d40c6aa17", "plot_count": 17, "centroid": [-73.6979, -11.2798],
     "extent_km": 2.6, "bounds": [-73.71, -11.29, -73.69, -11.27],
     "commodity": "coffee", "country": "Peru", "travel_unit": 1}
  ]
}
FieldMeaning
partitionstraight_line, travel_time, or uploaded. Which partition produced these units (see The two partitions).
window_minutesThe travel-time window, in minutes. Set only when partition is travel_time (or retained when keeping uploaded units); null otherwise.
split_byThe fields a geographic group was further split on: commodity, country, or both. [] when the file states neither.
clusters[].commodityThe commodity this unit holds. Present when commodity is in split_by. null where the rows state none.
clusters[].countryThe country of production this unit holds. Present when country is in split_by. null where the rows state none.
clusters[].travel_unitThe geographic group this unit was split out of. null on a straight_line partition.

One unit, one statement

A geographic group is not always one unit. One screening batch carries one commodity and one country of production, and so does one Due Diligence Statement. A group holding two commodities is therefore two units, and a group straddling a border is two more. The split runs after the geometry, on the values the file states.

The split applies to parcels only. A site is keyed on its place alone and is never split, so one site unit can hold rows of several commodities.

A blank value is its own unit. A row that states no commodity is never folded into the cocoa unit next to it, because proximity is not a declaration. It becomes a unit with commodity null, which the caller can fill in. The one exception is an upload that declares an accepted commodity_type: a blank cell then takes the declared commodity, because that is the commodity the location is stored with. A refused declaration, such as two commodities in one value, leaves the blank cell unknown.

A parcel unit therefore holds one commodity. The split ignores case, and it treats blank, unknown, none, nan and null as one value, so the locations in a unit can still store Cocoa beside cocoa, or unknown beside a blank. The split does not map aliases: on a file sent without normalisation, Cacao and cocoa are two units. A file that mixes two crops on neighbouring plots needs no split before upload.

A parcel unit id carries the commodity and the country it was split on, so two commodities in one group get two ids rather than one. A file that states neither is not split, and its ids are the ids it always had.

basisMeaning
one_per_rowEvery placed row is a site. A place is one supplier, however many rows sit there and whatever they say. Nothing was clustered.
geometry_clustersParcels, grouped by separation on the ground.
mixedBoth, counted separately in site_supplier_count and cluster_supplier_count.
uploadedParcels and sites grouped by the supplier_unit ids the file already carried, kept when they only split the computed units.
address_onlyEvery row gives an address and no place. Each is one supplier, so the count is known; the ids are not, and arrive at validation.
no_geometryNothing placeable at all, and no addresses either. Distinct from having measured and found one supplier.
no_crsParcels are on the map but the file states no coordinate system, so none of them could be grouped. Sites are still counted, since a site is one supplier whether or not it can be projected.
unprojectableEvery parcel carries a coordinate that does not land anywhere on Earth β€” most often a latitude and longitude the wrong way round. Sites are still counted. Where only some parcels are affected the rest are grouped normally and the basis reports that grouping instead.

clusters is ordered largest first, so a supplier's rank is its position. At ingestion the list is capped at 500 clusters; clusters_total reports the full count. Address-only rows are partitioned as facility sites at ingestion, while the profile reports unresolved addresses separately.

A unit is an opaque id derived from where that supplier sits, not from its place in this list. That matters because the list is re-derived: a later upload, a re-run, or the shed-confirmed split taken at ingest can re-order it, and a supplier's id has to keep naming the same supplier when it does. Treat it as a string to match on, never as a number to count with or sort by.

Every returned feature carries its own supplier_unit, so the allocation behind these counts can be drawn without re-deriving it. That field is on the feature rather than in this block deliberately: it is one value per row, and limit already decides how many rows come back, whereas a column of them here would be a 50,000-element array on a file that asked for twenty features.

A row that gives an address and no place

A plot is only ever a coordinate point or a polygon. A row that states an address and no coordinate is therefore naming a site, and it becomes one supplier once validation geocodes it. It is counted here, before that geocode, in address_supplier_count β€” so the number you are quoted is the number, not a floor that grows later.

Its supplier_unit is null until then. An id says where a unit sits, and this row does not sit anywhere yet.

The count can still fall. A geocode that finds nothing leaves the row unplaced, and an unplaced row is not a supplier.

extent_km is the widest separation between any two parcels in a group, so you can tell a village from a province. rows_unplaced counts rows that are not on the map β€” both the address rows above and rows with nothing to place at all β€” and rows_ungrouped counts parcels that are on the map but could not be grouped β€” the file states no coordinate system, or a coordinate does not project to anywhere on Earth, which is what an axis-swapped latitude does; neither is hidden inside a count.

The two partitions

Parcels are grouped in one of two ways, and partition says which.

straight_line cuts on separation on the ground at 50 km. It is exact, it needs nothing but your file, and it comes back with the first response. It is the immediate answer and the fallback.

travel_time cuts on how long a truck takes to drive. It uses the same friction surface and the same 120-minute window a facility's supply shed uses, so a parcel unit and a facility catchment are the same object measured the same way. Two parcels are one unit when a truck can drive between them inside the window. The answer is exact and it takes one pass: one least-cost flood from every parcel at once gives, per cell, the drive to its nearest parcel, and the drive between two parcels is the cheapest crossing of the boundary between their two regions. Units are single linkage on travel time, which is DBSCAN with a minimum cluster size of one.

Each unit also gets a shed: the ground within the window of its parcels, restricted to the cells nearest to them. Neighbouring sheds therefore never overlap.

Why the travel-time pass is asynchronous

The travel-time pass reads a friction raster for the ground your file covers, and that raster is fetched per file. The first profile of a file does not wait for it. It answers on the straight-line cut and starts the pass in the background.

supplier_units.refinement.status reports where that pass is.

statusMeaning
processingThe pass is running. task_id and poll_url are set.
readyThe travel-time partition is available. Profile the same file again to read it.
not_requestedrefine_units=false was passed.
not_neededThere is nothing to refine. reason says why, e.g. no_parcels.
unavailableThe pass could not start or it failed. reason says why.

To get the travel-time partition:

  1. Profile the file. Read supplier_units.refinement.
  2. Poll GET /ingestions/profile/units/{task_id} until it returns ready true.
  3. Profile the same file again, with the same bytes. The response now carries partition travel_time, the window, and a shed per unit.
  4. Select the units you want, then ingest them (see Each unit, ready to send). The file you send holds a subset of the rows, so ingestion matches it to this partition on the parcels' geometries.

The poll answers with status, ready, window_minutes, unit_count, resolution_m and algorithm. An unknown task id is a 404.

Why 50 km

50 km is the straight-line cut. Parcels are grouped by single linkage, which splits exactly where there is empty ground between groups. The cut is calibrated against real files rather than chosen: scanning the threshold and recording how wide a band produces each grouping, a 195-plot Peruvian cocoa file holds a two-group split stable from 26.5 km to 79.5 km, and 50 km sits in the middle of that band. Below roughly 26 km the same file fragments; above 80 km its two valleys merge. An independent river-basin partition of the same plots nests inside that split without crossing it.

A lone parcel far from the rest is reported as its own supplier rather than attached to the nearest group, which is a property of single linkage rather than a special case.

50 km means 50 km wherever your parcels are. Separation is measured in an equidistant projection centred on your own file, so the cut is the distance you would measure on the ground rather than a distance on a world map. A world projection cannot do this: the usual ones are exact along meridians and stretch east-west away from the equator by a factor reaching 1.4 at 45 degrees and 2.0 at 60, which would cut a Nordic timber file at an effective 25 km and report one producer as several. Checked against a WGS84 geodesic, what you are told is within 0.1% of true across a compact supply base at any latitude, and within 1.5% across one spanning the length of Sweden.

This is a floor, deliberately. Two producers whose parcels interleave a few kilometres apart are reported as one, because no separation distinguishes them and over-counting would charge for a supplier that does not exist. If you know the file holds more suppliers than reported, the supplier name is yours to supply.

The same partition at ingest

The profile only quotes. When the same file is ingested (POST /ingestions/assets), validation runs the identical pass over the frame the checks run on and commits it: every staged row carries its supplier_unit, and the task carries the block as supplier_partition on GET /ingestions/:id/summary, with a source of validation. That stored block, not a count sent by the client, is what a paid run is charged against, so the quote and the charge are one derivation of one file.

Identical bytes and options give identical labels on both endpoints. Both mint a unit from where the supplier sits, so the quote and the commit name the same supplier by the same id, not merely agree on how many there are.

A refined file is committed on the travel-time partition. The file you ingest is rarely byte-identical to the file you profiled: you drop units, and you stamp fields onto the rows you keep. Ingestion therefore finds the travel-time partition by the parcels' geometries, not only by the content fingerprint of the bytes.

Every parcel must come from one profiled pass. A parcel that pass never saw has no unit to be matched to, and the whole upload falls back to the straight-line cut. Add new parcels by profiling again and ingesting from that profile, rather than by appending them to a file the profile already read.

That is also why the id is not a rank. A rank only means something against the exact list that produced it: add one plot, the list re-sorts, and every row, suggested name and reserved credit pointing at "supplier 1" points somewhere else without anything changing that could be noticed. Ids derived from location survive re-sorting, trimming, and the block being replaced outright by the shed-confirmed split taken later at ingest β€” a cluster nobody touched keeps its id. One difference is deliberate: the profile classifies row by row, whereas ingestion validates the whole upload as the one location_type it was routed to. For a file the profile reads as a single kind (a plot file, or a facility file) the two agree label for label; for a file it reads as mixed, ingestion partitions every row as the routed kind.

Quote with the location_type you will ingest with. Ingestion partitions on the routed type, so a bare point is a parcel to be clustered under plot and one supplier per place under facility. A profile taken without a location_type reports such a point as point_no_area and clusters it; if the customer then ingests the file as facilities, the count changes. Profiling again with the chosen type gives the number ingestion will commit.

Supplier-name columns label units at ingestion; they do not replace the partition. Uploaded supplier_unit IDs are kept only under the partition validation rules described above.

One unit, one batch

A metrics run screens each plot unit in its own batch: one per commodity, country of production and supplier_unit. Facilities and sheds are batched per commodity and country. A plot file with twelve units starts twelve batches. Each row's results are the same as they would be in one large batch. Only the number of batches changes.

A failed batch fails only its own unit. When one unit's batch fails and the others finish, that unit's rows are failed with the batch's reason. Every other unit completes.

A unit identical to another upload's unit fails. The analysis service identifies a batch by its geometry, the date and the organisation. So a unit with exactly the plots of a unit uploaded the same day under another commodity reuses that unit's batch. Its own rows cannot be matched to it, so they are failed, and the reason says the plots match another upload's batch. A re-upload with the same commodity reuses the batch and completes normally.

A plot unit holds one commodity. Validation splits parcel units on commodity, so this always holds for a file ingested as it was validated. If a plot unit ever holds two, the ingestion fails before anything is reserved or submitted. Site units are not checked: they are never split. Its error_message names the unit ids and their commodities. That failure is an internal fault, not a problem with the file, and it is reported to us.

Each unit, ready to send

supplier_clusters counts and labels the units. supplier_units is what you need to put one on a screen and send it to ingestion: the rows it holds, what the file says about it, and which of those facts still need a person.

"supplier_units": {
  "basis": "travel_time",
  "window_minutes": 120,
  "split_by": ["commodity", "country"],
  "unit_count": 2,
  "rows_unassigned": 0,
  "refinement": {"status": "ready", "window_minutes": 120,
                 "resolution_m": 500, "algorithm": "tt1"},
  "units": [
    {
      "unit": "8f2c1a04e7b3",
      "kind": "parcels",
      "plot_count": 178,
      "row_ids": [0, 1, 5],
      "commodity": "cocoa",
      "country": "Peru",
      "detected_kinds": ["plot_boundary"],
      "centroid": [-74.6977, -11.2533],
      "bounds": [-75.1, -11.6, -74.2, -10.9],
      "travel_unit": 1,
      "shed": {"type": "MultiPolygon", "coordinates": []},
      "fields": {
        "product_type": {"state": "detected", "value": "cocoa",
                         "source": "column:commodity", "reason": null, "required": true},
        "production_country": {"state": "suggested", "value": "Peru",
                               "source": "geometry", "reason": "placed_by_geometry",
                               "required": true},
        "product_quantity": {"state": "missing", "value": null, "source": null,
                             "reason": "no_column", "required": true}
      },
      "missing_required": ["product_quantity"],
      "needs_confirmation": ["production_country"],
      "plots": {"type": "FeatureCollection", "features": []}
    }
  ]
}

basis, window_minutes and split_by repeat what supplier_clusters reports, so this block reads on its own. rows_unassigned counts rows that reached no unit, for the same reasons supplier_clusters reports under rows_unplaced and rows_ungrouped.

Units are ordered parcel units first, largest first, then sites.

FieldMeaning
unitThe unit id. It matches supplier_clusters.clusters[].unit and the supplier_unit on every feature.
kindparcels for a group of parcels, site for a single place.
plot_countRows in the unit.
row_idsThe rows themselves, as positions in the file, in file order. Use them to select the unit's rows out of your own copy of the file.
detected_kindsThe row kinds the unit holds.
centroid / boundsWhere the unit sits.
travel_unitThe geographic group the unit was split out of. null on a straight-line partition.
shedThe unit's supply shed, as a GeoJSON geometry. Present only when basis is travel_time.
plotsThe unit's rows as a GeoJSON FeatureCollection, ready to select and send on. Each feature carries the unit's resolved fields as properties. Present only when include_unit_plots is true.

The three field states

fields holds one entry per statement field. The state is the instruction.

stateWhat to do
detectedThe file states one value across the unit. Pre-fill it.
suggestedThe value is inferred, or a column disagrees with itself inside the unit. Pre-fill it, show it as a guess, and ask for a confirmation.
missingThere is nothing to offer. Ask for it.

Each entry carries value, source, reason and required. source says where the value came from: column:<name>, declared, supplier_names, geometry or file_detection. reason says why the state is what it is, for example mixed_values_in_unit (one column, several values in the unit, with distinct_values and share alongside), summed_over_unit (quantities added up, with rows_summed), no_column, column_empty_for_unit, no_commodity_found or not_placed.

Two lists summarise the entries, so you do not have to walk them:

  • missing_required β€” required fields in state missing. Every DDS needs each one, and filing can ask for more.
  • needs_confirmation β€” fields in state suggested. Each one is a guess to put in front of a person.

The fields a unit answers for itself are product_type, production_country, hs_code, product_quantity, supplier_name, supplier_address, product_trade_name, production_year, species_common_name and species_scientific_name.

The operator fields are always missing. operator_name, operator_address, operator_email and operator_country describe the entity placing the goods on the EU market. No location file carries them, and they carry the reason operator_field_not_in_location_file. They are reported here so you can see they will be asked for, rather than discovered late.

Sending the units you chose

Each feature in a unit's plots already carries that unit's resolved fields as properties, under the column names ingestion reads:

supplier_unit, commodity_type, production_country, hs_code, product_quantity, supplier_name, production_year.

A detected or suggested value is written to the property. A missing value is not written at all, so the client supplies it.

To upload:

  1. Select the units to send.
  2. Edit or fill each selected unit's fields. Write the values into that unit's feature properties, under the column names above.
  3. Concatenate the selected units' plots into one FeatureCollection.
  4. Send that collection as one ingestion (POST /ingestions/assets).

The columns carry the per-unit facts. The ingestion call takes no per-unit parameters, so the properties on each row are how a unit states its commodity, its country and its quantity. A file holding several commodities or countries therefore yields one batch per unit.

Who the suppliers are called

supplier_names reports the columns that could name the suppliers supplier_clusters counted. The two are separate on purpose: this one prefills an editable name, and never drives the count.

The reason is that a file's own labels sit at whatever grain its author had in mind. Across four real cocoa files the best candidate was an aggregator in one, a cooperative in two, and a collection village in the fourth β€” 1, 1, 2 and 32 values for files holding 1, 1, 2 and 1 actual suppliers. Counting on that field would have billed the last file thirty-two times over.

"supplier_names": {
  "state": "found",
  "reason": null,
  "source_column": "cooperativ",
  "candidates": [
    {"column": "cooperativ", "coalesced_from": ["cooperativ", "cooperat_1"],
     "grain": "cooperative", "distinct_values": 2, "fill_pct": 100.0,
     "id_like": false, "samples": ["COOP. HIJINI R.L.", "COOP. COVENDO R.L."]}
  ],
  "per_unit": [
    {"unit": 1, "rows": 247, "suggested_name": "COOP. HIJINI R.L.",
     "distinct_values": 2}
  ]
}

state is found when a candidate survived, absent when columns were read and none did, and undetermined when there were no columns to read β€” so "this file names nobody" stays distinguishable from "we could not look".

Unlike field_coverage, this reads the frame's column names directly and needs no schema detection, so it answers on GeoJSON, GeoPackage and shapefile uploads too β€” which is most plot files.

What it takes to match a real file

  • Stems, not whole words. Shapefile DBF truncates column names at ten characters, so cooperative arrives as cooperativ, and a join suffixes the second copy cooperat_1.
  • Accents folded, several languages. CoopΓ©rative, productor, comunidad, Nombre prod.
  • Cardinality decides, not the name. A supplier column groups rows, so its values must average at least two rows each. That is what rejects a farmer code with 804 values across 1146 rows, whatever it is called. Files under 20 rows are judged only on whether one value covers more than a single row.
  • ID-like values are demoted. DATE ADHESION COOP matches every cooperative stem and holds 2.016; it is the year a farmer joined.
  • Disjoint same-grain siblings are merged. Two shapefiles joined side by side fill one column each. coalesced_from names the columns combined. Merging is same-grain only β€” a join splits every column, so a producer column is disjoint from the second cooperative for the same reason and must not absorb it.

per_unit resolves the top candidate against each detected supplier, so the app prefills one field per supplier rather than one for the whole upload. When a unit spans more than one name, distinct_values says so and suggested_name carries the commonest.

Only suppliers holding more than one row appear. A supplier of one row has nothing to resolve β€” its suggested name is that row's own value, and the row itself carries both that value and, in supplier_unit, the supplier it was allocated to. In a file of facilities a supplier is usually a single row, so listing them would return one entry per row restating a single cell, which is the per-row array this block exists to avoid. Count your suppliers from supplier_clusters.supplier_count, never from the length of this list.

The EUDR lens

Pass include_eudr_profile=true to add the one regulatory distinction that cannot be derived without it: the polygon-required band. This enables the point_large_area kind and produces eudr_payload, a prefill block that ingest already knows how to consume. Override the band with area_band_ha.

eudr_payload is deliberately conservative. It carries only fields whose value cannot be silently wrong, and only when a column holds one single value across every row β€” a column that varies row to row describes the rows, not the consignment, and collapsing it would invent a fact.

Three fields are reported in field_coverage but never prefilled, because ingest fills only fields you left empty, so a wrong value would land exactly where nobody is positioned to notice it:

  • supplier_address and supplier_name β€” read downstream as the operator address and name. The operator is the entity placing goods on the EU market, and it is not your supplier.
  • product_quantity β€” ingest stamps the unit itself as kilograms, and a detected column may be tonnes, gross weight, unit count or area. A Quantity (tonnes) column would land on a statement wrong by a factor of 1000.

If you know which entity or unit a column describes, the coverage report still shows you it exists, and you can supply the value yourself.

Reading this against EUDR

The profile describes the uploaded data and the fields available for preparing a DDS. It runs no compliance assessment or submission validation.

Geolocation. rows_needing_a_decision counts rows carrying a decision, including identity conflicts. It is not a count of rejected or unfileable rows. The decision describes what the file leaves open:

KindWhat the file leaves openAvailable options
point_large_areaThe stated area reaches the configured polygon band, but the file supplies a pointsupply_boundary or keep_as_point
address_onlyThe row has an address but no coordinatesgeocode
point_no_areaThe point has no resolved areaassume_square with area_ha, or treat_as_facility

An identity conflict may also attach a decision to a boundary or facility group. No decision means this profiler has no open question for the group; it does not mean the locations have passed assessment or DDS submission checks.

Assessment footprints. Read geometry_types and footprint alongside kind. A facility group can contain both points and supplied boundaries. measured_on reports the most common basis in a mixed group, not the basis of every row. In sourcing-area runs, supplied boundaries define the sourcing areas. In facility runs, polygons of at least 5,000 ha define sourcing areas directly; smaller boundaries use their centroids to derive travel-time catchments, as site points do. Assessments can identify plots within those areas, and those plots' results and locations can be used in a DDS. Do not turn a facility or sourcing_area classification into a blanket DDS exclusion.

For plot input supplied as points, ingestion can resolve parcel boundaries or use fallback squares. The absence of an uploaded boundary does not itself mean that no plot geometry will be produced.

Statement fields. field_coverage reports what the file supplies. Missing required values need to be supplied before submission, but profile coverage alone does not determine whether a DDS can be filed.

Query parameters

ParameterDefaultMeaning
normalize_inputtrueNormalize columns and geocode addresses. When false, supplied geometries are still profiled without AI schema detection.
location_typeβ€”facility | plot | shed. An optional hint; the profiler may correct it. Read applied.location_type for the applied type.
countryβ€”Context. Echoed under detected; prefills only if it resolves.
commodity_typeβ€”Context. Echoed under detected; prefills only if supported.
limit100Max features returned, minimum 1. Aggregates stay complete regardless.
include_eudr_profilefalseAdd the EUDR lens.
area_band_ha4Polygon-required band. Applied only under the lens.
include_null_geometrytrueControls whether geometry cleanup may drop rows it cannot repair; applies to the default cleanup chain too. Unplaced rows are always reported: address-only and unusable rows have no geometry, and reporting them is the point.
cleanup_geometriesβ€”Geometry repair pass to apply.
include_unit_plotsfalseGive each unit its rows under supplier_units.units[].plots, plus complete review_rows for row exclusion. With false a unit carries row_ids only, and features is capped by limit as usual.
refine_unitstrueStart the travel-time pass for these bytes if it has not run. The request never waits for it.
unit_window_minutes120The travel-time window, in minutes, from 15 to 480. The default is the facility shed window.

Unknown query parameters are rejected with a 400.

Duplicate and tiny-plot warnings

properties.plot_warnings reports warnings over the complete file, independently of the preview's limit or supplier grouping. status: complete includes the applied min_area_ha (normally 0.05), the detected plot_id_column, and rows with source_row, plot_id, codes, measured_area_ha and duplicate_of. Duplicate references name both the original source row and its plot ID; geometry_groups uses source row positions. row is the post-cleanup profile position used by the review's exclusions. Positions are zero-based parsed-file rows, not spreadsheet line numbers. In merged Excel workbooks, source_row is the position after matching worksheets have been combined and full-row duplicates removed. Warning preflight uses ingestion's header promotion and matching-sheet merge. With include_unit_plots=true, properties.review_rows carries the complete post-cleanup rows for optional exclusion, independently of supplier units. These retain original row fields: unit plots may stamp detected or suggested unit values onto their rows, so they cannot replace raw review rows for public exclusions.

below_area_floor applies to plot boundaries below the configured minimum, not points or facilities. duplicate_geometry applies to matching cleaned geometries under different nonblank plot IDs. Repeat deliveries under the same ID are not flagged as copies. These findings require no acknowledgement, never block an upload, and never remove rows automatically.

The original member of a duplicate group is included for identity lookup. It has duplicate_of: null and may have codes: [] when it is not also below the area floor; only subsequent distinct plot IDs receive duplicate_geometry.

Direct POST /ingestions/assets uploads run the same checks after the file lands and before upload-time assessments. This includes multipart and signed-URL uploads; the signed-URL creation response cannot report on bytes not yet uploaded. Inspect GET /ingestions/:id β†’ data.attributes.plot_warnings, or request GET /ingestions/assets/:id?include=normalised_data β†’ meta.plot_warnings. These are compact summaries with row_count, geometry_group_count, and a details_url. GeoJSON implicitly includes this summary in collection properties; NDJSON includes it in the metadata header. Follow details_url to GET /ingestions/:id/plot-warnings with Accept: application/json and the same task-read authorization to download every finding and duplicate group. The complete JSON is stored beside uploads, outside task JSONB, and streamed only on that request. No findings are truncated. Read source identities there rather than assuming a profile position equals a result-page position. List and PATCH responses do not include the summary.

The warning preflight has a 60-second upstream deadline. Validation opens a fresh source stream afterward. Warning detection or persistence failure is logged and does not stop ingestion; an absent snapshot reads as unavailable.

To correct or exclude rows before direct submission, preview with POST /ingestions/profile, edit the source file, and submit the corrected bytes. Direct submission otherwise proceeds automatically, keeping all warned rows. status: unavailable explicitly distinguishes a failed/unrecorded check from a completed check with no warnings. Older uploads are not backfilled. App users see the same findings, optional exclusions, and a retry option for unavailable checks before submission. Public screening remains free for the visitor; these warnings do not change organisation allowances, payment rules, or assessment verdicts.

Status codes

CodeMeaning
200Profile returned, as application/geo+json.
400Missing file, or an unknown query parameter.
401 / 403Not authenticated, or missing the ingestions:read scope.
411No Content-Length.
413File over 100 MB.
415Unrecognised file type.
502The upstream profiler did not respond successfully.