Publisher guide
Publish your products
Publishing to prodid means putting two files on the web and one record in DNS. On your own domain there is nothing to sign up for and no key to keep: the domain is the credential, and you change or withdraw what you publish by changing the files.
How will you publish?
Identity and hosting are separate. Your identity is a domain you control; where the documents are actually served is a detail you can change later without changing your identity or any identifier prodid has assigned to your products. Three routes, all producing exactly the same records.
Route 1
You can serve files on your own domain.
Serve the discovery document on the domain, and publish a TXT record naming the domain itself.
- Identity
- the domain
- Trust
- verified
Route 2
You own a domain but cannot serve files from it.
Publish at a hosted slug and point one TXT record on your domain at that host.
- Identity
- the domain, proven by DNS
- Trust
- verified
Route 3
You have no domain at all.
Publish at a hosted slug. Add a domain later without losing anything.
- Identity
- the slug host
- Trust
- unverified
Routes 2 and 3 use the hosted publishing service at <slug>.prodid.org. Sign in at publish.prodid.org with a link sent to your email, create a site, upload your file, and connect a domain if you have one. You can export everything or delete the site there at any time.
Route 1
Publish on your own domain
Two files and one DNS record. A platform publishing on behalf of merchants does the same thing on each merchant domain, so every merchant keeps its own identity and its own trust level.
Serve the discovery document
One file, at a fixed path, listing where your product feeds are:
https://acme.example/.well-known/prodid.json.well-known/prodid.jsonapplication/json{ "$type": "org.prodid.feed", "version": 1, "publisher": { "name": "Acme Tekstil", "contact": "data@acme.example" }, "license": "CC0-1.0", "feeds": ["https://acme.example/prodid/items.ndjson"] }The example from spec/fixtures/v1/examples/prodid.json, rendered from the file itself. $typeRequired: yes- Always org.prodid.feed.
versionRequired: yes- 1. A document with 0 is accepted by crawls that start before 2027-01-26 00:00 UTC and refused after, as a definite negative.
publisher.nameRequired: yes- The name a person should see next to your claims.
publisher.contactRequired: no- An email address someone actually reads. It is where the index writes when your source lapses, which is the only warning you get.
licenseRequired: yes- SPDX identifier covering the facts in your feeds, such as CC0-1.0.
feedsRequired: yes- Up to 100 https URLs, every one on the same host as this document.
domainRequired: when delegating- The domain this document speaks for. Required whenever the document is reached through a TXT record naming some other host, and then it must equal that domain. Optional when the domain serves the document itself, and if present it must still match.
Serve it over HTTPS on the default port, with a valid certificate, as
application/json— or anyapplication/…+json. Do not redirect it to another host: the one redirect the crawler follows isacme.exampletowww.acme.example.Serve the feed
A feed is NDJSON: one complete JSON object per line, UTF-8, newline separated. No array around it, no commas between lines. That way a feed of two million products streams line by line, and one bad line costs you that line rather than the rest.
prodid/items.ndjsonapplication/x-ndjson{"$type":"org.prodid.item","key":"TJ-2041-NVY-M","brand":"Acme","name":"Trail Jacket","lang":"en","group":"TJ-2041","variant":{"color":"navy","size":"M"},"identifiers":[{"scheme":"gtin","value":"09506000134352"},{"scheme":"mpn","value":"TJ-2041-NVY-M"}],"markets":["TR","DE"],"properties":{"material":"100% recycled polyester","madeIn":"TR","weight":{"value":480,"unit":"g"}},"media":[{"url":"https://acme.example/img/tj2041-navy.webp","license":"CC-BY-4.0","kind":"image"}]} {"$type":"org.prodid.item","key":"TJ-2041-NVY-L","brand":"Acme","name":"Trail Jacket","lang":"en","group":"TJ-2041","variant":{"color":"navy","size":"L"},"identifiers":[{"scheme":"gtin","value":"09506000134406"},{"scheme":"mpn","value":"TJ-2041-NVY-L"}],"markets":["TR","DE"],"properties":{"material":"100% recycled polyester","madeIn":"TR","weight":{"value":505,"unit":"g"}},"media":[{"url":"https://acme.example/img/tj2041-navy.webp","license":"CC-BY-4.0","kind":"image"}]} {"$type":"org.prodid.item","key":"TJ-2041-OLV-M","brand":"Acme","name":"Trail Jacket","lang":"en","group":"TJ-2041","variant":{"color":"olive","size":"M"},"identifiers":[{"scheme":"gtin","value":"09506000134413"},{"scheme":"mpn","value":"TJ-2041-OLV-M"}],"markets":["TR"],"properties":{"material":"100% recycled polyester","madeIn":"TR","weight":{"value":480,"unit":"g"}},"media":[{"url":"https://acme.example/img/tj2041-olive.webp","license":"CC-BY-4.0","kind":"image"}]}Three variants of one product, from spec/fixtures/v1/examples/items.ndjson. They share a group, so the index builds one family with three variants from them. Feeds live on the same host as the document that lists them. A discovery document naming a feed on another host is rejected whole, not in part — you lose the whole source, not one feed, and it counts as a definite negative, so it starts the lapse clock below. Serving the JSON from your site and the NDJSON from a separate CDN hostname is a normal thing to want and it is not supported in v1. Put both on one host.
The first of those lines, laid out to read:
one org.prodid.item record{ "$type": "org.prodid.item", "key": "TJ-2041-NVY-M", "brand": "Acme", "name": "Trail Jacket", "lang": "en", "group": "TJ-2041", "variant": { "color": "navy", "size": "M" }, "identifiers": [ { "scheme": "gtin", "value": "09506000134352" }, { "scheme": "mpn", "value": "TJ-2041-NVY-M" } ], "markets": ["TR", "DE"], "properties": { "material": "100% recycled polyester", "madeIn": "TR", "weight": { "value": 480, "unit": "g" } }, "media": [{ "url": "https://acme.example/img/tj2041-navy.webp", "license": "CC-BY-4.0", "kind": "image" }] }$typeRequired: yes- Always org.prodid.item.
keyRequired: yes- Your stable name for this record inside the feed. Publishing the same key again replaces it.
brandRequired: yes- The brand as it appears on the product. It is normalized before it scopes an mpn, so ARÇELİK and Arçelik are one brand, not two.
nameRequired: yes- The product name, without the variant axes.
identifiersRequired: yes- At least one { scheme, value }, and at least one the index can store. Values carry no whitespace at either end and are not blank. A gtin must be 8, 12, 13 or 14 digits with a correct check digit and not all zeros; an mpn needs a brand that normalizes to something, because that is the scope it is unique within; a restricted-circulation gtin is kept, but only as your own rcn. A record left with nothing storable identifies nothing and is skipped.
langRequired: no- BCP 47 tag for the human-readable text in this record.
groupRequired: no- Your own grouping key. Items that share one are variants of a single family.
variantRequired: no- The axes that distinguish this item within the group: color, size, capacity.
categoryRequired: no- GS1 GPC brick code, written gpc: followed by eight digits.
marketsRequired: no- ISO 3166-1 alpha-2 codes of the markets this item is sold in.
propertiesRequired: no- Strings, numbers, booleans, lists, or { value, unit } for a measurement.
mediaRequired: no- Up to 20 https URLs, each with a kind and an SPDX license from the ingestible list. Unlike feeds, these may be on any host.
Send flat items. You never build families or variants yourself and you never send an
opid— the index derives the first fromgroupand assigns the second. If you already produce a Google Merchant feed,groupis youritem_group_id.Two things about barcodes are worth knowing before you export. A
gtinof all zeros is not a barcode, it is a catalogue placeholder, and a record carrying one is rejected like a bad check digit. A restricted circulation number — the in-store ranges — is stored and attributed to you, but it identifies nothing globally, so it never merges your product with anyone else's and/01/will not resolve it.Claim the identity in DNS
Once the files are up, publish one record naming your own domain as the host.
_prodid.acme.example TXT_prodid.acme.example. IN TXT "v=prodid0; host=acme.example"It points at exactly what discovery would have found anyway, so nothing about your data changes. What changes is what someone with add-only access to your DNS can do. With no record, a record they add takes your identity over silently, because DNS wins over the file. With your record already there, a second one makes discovery fail with more than one record — a lapse, which is visible, notified and recoverable.
Never delegate
_prodidto a vendor. Write access to_prodid.<domain>is control of your identity, exactly as it is for DKIM or an ACME DNS-01 challenge. A domain whose DNS is shared is an identity that is shared. Give an agency the feed, not the record.Put the files up before the record, not after: a record pointing at a document that is not there yet is a failed discovery. Values are read case-insensitively and one trailing dot is fine, so
ACME.Example.andacme.exampleare the same host.Tell the index it changed
Optional. Every known domain is re-crawled on a schedule, and a ping only makes the next crawl happen sooner.
ask the index to run discovery nowcurl -X POST https://prodid.org/ping \ -H 'content-type: application/json' \ -d '{"domain": "acme.example"}' HTTP/1.1 202 Accepted Content-Type: application/json {"status":"accepted"}{"domains": [...]}takes up to 1000 at once, which is how a platform introduces every merchant it hosts. There is no key to send, because the ping gives the caller no say over content — and202is all it ever says. It does not tell you whether the domain was known, whether anything was found or whether anyone else pinged it a minute ago. A429means only that you are over your own request budget, and errors come back asapplication/problem+json. What happened to your domain shows up in your source status, not in the ping.
Route 2
Delegate the hosting, keep the identity
Plenty of manufacturers own a domain and cannot put a file on it. The feed is hosted somewhere else and the domain proven with DNS, so the source identity is still the domain and the trust level still verified. Create a site at publish.prodid.org, enter your domain, and it shows the exact record to add and checks it for you.
What it looks like
A catalogue uploaded at a hosted slug
say
acme.prodid.org— which serves both the discovery document and the feed.One TXT record on the domain you own
, naming that host. A host, never a URL, so a record can never aim the crawler at an arbitrary address.
_prodid.acme.example TXT_prodid.acme.example. IN TXT "v=prodid0; host=acme.prodid.org"A document that names your domain back.
DNS says my document is over there; the document says I speak for that domain. A delegated document without
domainis rejected — absence is never read as consent, which is what stops anyone pointing a record at somebody else's feed.acme.prodid.org/.well-known/prodid.jsonapplication/json{ "$type": "org.prodid.feed", "version": 1, "domain": "acme.example", "publisher": { "name": "Acme Tekstil", "contact": "data@acme.example" }, "license": "CC0-1.0", "feeds": ["https://acme.prodid.org/feeds/items.ndjson"] }From spec/fixtures/v1/examples/prodid.delegated.json. The only difference from route 1 is the domain field, and here it is mandatory. Create your site
The hosted publisher writes
domaininto the document only after it has seen your TXT record itself, so there is no window in which a document claims a domain that has not asked for it.
Route 3
Publish without a domain
Create your site
A producer with no domain at all publishes at a hosted slug,
<slug>.prodid.org, under its own name. Claim one at publish.prodid.org. The source identity is the slug host and the trust levelunverified, because nothing has been proven about a domain. The records are real records: identifiers, resolution, attribution.Most publishers in this position already keep a Google Merchant product file, so the mapping from one of those to
org.prodid.itemrecords is written out in the specification, column by column, along with what it deliberately drops: price, availability, shipping and condition belong to a seller on a day, not to the product. Importing a Merchant feed is worth reading before you export: publish.prodid.org converts the CSV or TSV you upload with exactly that mapping. Four things in it decide what your spreadsheet has to look like:- Four columns become variant axes —
color,size,materialandpattern, the ones a buyer picks between within oneitem_group_id.gender,age_group,size_typeandsize_systemstay ordinary properties: they describe the whole group, and as axes they would split one product into several the first time somebody spelled one of them two ways. - A repeated attribute is comma-separated or repeated-header, per file, not per cell. A cell is split on commas only when its header appears once in the whole file; repeat the header and every cell under it is taken whole. That is the only way to carry a value with a comma in it, and it is a decision you make once for the file. It applies to
additional_image_link,product_type,product_highlight,product_detailandcertification; everything else, adescriptionfull of commas included, is taken whole either way. - A
product_detailnever overwrites anything. Its middle sub-attribute becomes the property key, the section name is dropped, and the first value for a key wins. A later detail with the same key, or one that lands on a key the mapping already produced, is dropped and reported with its row — so a detail column can never quietly redefine what the rest of the row said. - A media licence has to be declared. The Merchant format has no column for one and nothing is ingested without one, so an import declares a single ingestible SPDX identifier covering every image and document in the file. A
licensecolumn of your own overrides it row by row, and a file whose declared licence is not ingestible produces records without media rather than records that fail.
A row that cannot become a record — no usable identifier, a GTIN that fails its check digit — is skipped and reported by spreadsheet row number, header counted as row 1, so one bad row costs that row and not the import. There is a worked file to check yourself against in /spec/fixtures/merchant: a CSV, the records it must produce, and the row that must be skipped.
The part that matters for a decision made today is what happens later. Adding a TXT record on a domain you acquire, naming that same host, turns the slug into the domain at the next crawl — the same source, the same records, every
opidkept, with trust rising fromunverifiedtoverified. Yourskuidentifiers are scoped to the source itself rather than to its name, so they survive the move. On publish.prodid.org that is the same domain field route 2 uses.- Four columns become variant axes —
Check your domain
Done publishing? Enter your domain. The index is asked to look at it now, and you land on its live status page.
Before you go live
Check your files before you ping
prodid-batch check validates your discovery document and each feed exactly as the index would, with no token and without asking the index anything. It runs from a clone of the repository with Bun 1.4.4. Give it the files themselves: a path ending in .json is read as one document, any other path as an NDJSON feed.
git clone https://github.com/productdevbook/prodid
cd prodid
bun install --frozen-lockfile
curl -fsSo prodid.json https://acme.example/.well-known/prodid.json
curl -fsSo items.ndjson https://acme.example/prodid/items.ndjson
bun apps/batch/src/cli.ts check prodid.json items.ndjson
prodid.json: ok, 1 record
items.ndjson: ok, 3 recordsA file with no problem prints ok and the number of records the index would take from it. Otherwise it prints one line per problem, as items.ndjson:12: message with the feed line it is on, and exits non-zero. It reads the files, not your DNS or your server, so the TXT record, the content type and the caching headers in the list above are still yours to check.
The rules a record is validated against are in the specification, the schemas are served at /spec/schemas/v1, and /spec/fixtures/v1 holds machine-readable vectors you can run your exporter against before anyone crawls it.
Status: Spec v1. The index and the hosted publisher run on prodid.org. Anyone can publish, on their own domain or hosted. The file formats on this page are the ones the index reads when you ping it.
Reference
Worth knowing before anything else, because it decides which of the three routes below is yours. Discovery runs from scratch on every crawl, DNS first, and nothing about the path to your data is remembered between crawls.
First
A DNS TXT record at
_prodid.acme.exampleIf a
v=prodid0record is there it is followed exclusively, even when it names your own domain. The record names a host; the document is alwayshttps://<host>/.well-known/prodid.json.Only if there is no such record
https://acme.example/.well-known/prodid.jsonThe document on the domain itself. Whoever serves the domain over HTTPS speaks for it.
DNS comes first because it is the stronger proof: whoever can put a file on a host is not always who owns the domain, and the owner has to be able to override that. Which is also why you should write the record even when you serve the file yourself — route 1, step 3 explains what it buys you.
Exactly one record, and nothing behind it. More than one v=prodid0 record at that name is an error, not a choice between them, and a single malformed or over-long one is an error too. There is no fallback: once a record exists, a record that does not work ends discovery rather than sending the crawler to look at your domain instead. If you are moving hosts, replace the record — never add a second. The grammar, with test vectors, is in the specification.
What counts as an answer. HTTP 200 after at most one redirect, a JSON content type, a body that validates against the feed schema, and a domain that names the domain being discovered. A site that answers unknown paths with an HTML page and a 200 is not answering, and neither is a document served as text/html.
Trust is a property of the source, not of a claim. It says what has been proven about who is speaking, and nothing about whether a particular fact is correct. A consumer of the data decides what to do with that.
| Level | Meaning |
|---|---|
| authoritative | Domain control proven and ownership of the brand confirmed. |
| verified | Domain control proven, by the TXT record or by the domain serving the document. |
| unverified | A hosted slug with no domain, a dataset import, or a community contribution. |
Only a source whose identity is a domain can be more than unverified. verified comes from the crawl. authoritative does not, because no crawl can prove who owns a brand: the index's operator grants it, and only on evidence.
The evidence is your GS1 company prefix.
The GS1 company prefix of the GTINs your source publishes under the brand must be licensed to the organization that controls your domain, as GS1's public “Verified by GS1” lookup shows. The URL of that lookup is kept with the grant as its evidence.
A grant names one brand, and each can be revoked.
The brand is recorded in its normalized form. A source may hold grants for several brands, and the operator revokes each on its own.
A grant does not outlive the proof under it.
Your source is
authoritativeexactly while control of its domain is proven, it is not lapsed, and it holds at least one grant that has not been revoked. Lose the domain proof or lapse, and it drops at once to what its remaining proof supports —verifiedwhile lapsed, lower if the domain proof is gone too — with no one acting. It isauthoritativeagain as soon as all three hold again.It covers the source, not the brand.
Trust stays a property of the source: a grant for one brand makes every claim your source makes
authoritative, including claims about brands it holds no grant for.
To ask for it, publish on your own domain first, then ask with the request form, naming your domain, the brand and a Verified by GS1 lookup for one of its GTINs. The operator checks the GTINs your source publishes under that brand in Verified by GS1. The issue is public.
Publishing a record with a key that already exists replaces the previous one. There is no update endpoint, because the feed is the state.
A key that disappears from a feed the crawler fetched whole marks your claims about it as withdrawn. The entity and its identifier stay: other people's links to it keep working, and so do the claims other sources made.
When a crawl reads a document that no longer names one of your feed URLs, every live record that feed carried is withdrawn at once, as if all of its keys had vanished, and the URL is not fetched again. None of the seven-day grace below applies: dropping a URL is you saying the file is gone, and the index takes you at your word. Listing it again later brings it back, read from scratch. Serve the document with an ETag if you can — a 304 means the list has not changed, so it retires nothing.
A line that is over 64 KiB, unparseable or invalid is skipped and counted. Because a skipped line's key is unknown, a feed with skipped lines withdraws nothing — for seven days. After that, absent keys are withdrawn as usual. The count is public, so a feed quietly shedding lines is not quiet.
A crawl succeeds only when everything the document lists was read: an acceptable document, and every feed in it answering 200 or 304. One feed timing out does not lose you the others — the feeds that answered are ingested, and nothing is withdrawn on a crawl that failed — but the crawl counts as a transport failure, and feeds_failed says so in your status. A perfectly healthy document in front of one permanently dead feed URL therefore lapses on the same seven-day clock as a domain that stopped answering altogether, and loses the catalogue thirty days after that. A feed URL you no longer serve should be removed from the document, not left to rot.
A definite negative — discovery ending with no acceptable document: no record and no document, a record that is malformed, over-long or duplicated, or a document that is not 200, not JSON, not valid, no longer agrees about domain, or lists a feed off its host — lapses the source at once. A transport failure — a DNS timeout, a 5xx, a 429, a cap reached, on the document or on any feed — changes nothing by itself, but seven consecutive days in which every attempt ends that way lapse you too, so serving errors forever is not a way to freeze your claims where they are.
That benefit of the doubt belongs to a host, not to you. It applies only at a host your source has already been verified at. At a host it has never been verified at — a new slug, a host you have just re-pointed _prodid at, anything under routes 2 and 3 — any failure, a timeout and a 5xx included, is a definite negative and lapses you at once. That is deliberate: otherwise a verified source could aim its record at a host it does not control and read back, from whether its lapse starts today or in a week, whether that host is up. Moving hosts costs you nothing for it. The first crawl that succeeds at the new host clears everything, and a lapse stays recoverable for the 30 days before anything is withdrawn.
A lapsed source takes no new claims, is shown as lapsed to readers, and its publisher.contact is notified. A crawl of a lapsed source writes nothing until it has succeeded in full; only then is the lapse cleared. After 30 days of continuous lapse your records and their active claims are withdrawn together, and remain as history. Those 30 days are your recovery window, which is why the contact address has to be one somebody reads.
Coming back does not require changing anything. Serve the same document and the same feed you always did and everything you publish is asserted again the moment discovery succeeds, whether or not a byte moved — the index gives up conditional requests while it holds nothing live from you, so an unchanged file is never a reason to stay withdrawn.
Every claim a reader sees carries seen_at: not when the fact was first published anywhere, but since when this source has asserted this value without stopping. It survives every re-crawl that finds the same value, which is the point of it. It does not survive a withdrawal. Let your source stay lapsed past 30 days and the date on every fact you publish restarts from the day you come back, because for the length of the gap nobody verifiable stood behind them. The withdrawn rows stay, so ?history=1 shows the gap rather than hiding it — what a reader sees is in the specification.
/source/acme.example is how you see that something is wrong: the outcome of the last crawl, which path discovery took, how many records changed, how many lines were skipped, how many feeds could not be read, and whether you are lapsed and since when. It reports only what your domain publishes and when the index looked — never who asked for a crawl.
{
"$type": "org.prodid.source",
"version": 1,
"name": "acme.example",
"kind": "domain",
"trust": "verified",
"verified_by": "dns-txt",
"verified_at": "2026-09-14T09:12:03Z",
"lapsed": false,
"license": "CC0-1.0",
"last_crawl": {
"at": "2026-09-19T06:00:11Z",
"via": "dns-txt",
"document_url": "https://acme.prodid.org/.well-known/prodid.json",
"outcome": "success",
"http_status": 200,
"records_seen": 3,
"records_changed": 1,
"lines_skipped": 0,
"feeds_failed": 0,
"media_failed": 0
}
}The type is decided by reading the bytes; your Content-Type is a hint. Six types are stored and nothing else: JPEG, PNG, GIF, WebP and AVIF as image, PDF as document. No SVG, no HTML, no archives, no Office files — a spec sheet goes out as a PDF. The sniffed type and the entry's kind have to agree, or the entry is skipped.
| What | Cap |
|---|---|
| One image | 20 MiB |
| One document | 50 MiB |
| One media fetch | 60 s |
| Media entries in one record | 20 |
The licence is checked as a string before anything is fetched, and it must be exactly one of seven SPDX identifiers — the ones that let anyone redistribute the file verbatim, which is what the index does with it:
CC0-1.0 CC-BY-4.0 CC-BY-SA-4.0 CC-BY-ND-4.0
CC-BY-3.0 CC-BY-SA-3.0 CC-BY-ND-3.0Everything here is per entry. An entry that is refused, too big or unreachable costs that entry and nothing else: your record is never invalid because of its media, its other claims stand, and no media failure lapses you. It is counted in media_failed and tried again on the next crawl of that record. One thing works the other way from feeds: the same-host rule does not apply here, so your images can live on a CDN. What is ingested and the licence list have the reasoning.
Retailers, registries, open datasets and contributors are sources like any other. Their claims are attributed to them and never overwrite yours; where you disagree, both claims are visible with their sources side by side.