AgriAtlas API

One canonical record per real agri-input product in India — brand, manufacturer, chemistry, pack sizes — plus the crops and the pests, diseases, weeds and deficiencies that afflict them. This is how another application reads it.

Base URL and credentials

Everything lives under /v1. There are two audiences with two different credentials, and they are not interchangeable.

WhatEndpointsCredential
Read the directory /v1/brands, /v1/products, /v1/companies, /v1/technicals, /v1/crops, /v1/problems, /v1/segments, /v1/meta, /v1/products/match X-API-Key
one key per consuming application
Push products in /v1/tenant/{tenant}/products Authorization: Bearer
one token per dealer

A read key is not a write token. A dealer's token lets that dealer write their own rows and nothing else. Ask whoever runs the directory for yours — keys are issued per application so that one can be withdrawn without breaking the others.

curl -H "X-API-Key: $KEY" https://<host>/v1/brands?q=coragen

Either header spelling works for a read key, because the callers are not all built with the same HTTP client:

X-API-Key: <key>
Authorization: Bearer <key>
Everything is HTTPS. Keys and tokens are sent in a header with no rotation and no expiry. Over plain http they are readable by anything on the path.

Integrating dealers (TradeOps)

The whole flow a dealer platform follows, and the only calls it needs. Four moments:

WhenCallCredential
A dealer is onboardedPOST /v1/tenantsread key
Dealer chooses what to downloadGET /v1/companies · GET /v1/segmentsread key
Download the chosen productsGET /v1/products?company=&category=&limit=500&offset=read key
Dealer types in Add ProductGET /v1/brands?q=&packs=trueread key
Dealer pastes a free-text lineGET /v1/products/match?q=&tenant=read key
Any product saved, added or editedPOST /v1/tenant/{tenant}/productsdealer's push token
Keep ids currentGET /v1/tenant/{tenant}/links?since=dealer's push token

The directory's fields, and the dealer's

Changing a directory field on an inherited product makes it dirty, so a moderator checks whether the dealer or the directory is wrong: brand name, manufacturer, chemistry and strength, formulation, pack size, HSN, GST rate, category. Everything else — prices, discounts, stock, the dealer's own codes, photos — is the dealer's and never makes a link dirty. A field left blank is not a change.

1 · Onboard and download

A dealer has one identifier: your TenantId — the code the directory knows them by, the code in their push URL, and the code on every row they send. The push token comes back in this response, so no token is carried by hand: store it against the tenant as you would any other per-tenant secret.

POST /v1/tenants
X-API-Key: <TradeOps read key>

{ "TenantId": "ACME_AGRI", "display_name": "Acme Agri Agencies",
  "state": "IN-TS", "gstin": "29ABCDE1234F1Z5" }

201 → { "tenant_code": "ACME_AGRI", "status": "active", "created": true,
        "push_endpoint": "/v1/tenant/ACME_AGRI/products",
        "push_token": "DxTpAQGqkH5wDt3PkwbNV2k3C8lCDPgvW4k6N4s-xFo" }

A dealer registered in the AgriAtlas console gets no token there, deliberately: a token shown on a screen has to be carried across by a person. GET /v1/tenants lists every dealer with "token": "issued" | "none", and your POST /v1/tenants for a TenantId with no token issues it and returns it. One long-lived read key on your side, per-dealer tokens that only ever travel in an API response, and nothing typed anywhere.

state is the ISO code for the dealer's state (IN-TS, IN-PB; GET /v1/geo?parent=IN lists all 36). A state written out in words is refused rather than stored, because a spelling cannot be joined to the geography the agronomy is filed under. The call is idempotent on TenantId and does not issue a second token; "reissue_token": true replaces a lost one and invalidates the old. Registering a dealer mints a write credential, so only the consumer named in AGRIDIR_PROVISION_CONSUMERS may call it — another read key gets 403.

GET /v1/companies?q=dhanuka
→ { "data": [ { "key": "DHANUKA AGRITECH", "name": "Dhanuka Agritech", "brands": 77 } ], "next_offset": null }

GET /v1/segments
→ { "data": [ { "key": "PESTICIDES", "label": "Pesticides",
                "categories": [ { "key": "INSECTICIDE", "label": "Insecticide" }, … ] } ] }

