For software vendors (ERPs, distribution and supply-chain systems) who want to give their own customers ZigoTrace: a rebuilt supply chain, the losses and gaps in it with a value on each, and a way to close them.
https://agri-api.zigotrace.com/v1
GET /v1/openapi.json
/v2
your ERP ──records──▶ ZigoTrace ──webhooks──▶ your ERP │ rebuilds the chain, checks it │ finding.opened / finding.closed │ fix.proposed (the exact record to write) your ERP ◀── you write the record, then it comes back in the next sync ──┘ fix.confirmed + finding.closed
fix.confirmed
finding.closed
A finding closes only when the record really exists. Marking it done is not enough.
Every call except /v1/openapi.json needs your partner key:
/v1/openapi.json
Authorization: Bearer zpk_...
ZigoTrace issues the key. To start building, ask for a free sandbox key: it comes with test organisations already loaded with sample records. The key is shown once and stored only as a hash; if you lose it, ask for it to be rotated.
Organisation keys (zgk_...) do not work on /v1. An organisation you create through the API has no key of its own; your partner key reaches it.
zgk_...
/v1
KEY="zpk_..." API="https://agri-api.zigotrace.com/v1" # 1. Where events should go. Store the secret it returns: it signs every event. curl -X PUT "$API/partner/webhook" -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" -d '{"url": "https://erp.example.com/zigotrace/events"}' # 2. One organisation per customer. Sending the same external_ref again returns the same organisation. curl -X POST "$API/orgs" -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{"name": "Kilimani Foods", "external_ref": "cust-42", "templates": ["horeca_distributor"]}' # 3. Their records. Check the fields against GET /v1/schema/receipts. curl -X POST "$API/orgs/org-.../records/receipts" -H "Authorization: Bearer $KEY" \ -H "Idempotency-Key: grn-2026-09-24-001" -H "Content-Type: application/json" \ -d '{"records": [{"receipt_id": "GRN-881", "lot_id": "LOT-2026-0412", "supplier_id": "SUP-NYERI", "received_date": "2026-09-24", "quantity": 40, "unit": "kg", "location": "WH-1"}]}' # 4. What is open now. curl "$API/orgs/org-.../findings?area=receiving" -H "Authorization: Bearer $KEY"
POST /orgs
name
external_ref
templates
GET /orgs
GET /orgs/{org_id}
PUT /orgs/{org_id}/module
general
horeca
wholesale
retail
freight
module
DELETE /orgs/{org_id}
Templates (kinds of business): agri_exporter, horeca_distributor, fmcg_wholesaler, retailer, logistics_operator. The template decides where a lot's chain has to begin (a farm plot, or a named supplier) and which checks run. If you leave it out, ZigoTrace works it out from the data.
agri_exporter
horeca_distributor
fmcg_wholesaler
retailer
logistics_operator
Another partner's organisation answers 404. Its existence is not disclosed.
GET /v1/schema lists every kind of record, each with its JSON Schema. GET /v1/schema/{entity} returns one. The main ones:
GET /v1/schema
GET /v1/schema/{entity}
receipts
receipt_id
dispatches
dispatch_id
invoices
direction
payable
receivable
invoice_id
stock_movements
movement_id
shipment_legs
harvests
suppliers
farms
batches
contacts
POST /orgs/{org_id}/records/{entity} with {"records": [ ... ]}:
POST /orgs/{org_id}/records/{entity}
{"records": [ ... ]}
rejected
findings: {open, opened, closed, coverage}
?check=false
POST /orgs/{org_id}/check
POST /orgs/{org_id}/imports with {"entity": "receipts", "records": [...]} takes up to 50,000 records. It answers 202 at once with a job; the records are stored and the chain checked in the background. POST /orgs/{org_id}/check?background=true runs a check the same way.
POST /orgs/{org_id}/imports
{"entity": "receipts", "records": [...]}
POST /orgs/{org_id}/check?background=true
{"job_id": "job-5c1e...", "kind": "import", "status": "queued", "result": null, "error": null}
GET /orgs/{org_id}/jobs/{job_id}
queued
running
succeeded
failed
job.done
result
records
error
GET /orgs/{org_id}/jobs
GET /orgs/{org_id}/findings returns what is open now, most serious first. You can filter with area, gap_type, lot and blocking, and page with limit (up to 500) and offset.
GET /orgs/{org_id}/findings
area
gap_type
lot
blocking
limit
offset
{ "finding_id": "dist_purchase_unbilled|lot:LOT-2026-0412|", "gap_type": "dist_purchase_unbilled", "label": "Received with no purchase invoice", "area": "receiving", "severity": 0.6, "priority": "High", "blocking": false, "node_id": "lot:LOT-2026-0412", "lot": "LOT-2026-0412", "description": "lot LOT-2026-0412 was received from a supplier but no purchase invoice references it ...", "evidence": { "...": "..." }, "fix": { "verb": "Record the supplier's invoice", "asks": "supplier", "fields": [ {"name": "invoice_no", "label": "Invoice number", "kind": "text", "required": true}, {"name": "amount", "label": "Amount", "kind": "number", "required": true}, {"name": "issue_date", "label": "Invoice date", "kind": "date", "required": true} ], "prefill": {"issue_date": "2026-09-24"} } }
traceability
receiving
inventory
deliveries
billing
loss
wastage
cold_chain
compliance
other
fix
null
asks
supplier
customer
finance
warehouse
GET /orgs/{org_id}/findings/summary
GET /orgs/{org_id}/reports lists the reports for that organisation's books:
GET /orgs/{org_id}/reports
Each entry has its headline and metrics. Download a report with GET /orgs/{org_id}/reports/{report_id}.pdf or .docx.
GET /orgs/{org_id}/reports/{report_id}.pdf
.docx
The same step-by-step workflows the ZigoTrace app shows are available here:
GET /orgs/{org_id}/workflows/{plugin_id}
GET /orgs/{org_id}/workflows/{plugin_id}/{step_key}/resolution
POST .../resolution/ask
{"recipient": "party:SUP-NYERI"}
"send": true
POST /orgs/{org_id}/fixes with {"finding_id": "...", "values": {...}}. Values already on file are filled in for you, and anything you send overrides them. The response (201) is a proposal:
POST /orgs/{org_id}/fixes
{"finding_id": "...", "values": {...}}
{ "proposal_id": "prop-9f2c...", "finding_id": "dist_purchase_unbilled|lot:LOT-2026-0412|", "status": "proposed", "records": [ {"entity": "invoices", "operation": "upsert", "record": {"invoice_id": "...", "direction": "payable", "amount": 5200, "batch_id": "LOT-2026-0412", "...": "..."}} ], "values": {"invoice_no": "INV-9", "amount": 5200, "issue_date": "2026-09-24"}, "source": "api" }
proposed
acknowledged
POST /proposals/{id}/ack
erp_reference
confirmed
POST /proposals/{id}/reject
reason
Proposals are also made when your customer resolves a finding inside the ZigoTrace app, or when a supplier answers a link. source says which: resolve or respond. Each one reaches you as a fix.proposed event. There is at most one open proposal per finding; proposing again replaces the records on the one still waiting.
source
resolve
respond
fix.proposed
GET /orgs/{org_id}/proposals?status=proposed lists proposals; GET /orgs/{org_id}/proposals/{id} gets one.
GET /orgs/{org_id}/proposals?status=proposed
GET /orgs/{org_id}/proposals/{id}
PUT /partner/webhook with {"url": "https://..."} sets the endpoint. The response includes webhook_secret the first time, or when you pass "rotate_secret": true. Store it; it is not shown again.
PUT /partner/webhook
{"url": "https://..."}
webhook_secret
"rotate_secret": true
Events are sent only while an endpoint is set; nothing is queued before you set one, or after you remove it. Whatever happened meanwhile is in GET /orgs/{id}/findings and GET /orgs/{id}/proposals.
GET /orgs/{id}/findings
GET /orgs/{id}/proposals
Event types:
finding.opened
ping
POST /partner/webhook/test
Every event is sent in the same envelope:
{"id": "evt-3a9...", "type": "fix.proposed", "created_at": "2026-09-24T09:12:03Z", "org_id": "org-...", "external_ref": "cust-42", "data": { ... }}
Verifying a delivery. Each request carries Zigo-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" keyed with your webhook secret. Reject a request when the signature doesn't match, or when t is more than 5 minutes old.
Zigo-Signature: t=<unix seconds>,v1=<hex>
v1
"<t>.<raw body>"
t
import hashlib, hmac, time def verify(secret: str, raw_body: bytes, header: str) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) t = int(parts["t"]) if abs(time.time() - t) > 300: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts["v1"])
const crypto = require("crypto"); function verify(secret, rawBody, header) { const parts = Object.fromEntries(header.split(",").map((p) => p.split("="))); if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false; const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest("hex"); return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1)); }
Delivery and retries.
id
GET /partner/webhook/deliveries?status=failed
POST /partner/webhook/deliveries/{id}/retry
POST /orgs, POST /orgs/{org_id}/records/{entity} and POST /orgs/{org_id}/fixes accept an Idempotency-Key header:
Idempotency-Key
Idempotent-Replay: true
Show a customer their ZigoTrace views inside your own product, in an iframe, without them signing in to ZigoTrace.
POST /orgs/{org_id}/embed-tokens:
POST /orgs/{org_id}/embed-tokens
{"views": ["gaps", "reports", "resolve"], "plugin_id": "horeca", "step_key": "goods_receiving", "ttl_minutes": 60, "theme": {"primary": "#0B7A75", "name": "Acme ERP"}}
It returns {"token", "url", "expires_at"}. Load the url in an iframe:
{"token", "url", "expires_at"}
url
<iframe src="https://agri.zigotrace.com/embed?t=eyJ..." style="width:100%;height:900px;border:0"></iframe>
gaps
reports
plugin_id
step_key
ttl_minutes
theme.primary
theme.name
GET /samples lists a year of generated records for each kind of business: horeca_distributor, fmcg_wholesaler, retailer, logistics_operator and agri_exporter.
GET /samples
POST /orgs/{org_id}/sample with {"template": "horeca_distributor"} loads one into a test organisation in the background. It answers 202 with a job, sets the organisation's kind of business to match, and checks the chain. The sample files are messy spreadsheets like real customers' files, read by the same sync your customers' uploads go through. Loading a sample does not count toward your usage.
POST /orgs/{org_id}/sample
{"template": "horeca_distributor"}
To build before you have a contract, ask ZigoTrace for a sandbox: a free partner key on Max, with one organisation per kind of business already loaded (external_ref sample-<template>).
sample-<template>
Generated from this spec and kept in step with it. Each package's version is the API version it was generated from.
pip install zigotrace-partner
from zigotrace_partner import ZigoTracePartner, verify_webhook zt = ZigoTracePartner("zpk_...") org = zt.create_org({"name": "Kilimani Foods", "external_ref": "cust-42"}) zt.push_records(org["org_id"], "receipts", {"records": rows}, idempotency_key="grn-001")
fetch
npm install @zigotrace/partner
import { ZigoTracePartner, verifyWebhook } from "@zigotrace/partner"; const zt = new ZigoTracePartner("zpk_..."); const org = await zt.createOrg({ name: "Kilimani Foods", external_ref: "cust-42" });
Both include verify_webhook / verifyWebhook. Every method returns the JSON body, or bytes for a PDF or Word file, and raises ZigoTraceError with the status and the API's explanation. Packages: zigotrace-partner on PyPI and @zigotrace/partner on npm.
verify_webhook
verifyWebhook
ZigoTraceError
GET /partner shows your terms:
GET /partner
max
pro
GET /partner/usage?period=YYYY-MM returns the month's statement; it defaults to the current month in UTC. For each organisation it shows records sent, checks, proposals, API calls and whether the organisation was active, then the total:
GET /partner/usage?period=YYYY-MM
The figures below are illustrative; yours are in your contract.
{"period": "2026-09", "plan": "max", "currency": "USD", "price_per_org_cents": 1000, "active_orgs": 12, "subtotal_cents": 12000, "minimum_cents": 25000, "total_cents": 25000, "total": "USD 250.00", "minimum_applied": true, "orgs": [ ... ]}
An organisation is active in a month when records were sent for it or its chain was checked. An organisation you created but didn't use costs nothing, and reading findings or reports alone doesn't make one active. ZigoTrace invoices you monthly from this statement.
Each response carries X-RateLimit-Limit and X-RateLimit-Remaining. Over your limit (600 requests a minute unless your contract says otherwise), calls answer 429 with a Retry-After header in seconds. For large volumes, use one background import rather than many small calls.
X-RateLimit-Limit
X-RateLimit-Remaining
Retry-After
Errors come back as {"detail": "..."}, written to be shown to a person.
{"detail": "..."}