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.
| What | Endpoints | Credential |
|---|---|---|
| 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>
Integrating dealers (TradeOps)
The whole flow a dealer platform follows, and the only calls it needs. Four moments:
1 A new dealer joins
POST /v1/tenantsthe push token is in the reply — store itGET /v1/companiesGET /v1/segmentsGET /v1/products?company=&category=save with both ids2 Dealer adds a product
GET /v1/brands?q=&packs=true3 Push every save
POST /v1/tenant/{tenant}/products4 Stay in sync
GET /v1/tenant/{tenant}/links?since=| When | Call | Credential |
|---|---|---|
| A dealer is onboarded | POST /v1/tenants | read key |
| Dealer chooses what to download | GET /v1/companies · GET /v1/segments | read key |
| Download the chosen products | GET /v1/products?company=&category=&limit=500&offset= | read key |
| Dealer types in Add Product | GET /v1/brands?q=&packs=true | read key |
| Dealer pastes a free-text line | GET /v1/products/match?q=&tenant= | read key |
| Any product saved, added or edited | POST /v1/tenant/{tenant}/products | dealer's push token |
| Keep ids current | GET /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.
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.
| Parameter | Meaning |
|---|---|
| q | free text over brand, chemistry and manufacturer |
| company | manufacturer key, from /v1/companies |
| segment | PESTICIDES, FERTILIZERS, SEEDS, PGR_BIOLOGICALS, EQUIPMENT |
| category | category key, from /v1/segments |
| crop | crop key — mostly meaningful for seeds |
| hsn | exact HSN code |
| packs | true includes pack sizes in the same call |
| limit, offset | paging; 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 } }
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.
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.
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": [] }
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.
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.
| Parameter | Meaning |
|---|---|
| q | required. What the person typed |
| pack | pack size, if held separately |
| company | manufacturer as the dealer spells it — resolved, not matched literally |
| tenant | the dealer's code; scopes their own aliases |
| limit | candidates 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": []
}
| Field | What 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)." } ] }
| status | Meaning | What to do |
|---|---|---|
| inherited | carries ids; every directory field agrees | nothing — it is linked |
| modified | carries ids; a directory field differs — changes lists each | keep the ids; a moderator checks |
| modified_accepted | the same change a moderator already accepted | nothing — still linked |
| unknown_id | id never issued, or retired (current_global_product_id if it moved) | keep saving; the links feed corrects it |
| linked | no ids sent; matched an existing global product with confidence | offer to store both ids |
| needs_moderator | candidates exist, none certain | store unlinked; candidates says what it resembles |
| new_to_directory | nothing resembles it | store unlinked; a moderator will admit it |
| unmatchable | no BrandName to match on | fix the payload |
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.
Links feed GET /v1/tenant/{tenant}/links
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.
| state | Do this to the dealer's row |
|---|---|
| linked | set the id given — a new product admitted, or linked to another record |
| dirty | keep the id you hold; a moderator is looking at the dealer's change |
| unlinked | clear 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
| Code | Meaning |
|---|---|
| 401 | missing or wrong key or token |
| 404 | no such record — also what a quarantined record returns, so a caller cannot enumerate what a moderator withheld |
| 400 | malformed body; rejected lists which rows and why |
| 503 | the 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.
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.