GET /v1/products?company=DHANUKA%20AGRITECH&category=INSECTICIDE&limit=500&offset=0
→ { "total": 55, "next_offset": null,
    "data": [ { "global_brand_id": "GB-000173", "brand": "Mortar",
                "manufacturer": { "key": "DHANUKA AGRITECH", "name": "Dhanuka Agritech" },
                "chemistry": { "readable": "Cartap Hydrochloride 75% SG", "formulation": "SG" },
                "category": "INSECTICIDE", "hsn": "38089990", "gst_rate": 18.0,
                "pack": { "global_product_id": "GP-000257", "pack": "250 g", "units_per_shipper": 40 } } ] }

2 · Search in Add Product

GET /v1/brands?q=mortar&packs=true
→ { "data": [ { "global_brand_id": "GB-000173", "brand": "Mortar",
                "manufacturer": { "name": "Dhanuka Agritech" },
                "chemistry": { "readable": "Cartap Hydrochloride 75% SG" },
                "packs": [ { "global_product_id": "GP-000255", "pack": "100 g" },
                           { "global_product_id": "GP-000257", "pack": "250 g" },
                           { "global_product_id": "GP-000258", "pack": "500 g" },
                           { "global_product_id": "GP-000256", "pack": "1 kg" } ] } ] }

The dealer picks Mortar 250 g: store GlobalBrandId = GB-000173 and GlobalProductId = GP-000257. Nothing found: save without ids — no "waiting for approval" state.

3 · Push the save

POST /v1/tenant/ACME_AGRI/products
Authorization: Bearer <ACME_AGRI push token>

{ "tenant": "ACME_AGRI",
  "products": [ { "ProductId": "P00231", "BrandId": "BR0045",
                  "GlobalProductId": "GP-000257", "GlobalBrandId": "GB-000173",
                  "BrandName": { "en": "Mortar" }, "CompanyName": { "en": "Dhanuka Agritech" },
                  "MoleculeName": { "en": "Cartap Hydrochloride 75% SG" },
                  "PackSize": "300 g", "HSNCode": "38089990", "GSTPrice": 18, "SellingPrice": 420 } ] }

202 → { "accepted": 1, "mapping": "inline",
        "mapped": [ { "ProductId": "P00231", "status": "modified",
                      "global_product_id": "GP-000257", "global_brand_id": "GB-000173",
                      "changes": [ { "field": "pack", "dealer": "300 g", "directory": "250 g" } ],
                      "why": "Saved and kept linked. A moderator will check the change; the dealer is not blocked." } ] }
A product sent without ids that the directory does not hold:
{ "ProductId": "P00232", "status": "new_to_directory", "why": "no candidate in the directory resembles this", "candidates": [] }

4 · Read the links feed

GET /v1/tenant/ACME_AGRI/links?since=0&limit=500
Authorization: Bearer <ACME_AGRI push token>

→ { "tenant": "ACME_AGRI", "next_since": 9614, "more": false,
    "events": [
      { "seq": 9613, "entity": "product", "ProductId": "P00231", "global_product_id": "GP-000257",
        "state": "dirty", "reason": "dealer changed pack: 250 g → 300 g" },
      { "seq": 9614, "entity": "product", "ProductId": "P00232", "global_product_id": "GP-010544",
        "state": "linked", "reason": null } ] }

The second event is the moderator admitting the dealer's new product: store its new id. Details of each call follow below.


The two identifiers, and why only these

global_brand_id    GB-001334   a brand identity: brand + manufacturer + chemistry
global_product_id  GP-002082   one pack of that brand — the sellable thing

Store these two. Store nothing else. The directory rebuilds its canonical layer wholesale — re-clustering and renumbering every internal row — each time a moderator applies a batch of corrections. Internal ids move when it does. A global id is issued once against a stable natural identity and is never renumbered, which is precisely why these are the only ones published.

global_product_id can be null, and that is not an error. It means the row has not been through a build yet, or is waiting on a moderator. Save the dealer's product unlinked and pick the link up later. A dealer must never be blocked on this directory.

One exception worth naming: /v1/products/match also returns an id field. That is an internal row number, it is not stable across rebuilds, and it exists for debugging a match. Do not persist it.

