--- name: un-comtrade-scraper description: Pull annual merchandise trade statistics from UN Comtrade as structured records via the Apify Actor arman-bd/un-comtrade-scraper. Returns one row per trade flow with reporter and partner country codes and resolved names, calendar year, direction (imports or exports), HS commodity code and description, trade value in US dollars, net weight and quantity with its unit. Use for supply-chain dependency mapping, export market sizing, trade-balance analysis, tariff or sanction impact studies and commodity concentration risk. Not for monthly or quarterly periods, services trade, tariff rates, company-level shipments or the current year. --- # UN Comtrade Scraper Apify Actor `arman-bd/un-comtrade-scraper`. Name reporter countries, optionally partners, commodity codes and years, and get one dataset record per trade flow. It reads the free public preview tier, so it runs without credentials, and the cost of that is a hard 500-row ceiling per request that shapes how you should configure it. ## When to use it - Mapping supply-chain dependency: one HS code across many reporters, to see who actually supplies it. - Sizing an export market: total imports of a product category by country, from customs data rather than a survey. - Trade-balance work: imports and exports for the same reporter and partner in one pass. - Before-and-after studies around a tariff or sanction, comparing the same reporter/partner/commodity across years. - Commodity concentration profiles: all 97 two-digit HS chapters for one country. ## When not to use it - Monthly or quarterly figures. This reads the annual series only. - Services trade, tariff schedules, rules of origin or customs duties. Merchandise values, weights and quantities only. - Company-level or shipment-level data. Every row is a national aggregate. - The current year, or usually the year before it. The series lags reporting and validation by roughly two years. - Exhaustive extraction of a wide query in one request. The preview tier truncates at 500 rows. ## Call it ```js import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('arman-bd/un-comtrade-scraper').call({ reporters: ['276', '842'], partners: ['156'], commodityCodes: ['8703'], years: ['2021', '2022'], flow: 'M,X', maxResults: 5000, }); const { items } = await client.dataset(run.defaultDatasetId).listItems(); const { value: summary } = await client .keyValueStore(run.defaultKeyValueStoreId) .getRecord('RUN_SUMMARY'); ``` One-shot over HTTP, when you want the rows back in the same request: ```bash curl -X POST "https://api.apify.com/v2/acts/arman-bd~un-comtrade-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"reporters":["842"],"commodityCodes":["AG2"],"years":["2022"],"flow":"M"}' ``` The Actor is also exposed through Apify's MCP server as `arman-bd/un-comtrade-scraper`, so an MCP-capable agent can call it with no extra wiring. ## Input | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `reporters` | string[] | yes | | UN M49 numeric country codes for the countries doing the reporting: 842 USA, 156 China, 276 Germany, 826 United Kingdom, 392 Japan, 699 India. A picker label such as "842 - USA" is accepted and the leading digits are taken. Not ISO letter codes. | | `partners` | string[] | no | `["0"]` | Partner country codes, same numbering. Empty means partner 0, the "World" aggregate, which is what you want for a country's total trade. | | `commodityCodes` | string[] | no | `["TOTAL"]` | `TOTAL` for all commodities combined; AG2, AG4 or AG6 to expand to every 2-, 4- or 6-digit heading; or specific headings such as `8703`. Upper-cased and deduplicated. An empty list falls back to `TOTAL`. | | `years` | string[] | no | current year minus 2 | Calendar years. The preview tier accepts exactly one year per request, so each extra year is another request. | | `flow` | string | no | `"M"` | Trade direction: M for imports, X for exports, or both together as `"M,X"` in one pass. | | `maxResults` | integer | no | `5000` | Total cap on saved flows across the whole run. `0` means no cap. When the cap is reached, the remaining requests are skipped, not truncated mid-row. | **Only years and commodity codes multiply the request count; reporters, partners and flows do not.** Requests are chunked by year and by groups of 20 commodity codes, whereas reporters, partners and both directions are sent together in one call. That is efficient, and it is also the trap: a request carrying 5 reporters, 10 partners, both directions and 20 commodity codes asks for up to 2000 combinations and comes back truncated at 500. Widen along years and commodities, which cost requests but never lose rows. Widen along reporters and partners only until `RUN_SUMMARY.requestsTruncated` stops being zero. ## Output One record per trade flow. | Field | Type | Notes | |---|---|---| | `reporterCode` | number \| null | UN M49 code of the reporting country. | | `reporterName` | string \| null | Resolved from a reference table, not returned by the data tier. `null` if that table could not be loaded. | | `partnerCode` | number \| null | UN M49 code of the partner. `0` is the "World" aggregate. | | `partnerName` | string \| null | Resolved the same way. | | `period` | string | Calendar year, as a string. | | `flowCode` | string \| null | Direction of the flow: M for imports, X for exports. | | `cmdCode` | string \| null | HS commodity code. `TOTAL` on an all-commodities row. | | `cmdDesc` | string \| null | Resolved commodity description, with the redundant code prefix stripped. `null` for aggregates with no reference entry. | | `tradeValue` | number \| null | Reported value in US dollars. See the note below on what this measures. | | `netWeight` | number \| null | Net weight in kilograms, where the reporter recorded one. | | `qty` | number \| null | Quantity in the unit below. `null` when no quantity was recorded. | | `qtyUnit` | string \| null | Unit label, resolved from the numeric unit code: `kg`, `u` for items, `l`, `m²` and so on. `null` when the flow carries no quantity. | | `scrapedAt` | string | Run timestamp, ISO 8601. | A real record: ```json { "reporterCode": 276, "reporterName": "Germany", "partnerCode": 156, "partnerName": "China", "period": "2021", "flowCode": "M", "cmdCode": "8703", "cmdDesc": "Motor cars and other motor vehicles; principally designed for the transport of persons…", "tradeValue": 1152703769.201, "netWeight": 55997483.106, "qty": 72357.005, "qtyUnit": "u", "scrapedAt": "2026-08-06T11:56:24.766Z" } ``` ## RUN_SUMMARY Written to the run's key-value store under the key `RUN_SUMMARY`. **Read it.** `requestsTruncated` is the difference between a complete answer and a plausible-looking one. ```json { "requestsIssued": 4, "requestsFailed": 0, "requestsTruncated": 1, "failures": [], "flowsSaved": 1187, "filters": { "reporters": ["276", "842"], "partners": ["156"], "commodityCodes": ["AG2"], "years": ["2021", "2022"], "flow": "M,X", "maxResults": 5000 }, "finishedAt": "2026-08-06T11:57:41.902Z" } ``` `requestsTruncated` above zero means at least one request came back at the ceiling and the rows you have are a partial answer to that slice, with no marker on the affected records. Narrow that slice and re-run. `requestsIssued` is the number of requests **planned**; if `maxResults` was reached the run stopped early and fewer were actually made, so `requestsIssued` minus `requestsFailed` is not a count of completed requests. `filters.partners` reads `["0 (World)"]` when you left `partners` empty. ## Behaviour to plan around - **Exactly 500 rows from one request means truncation, not a coincidence.** The preview tier stops there. The Actor counts it in `requestsTruncated` and logs which slice was affected, but the saved rows carry no flag, so a run that looks complete can be short. Split by fewer commodity codes, fewer partners or fewer reporters. - **Rows are aggregate flows, and that is a deliberate choice.** Left alone the source returns one row per customs procedure, transport mode and second partner, which for a single bilateral flow can be over a hundred rows summing to several times the true value. This Actor always requests the all-procedures, all-modes, all-origins aggregate, so one row is one flow and `tradeValue` sums correctly. Do not try to reconcile these numbers against a breakdown pulled elsewhere. - **Import and export values are measured differently.** `tradeValue` is what the reporter declared: imports are valued including insurance and freight, exports at the border. So one country's reported imports from a partner will not equal that partner's reported exports, typically by a few percent, and neither figure is wrong. - **Names are resolved locally, not returned by the source.** Every descriptive column comes back empty on this tier, so reporter, partner and commodity names are filled in from reference tables loaded once per run. If a table cannot be loaded the run continues with `null` names and a warning; the codes are always right. - **`qty: null` means the reporter recorded no quantity**, not that the flow was zero. `tradeValue` is still valid on those rows. Never impute a quantity from weight. - **The series lags by roughly two years.** Leaving `years` empty defaults to the current year minus two for that reason. A year that is simply not published yet returns no rows rather than an error, which is indistinguishable from a genuine zero unless you check that other years returned data. - **A reporter code that does not exist fails the whole run.** The rejection is a permanent input error, every request carries the same reporter list, so every request fails and the Actor throws with the source's own explanation. - **It is slow, and that is the source.** Each request spends seconds in a query planner before answering, there is a pause between requests, and throttling and server errors are retried with exponential backoff. Budget minutes, not seconds, for a wide run. - **Individual request failures never abort the run.** They are recorded in `RUN_SUMMARY.failures` and the rest continue. The Actor only throws when every request failed. ## Recipes **One country's total trade with the world.** The simplest useful run. ```json { "reporters": ["842"], "partners": [], "commodityCodes": ["TOTAL"], "years": ["2022"], "flow": "M,X" } ``` Two rows per year, one per direction. Subtract to get the balance. **Full commodity profile.** All 97 chapters, one country, capped. ```json { "reporters": ["842"], "commodityCodes": ["AG2"], "years": ["2021", "2022"], "flow": "M", "maxResults": 500 } ``` Ninety-seven rows per year against the world sits well under the ceiling. Sort by `tradeValue` for concentration; check `requestsTruncated` is zero first. **Who supplies this product.** One heading, many reporters, world partner. ```json { "reporters": ["842", "276", "392", "826"], "partners": [], "commodityCodes": ["8703"], "years": ["2022"], "flow": "M" } ``` Four rows. Then flip `flow` to exports and re-run to see the other side of the same market. **Bilateral detail across a policy change.** Narrow slices, several years. ```json { "reporters": ["276"], "partners": ["156", "826"], "commodityCodes": ["8703", "27"], "years": ["2019", "2020", "2021", "2022"], "flow": "M,X", "maxResults": 2000 } ``` Four requests, one per year. Pivot on `period`, `partnerName` and `cmdCode`, and compare like with like: never a reported import against a reported export.