What is deliberately not returned

  • Which dealers stock a product, or how many. Dealers must not be able to read each other's commercial position out of a shared master, and a count is a weaker version of the same leak rather than a different thing.
  • The internal chemistry key. It is upper-cased, pipe-delimited and sorted for exact comparison — so printing it reverses a mixture and states a composition no pack has ever carried. You get actives in the manufacturer's own order, plus a readable rendering.
  • Anything a moderator quarantined. Enforced in the database view every read passes through, not filtered afterwards — so calling the API is not a way around a moderator's judgement. A quarantined record answers 404, the same as one that never existed, so that nobody can enumerate what was withheld.

Lists and paging

Every list endpoint returns the same envelope.

{ "count": 1, "total": 3391, "limit": 100, "offset": 0,
  "next_offset": 100, "data": [ ... ] }

next_offset is null on the last page — always present, never absent, so a loop can test either way. limit caps at 500.


Brands GET /v1/brands

One row per brand identity — brand, manufacturer and chemistry together. This is the list to populate a product picker from.

ParameterMeaning
qfree text over brand, chemistry and manufacturer
companymanufacturer key, from /v1/companies
segmentPESTICIDES, FERTILIZERS, SEEDS, PGR_BIOLOGICALS, EQUIPMENT
categorycategory key, from /v1/segments
cropcrop key — mostly meaningful for seeds
hsnexact HSN code
packstrue includes pack sizes in the same call
limit, offsetpaging; limit caps at 500
GET /v1/brands?q=coragen&packs=true&limit=1

{
  "count": 1, "total": 2, "limit": 1, "offset": 0, "next_offset": 1,
  "data": [
    {
      "global_brand_id": "GB-001334",
      "brand": "CORAGEN",
      "brand_core": "CORAGEN",
      "modifiers": [],
      "manufacturer": { "key": "FMC", "name": "FMC India" },
      "chemistry": {
        "actives": [ { "name": "CHLORANTRANILIPROLE", "pct": 18.5, "unit": "%" } ],
        "formulation": "SC",
        "readable": "Chlorantraniliprole 18.5% SC"
      },
      "segment": "PESTICIDES",
      "category": "INSECTICIDE",
      "crop": null,
      "hsn": "38089990",
      "gst_rate": null,
      "shelf_life_days": null,
      "image_url": null,
      "description": null,
      "formed_by": "exact",
      "corroborated_by_manufacturer": false,
      "updated_at": "2026-09-09T14:35:53.902197+05:30",
      "packs": [
        { "global_product_id": "GP-002082", "pack": "30 ml",
          "value": 30.0, "uom": "ml",
          "base_value": 30.0, "base_uom": "ml",
          "units_per_shipper": 0, "hsn": "38089990", "gst_rate": null },
        { "global_product_id": "GP-002083", "pack": "60 ml", "...": "..." }
      ]
    }
  ]
}

Two fields say how much to trust a record. formed_by is how it came to exist — exact means several dealers' rows clustered onto it, directory means a moderator admitted it from an agreed document. corroborated_by_manufacturer means the chemistry was confirmed against a manufacturer's own source rather than a dealer's spelling.

GET /v1/brands/{global_brand_id} returns one brand, always with its packs — a caller that asked for exactly one record wants the whole of it.

Products GET /v1/products

The same records at pack grain — one row per global_product_id. This is the level a dealer's own product master is keyed on, so it is usually the one to sync against. Same filters as brands; each row is a brand object with an extra pack.

GET /v1/products?segment=PESTICIDES&limit=100

"data": [ { "global_brand_id": "GB-001334", "brand": "CORAGEN", "...": "...",
            "pack": { "global_product_id": "GP-002082", "pack": "30 ml",
                      "value": 30.0, "uom": "ml", "hsn": "38089990" } } ]

GET /v1/products/{global_product_id} returns one.

Companies GET /v1/companies

Manufacturers, each with the spellings it absorbs. Use key as the company filter elsewhere. Filter with q.

{ "key": "COROMANDEL",
  "name": "Coromandel International",
  "spellings": [ "COROMANDEL", "COROMANDEL INTERNATIONAL GROMOR",
                 "COROMANDEL INTERNATIONAL LTD" ],
  "resolved_by": "prefix_merge",
  "brands": 100 }

brands counts only records this API will actually hand over, so it agrees with what /v1/brands?company=… returns rather than promising rows it then withholds.

Technicals GET /v1/technicals

The directory read by chemistry: one row per distinct active-ingredient composition, with whether CIB&RC registers it. Filter registered=true|false, or q.

{ "actives": [ { "name": "ACETAMIPRID", "pct": 20.0, "unit": "%" } ],
  "formulation": "SP",
  "readable": "Acetamiprid 20% SP",
  "registered": true,
  "registry_category": "INSECTICIDE",
  "category": "INSECTICIDE",
  "brands": 17,
  "manufacturers": 12 }

Knowledge ids GET /v1/entities/{id}

Crops, problems and symptoms carry an issued id — CR-000116, PB-000015, SY-000042 — beside the key it was issued against. The id is issued once and never renumbered, like GB-/GP-; the key is a name that may be renamed. Store the id. Every endpoint taking a crop or problem accepts either.

{ "id": "PB-000015", "current_id": "PB-000015", "type": "problem",
  "status": "active", "key": "BPH", "label": "Brown planthopper",
  "present": true, "issued_at": "…", "href": "/v1/problems/PB-000015" }

current_id differs only after a merge, so a stored id keeps answering. present: false means the record no longer exists; its id is never given to another record.

Relationships GET /v1/relationships

The knowledge graph: a subject, a predicate (see GET /v1/predicates), an object or value, and the qualifiers it holds for. Filter by subject, object, crop, predicate, geo.

{ "id": "ST-…", "held_in": "statement",
  "subject": { "id": "PB-000015", "type": "problem", "label": "Brown planthopper" },
  "predicate": "affects",
  "object": { "id": "CR-000116", "type": "crop", "label": "Paddy (Rice)" },
  "value": null,
  "qualifiers": { "crop": null, "stage": null, "geo": "IN-TS", "season": null,
                  "farming_system": null, "role": "major" },
  "geo_stated": true, "state": "reviewed", "origin": "harvest", "revision": 1,
  "evidence": { "sources": 1, "independent": 1, "best_tier": 2, "contradicted": false } }
Only reviewed relationships are served, as their reviewed revision. Proposed and AI-drafted content never leaves. geo=IN-TS includes what is filed for Telangana or anywhere containing it, and relationships with no stated geography (geo_stated: false) — never a neighbouring state's.

Building on this knowledge GET /v1/changes

For a platform that makes decisions from these answers:

  • Every /v1 response carries X-Knowledge-Version. Store it beside each decision.
  • GET /v1/changes?since=<n> — what became readable or stopped being readable, in order. Do one full read, then follow next_since.
  • as_of=<version> on /v1/relationships replays the graph as it stood.
  • POST /v1/proposals — a claim with evidence, from permitted consumers. It is reviewed by a person and served to nobody until then. 201, 409 if already held, 422 if it does not fit, 403 for a read-only key.
  • POST /v1/diagnosis/candidates — possible causes from agreed symptom links and the next question to ask. Stateless; no percentages.
POST /v1/proposals
{ "subject": "PB-000015", "predicate": "affects", "object": "CR-000084",
  "qualifiers": { "geo": "IN-TS", "role": "occasional" },
  "evidence": { "reference": "aip://observation/81234", "text": "Confirmed on 14 fields, Sep 2026" } }
→ 201 { "id": "ST-…", "state": "proposed" }

What is not known yet GET /v1/coverage

What each crop, problem or active ingredient is expected to have, why, and how many have it — reviewed, proposed, missing, not_applicable. Add expectation=<key>&state=missing for the ids.

GET /v1/entities/{id}/quality — the factors behind how far to trust what is held about one record: evidence, review, recency, geography and coverage. They are never combined into a score or a percentage.

GET /v1/coverage?type=crop&expectation=crop_kc&state=missing
→ { "expectations": [{ "key": "crop_kc", "label": "crop coefficients (Kc)", "domain": "water",
      "awaiting_source": "FAO Irrigation and Drainage Paper 56",
      "counts": { "reviewed": 0, "proposed": 0, "missing": 167, "not_applicable": 0 } }],
    "entities": [{ "id": "CR-000040", "label": "Chilli", "state": "missing" }, …] }

Approved uses GET /v1/label-uses

Uses approved on a national register (India: CIB&RC's Major Uses of Pesticides), only those a person has checked against the register's own page. Filter by crop, problem, chemistry. GET /v1/chemistries/{CH-id} gives actives at strength, formulation, register status and checked uses; GET /v1/actives lists active ingredients.

Doses are text, as printed. The register's doses carry bases — per hectare, per tree, per litre, per kg of seed — that a number would lose. No dose is ever given as a number, and none should be derived from one.

Phenology and agroclimate GET /v1/crops/{id}/phenology

Knowledge and models for a calculation — never a calculation for a field. Every object carries nature: model or stated_response.

GET /v1/methods and /v1/methods/{key}: calculation methods with parameters, output unit, citation and conformance cases your implementation must reproduce. GET /v1/units, /v1/variables. GET /v1/crops/{id}/stages: reviewed stage schemes. GET /v1/crops/{id}/phenology?variety=&geo=&season=&system=: reviewed models that apply, most specific first, each with matched_on, relaxed and geography_not_stated — place widens upward only, never to a neighbour. GET /v1/phenology-models/{PM-id}: one model whole. GET /v1/crops/{id}/agroclimate: stated heat, cold, frost, day-length, vernalization and water responses and crop coefficients. GET /v1/problems/{id}/weather-risk: disease weather rules.

A requirement is never served without its model. Degree-days mean nothing without the base temperature, method and reference event they were measured with.

Places and seasons GET /v1/geo

GLOBAL, every ISO 3166-1 country and every ISO 3166-2 subdivision under it (parent=IN lists India's states and union territories). GET /v1/geo/{id} adds within, the containing areas nearest first, and the seasons defined there. GET /v1/seasons?geo=IN lists seasons.

{ "id": "GE-…", "key": "IN-TS", "name": "Telangāna", "display_name": "Telangana",
  "level": "subdivision", "type": "State", "parent": "IN", "status": "current",
  "within": [ "IN", "GLOBAL" ], "seasons": [] }
A null geography is not global. Knowledge that does not say where it holds is marked as such; GLOBAL is an explicit area.

Sources GET /v1/sources

Who the directory cites. Filter by kind; GET /v1/sources/{id} takes the SR- id or the key.

{ "id": "SR-000060", "key": "TNAU", "name": "TNAU Agritech Portal",
  "kind": "university", "organization": "Tamil Nadu Agricultural University",
  "url": "https://agritech.tnau.ac.in", "country": "IN", "interested": false,
  "authority_tier": 2, "licence": "no declaration; used as attributed reference",
  "redistributable": "unknown" }

authority_tier: 1 regulator or standards body · 2 research body or university · 3 aggregator · 4 interested party (a manufacturer describing its own product) or unclassified · 5 AI draft, never a citation. redistributable unknown means no terms were declared — not permission to republish.

Crops GET /v1/crops

Filter by q, group, season. Aliases are searched too, so q=thotakura finds Amaranthus.

{ "id": "CR-000005", "key": "AMARANTHUS", "label": "Amaranthus",
  "botanical_name": "Amaranthus spp.", "family": "Amaranthaceae",
  "group": "vegetable", "seasons": [ "kharif", "zaid" ],
  "duration_days": 40, "seed_category": "VEGETABLE_SEED",
  "aliases": [ "amaranthus", "amaranth", "thotakura" ] }

GET /v1/crops/{key} adds problems — what is recorded as afflicting that crop. That join is the reason to fetch one crop rather than filter the list.

Problems GET /v1/problems

Insects, diseases, weeds, nematodes and deficiencies are one entity with a kind, not five endpoints. Filter by kind, crop or q.

{ "key": "BOLLWORM_AMERICAN",
  "label": "American bollworm / Gram pod borer",
  "kind": "insect",
  "scientific_name": "Helicoverpa armigera",
  "aliases": [ "helicoverpa", "american bollworm", "pod borer", "…" ] }

GET /v1/problems/{key} adds the crops it affects and the chemistries recorded as controlling it:

"controls": [
  { "chemistry": "Spinosad 45% SC", "crop": "COTTON",
    "dose": null, "source": "scraped:TROPICAL_AGRO", "confidence": 0.7,
    "basis": "manufacturer_claim", "pairing": "exact" } ]

basis is who makes the claim: registered_label (a registration's approved use), document_agreed (a steward agreed a row of an uploaded document), manufacturer_claim (a manufacturer's or retailer's own product page) or unknown. pairing is exact when the source names this crop and this problem together, listed_together when it lists several of each without saying which goes with which.

Treat a control as a claim with a source, not as advice. source and confidence are on every row because they matter: a recommendation a farmer acts on should carry where it came from. Do not present a manufacturer_claim as a recommendation from this directory.

Segments GET /v1/segments

The filter vocabulary — segments with their categories — returned whole. Fetch once at startup and cache; it changes rarely.

Meta GET /v1/meta

Worth one call before any sync. last_build.run_id moving is the only reliable signal that anything downstream needs re-reading, and the counts let you notice a collapse before importing it.

{ "directory": "DibbleTech AgriAtlas",
  "counts": { "brands": 3391, "products": 5171, "manufacturers": 344,
              "chemistries": 507, "crops": 61, "problems": 79 },
  "last_build": { "run_id": 283, "finished_at": "2026-09-09T15:29:07+05:30" } }

Match free text GET /v1/products/match

Free text in, ranked candidates out. This is what an Add Product screen calls when somebody types a product name the way a dealer says it.

ParameterMeaning
qrequired. What the person typed
packpack size, if held separately
companymanufacturer as the dealer spells it — resolved, not matched literally
tenantthe dealer's code; scopes their own aliases
limitcandidates to return, default 8
GET /v1/products/match?q=coragen%2030ml&tenant=ACME_AGRI

{
  "query": "coragen 30ml",
  "parsed": { "brand_core": "CORAGEN", "modifiers": [], "pack": "30 ml",
              "actives": [], "formulation": null },
  "decision": "auto_accept",
  "matches": [
    { "id": 3199,
      "global_brand_id": "GB-001334",
      "global_product_id": "GP-002082",
      "inheritable": true,
      "brand": "CORAGEN", "manufacturer": "FMC",
      "molecule": "CHLORANTRANILIPROLE 18.5% SC",
      "pack": "30 ml", "segment": "PESTICIDES", "category": "INSECTICIDE",
      "hsn": "38089990",
      "score": 1.0,
      "verdict": "auto_accept",
      "why": "this exact spelling is already known (+1 other signal).",
      "trace": [
        { "rule": "T2 alias_exact", "fired": true, "contribution": 0.92,
          "blocking": false, "note": "this exact spelling is already known" },
        { "rule": "B03 manufacturer_conflict", "fired": false,
          "contribution": 0.0, "blocking": true,
          "note": "manufacturer compatible" }
      ] },
    "…"
  ],
  "blocked": [],
  "other_packs": []
}
FieldWhat to do with it
decision auto_accept — safe to link without asking. review — show the candidates and let a person choose. no_match — nothing resembles it.
verdict the same judgement per candidate, plus blocked
inheritable true when this candidate has a global id and is not blocked — i.e. you may copy its fields onto the dealer's product
why one plain sentence. Put it on the screen; it is written to be read by the person choosing
trace the rule-by-rule scoring. For debugging a surprising result, not for display
blocked candidates deliberately withheld, with the reason. A blocked row is a near-spelling with different chemistry, a different manufacturer or a different pack — offering it is how the wrong product gets linked
id internal. Do not store. It is renumbered on every rebuild

Push products in POST /v1/tenant/{tenant}/products

Send every product the dealer saves — inherited from the directory (with its GlobalProductId and GlobalBrandId), edited, or new. Send the dealer's own document in the shape their master already holds it — nobody should have to build a second representation of a product to use this. ProductId identifies the row, because that is what the dealer's master is keyed on.

POST /v1/tenant/ACME_AGRI/products
Authorization: Bearer <dealer token>
Content-Type: application/json

[ { "ProductId": "ACME-88421",
    "BrandName":   { "en": "Coragen" },
    "CompanyName": { "en": "FMC India" },
    "PackSize": "30 ml" } ]

Between 1 and 500 per call. All or nothing: if any row is rejected the whole batch is, because a partial accept leaves you guessing which products landed. Retrying a whole batch is safe — an unchanged row is a no-op.

202 Accepted

{ "accepted": 1, "new": 1, "changed": 0, "unchanged": 0,
  "run_id": 284,
  "mapping": "inline",
  "mapped": [
    { "ProductId": "ACME-88421",
      "status": "linked",
      "global_brand_id": "GB-001334",
      "global_product_id": "GP-002082",
      "brand": "CORAGEN", "manufacturer": "FMC", "pack": "30 ml",
      "score": 1.0,
      "why": "this exact spelling is already known (+1 other signal)." } ] }
statusMeaningWhat to do
inheritedcarries ids; every directory field agreesnothing — it is linked
modifiedcarries ids; a directory field differs — changes lists eachkeep the ids; a moderator checks
modified_acceptedthe same change a moderator already acceptednothing — still linked
unknown_idid never issued, or retired (current_global_product_id if it moved)keep saving; the links feed corrects it
linkedno ids sent; matched an existing global product with confidenceoffer to store both ids
needs_moderatorcandidates exist, none certainstore unlinked; candidates says what it resembles
new_to_directorynothing resembles itstore unlinked; a moderator will admit it
unmatchableno BrandName to match onfix the payload
For products sent without ids, the mapping is provisional. A push cannot create a canonical product. linked means "this is the existing product it will attach to", not "this is now attached" — the attachment happens when the row clusters in the next build. Pushing the same product twice does not admit it into the directory; a moderator does, from the review queue.

Batches over 100 rows are stored without matching ("mapping": "deferred"), because matching costs a database round trip per row and a truncated list is indistinguishable from a complete one. Push in smaller batches if you want the answer in the response.

What changed about this dealer's links since the last read — how a moderator's decision reaches TradeOps without anyone running a script. Read with the dealer's push token. Start at since=0 (every link the dealer has), store next_since, pass it back; while more is true, read again.

stateDo this to the dealer's row
linkedset the id given — a new product admitted, or linked to another record
dirtykeep the id you hold; a moderator is looking at the dealer's change
unlinkedclear the id; a moderator decided the row is not that record

Events are append-only and in order; a reader that applies them in order always ends in the directory's current state. One dealer's token cannot read another dealer's feed (401).

Errors

CodeMeaning
401missing or wrong key or token
404no such record — also what a quarantined record returns, so a caller cannot enumerate what a moderator withheld
400malformed body; rejected lists which rows and why
503the read API has no keys configured on the server

Every error is {"error": "…"} with a sentence a person can act on.

A C# client, end to end

Enough to paste into a .NET service and start reading.

using System.Net.Http.Json;

var http = new HttpClient { BaseAddress = new Uri("https://<host>/") };
http.DefaultRequestHeaders.Add("X-API-Key", apiKey);

// 1. Has anything changed since the last sync?
var meta = await http.GetFromJsonAsync<Meta>("v1/meta");
if (meta.LastBuild.RunId == lastSeenRunId) return;

// 2. Page through the pack-level records.
int offset = 0;
while (true)
{
    var page = await http.GetFromJsonAsync<Page<ProductRow>>(
        $"v1/products?limit=200&offset={offset}");
    foreach (var row in page.Data)
        Upsert(row.Pack.GlobalProductId, row.GlobalBrandId, row);

    if (page.NextOffset is null) break;   // null means last page
    offset = page.NextOffset.Value;
}

// 3. A dealer typed something. Ask what it is.
var m = await http.GetFromJsonAsync<MatchResult>(
    $"v1/products/match?q={Uri.EscapeDataString(typed)}&tenant=ACME_AGRI");

if (m.Decision == "auto_accept" && m.Matches[0].GlobalProductId is not null)
    LinkTo(m.Matches[0].GlobalProductId);        // safe to link
else
    ShowCandidatesToUser(m.Matches);             // let a person choose
    // and if they save anyway, save it UNLINKED — never block the dealer

// 4. Push a product this directory does not have yet.
var push = new HttpRequestMessage(HttpMethod.Post, "v1/tenant/ACME_AGRI/products")
{
    Content = JsonContent.Create(new[] { new {
        ProductId = "ACME-88421",
        BrandName   = new { en = "Coragen" },
        CompanyName = new { en = "FMC India" },
        PackSize    = "30 ml" } })
};
push.Headers.Authorization = new("Bearer", dealerToken);
var res = await (await http.SendAsync(push)).Content.ReadFromJsonAsync<PushResult>();

foreach (var r in res.Mapped)
    if (r.Status == "linked") LinkTo(r.GlobalProductId);
    // needs_moderator / new_to_directory: save unlinked and try again after
    // the next build. Do not retry in a loop — a person has to decide.
Three rules worth putting in your own code review checklist. Store only GB- and GP- ids. Treat a null global_product_id as "save unlinked", never as an error. Never block a dealer's save on this directory being reachable.

Questions about a specific record, or a match that looks wrong, go to whoever moderates the directory — every record here was ruled on by a person, and they can tell you why.