# UPCguru API

Universal product identifier API. Accepts UPC, EAN, GTIN, ISBN, ISSN, JAN, ITF-14, ASIN and MPN, and returns one normalised product record.

- Base URL: `https://api.upcguru.com/v1`
- Auth: `X-API-Key` header
- Format: JSON, UTF-8. Binary only for barcode images.
- Docs: `https://upcguru.com/api-docs`
- This file: `https://upcguru.com/api-docs.md`

## Core model

Every GS1 identifier normalises to **GTIN-14**, which is the canonical primary key. Shorter forms are left padded.

| Input | Handling |
|---|---|
| UPC-A, 12 digits | Left pad two zeros |
| UPC-E, 8 digits | Expand to UPC-A per GS1 rules, then pad |
| EAN-13 | Left pad one zero |
| EAN-8 | Left pad six zeros |
| GTIN-14, ITF-14 | Already canonical |
| ISBN-13 | Already a GTIN, 978 or 979 prefix |
| ISBN-10 | Converted to ISBN-13, check digit recomputed |
| ISSN | Converted to EAN-13, 977 prefix |
| JAN | An EAN-13 under a Japanese prefix |
| ASIN, MPN, SKU | Alias identifiers only, never the canonical key |

Punctuation is ignored, so `978-0-306-40615-7` and `9780306406157` are the same input.

## Authentication

Send your key on every request:

```
X-API-Key: upc_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

`Authorization: Bearer <key>` also works.

### Keys belong to apps

A key is issued against a registered, approved application, and requests are checked against what your app claims to be.

| Platform | Requirement |
|---|---|
| `web` | The `Origin` header must match an origin registered for the app |
| `ios`, `android` | Send `X-Bundle-Id` matching the app's registered bundle identifier |
| `server` | No binding |

A key for an app that is still pending review returns `403 APP_NOT_APPROVED`. Keys start working the moment the app is approved.

### Getting a key

1. Register your app: name, platform, and either its origins (web) or bundle identifier (mobile).
2. Wait for approval.
3. On approval a key is issued. **It is shown once.** Only a hash is stored, so it cannot be recovered later, only rotated.

## Endpoints

### GET /lookup/{identifier}

Resolve one identifier to a product. This is the primary endpoint.

Query parameters:

| Name | Default | Meaning |
|---|---|---|
| `trace` | `0` | Set `1` to include the full resolution trace in `meta` |
| `paid` | `1` | Set `0` to forbid paid providers for this request |
| `refresh` | `0` | Set `1` to bypass the cache and re-resolve |

```bash
curl -H "X-API-Key: $KEY" \
  "https://api.upcguru.com/v1/lookup/3017620422003"
```

```json
{
  "success": true,
  "identifier": {
    "input": "3017620422003",
    "type": "ean13",
    "gtin14": "03017620422003",
    "valid": true,
    "gs1_prefix": "301",
    "issuing_country": "France",
    "issuing_country_code": "FR",
    "classification": "standard",
    "cacheable": true,
    "aliases": { "gtin14": "03017620422003", "ean13": "3017620422003" }
  },
  "product": {
    "title": "Nutella",
    "brand": "Nutella",
    "category": "Pâtes à tartiner",
    "description": null,
    "images": [{ "url": "https://...", "primary": true }],
    "attributes": [
      { "key": "quantity", "value": "400 g", "confidence": 0.62, "source": "openfoodfacts" }
    ],
    "identifiers": {},
    "confidence": 0.867,
    "category_normalized": "Hazelnut spreads",
    "net_content": { "value": 400, "unit": "g" }
  },
  "recalls": [],
  "offers": [],
  "meta": {
    "cached": false,
    "cache_age_seconds": 0,
    "tier": "tier1",
    "billable_units": 1,
    "sources": ["openfoodfacts"],
    "attribution": "Contains data from Open Food Facts, available under the Open Database Licence (ODbL)",
    "response_time_ms": 412.5
  }
}
```

`meta.tier` tells you where the answer came from: `l1_redis` and `l2_mysql` are our own store, `tier1` is free upstream APIs, `tier2` is paid providers.

**`category_normalized`** is an English label whatever language the source used. `category` is kept as the source wrote it, which can be French or Portuguese. Filter and group on `category_normalized`. It is `null` when only a non-English label exists, rather than passing the foreign text through.

**`net_content`** is the package size parsed into a number and a unit, so you can compute with it. Units are `g`, `kg`, `mg`, `ml`, `cl`, `dl`, `l`, `oz`, `fl_oz`, `lb` and `count`. Multipacks report the total with `pack_count` alongside, so `"6 x 330 ml"` is `{"value": 1980, "unit": "ml", "pack_count": 6}`. It is `null` when the size could not be read with confidence, because a wrong size is worse than none: it would show a full tin as nearly empty.

**`recalls`** lists regulator recalls that name this barcode. It is present on a `404` as well, because a regulator can name a product we have never held. See `GET /recalls` for what each entry contains and how far to trust it.

**`meta.attribution`**, when present, is a licence requirement. It is returned on every response built from licensed data, including answers served from our own store.

### POST /lookup

Batch. Send up to your plan's batch limit per request. Each identifier bills independently.

```bash
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"identifiers":["3017620422003","9780306406157"]}' \
  https://api.upcguru.com/v1/lookup
```

Returns `{ "success": true, "count": n, "results": [ ... ] }` where each result has the same shape as a single lookup, plus the `input` echoed back. A failure on one identifier does not fail the batch.

### GET /search

Full text over stored product titles and brands, plus exact match on MPN and SKU.

| Name | Default | Meaning |
|---|---|---|
| `q` | required | At least two characters |
| `page` | `1` | |
| `per_page` | `20` | Maximum 100 |

Searches only what has already been resolved into the store. It does not trigger upstream lookups.

### GET /validate/{identifier}

Check digit, format, prefix decoding and restricted range detection. Never calls a provider. **Free, never billed.**

An invalid check digit returns `400` with the correct digit computed for you:

```json
{
  "success": false,
  "error": {
    "code": "IDENTIFIER_CHECK_DIGIT_FAILED",
    "message": "Check digit for \"036000291453\" is 3, expected 2.",
    "supplied_check_digit": "3",
    "expected_check_digit": "2",
    "corrected": "036000291452"
  }
}
```

### GET /convert/{identifier}

Every representation of an identifier. **Free.**

With no `to` parameter, returns all available forms in `conversions`. With `to=<type>`, returns just that one, or `422 CONVERSION_UNAVAILABLE` when the code has no such form. A 979 prefixed ISBN has no ISBN-10, and a 13 digit EAN has no UPC-A.

Valid `to` values: `upc_a`, `upc_e`, `ean13`, `ean8`, `gtin14`, `itf14`, `isbn10`, `isbn13`, `issn`, `jan`.

### GET /prefix/{prefix}

Decode a three digit GS1 prefix to its issuing country or agency, and list any brands known to hold a company prefix under it. **Free.**

### GET /barcode/{identifier}.svg | .png

Render the identifier as a scannable symbol. EAN-13, UPC-A and EAN-8 are supported; the symbology is chosen from the identifier.

| Name | Default | Range |
|---|---|---|
| `scale` | `2` | 1 to 6, module width in pixels |
| `height` | `80` | 20 to 300 |

Free on paid plans. One unit on the free tier.

### POST /contribute

Send us data for a barcode we could not resolve. **Free, never billed.**

This is the intended answer to a `404`: ask the user what they are holding, and post it back. The next caller gets a hit.

```bash
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{
        "identifier": "099482000110",
        "title": "365 Organic Black Beans 15oz",
        "brand": "365 Whole Foods",
        "category": "Canned beans",
        "attributes": { "quantity": "15 oz", "organic": "yes" }
      }' \
  https://api.upcguru.com/v1/contribute
```

| Field | Required | Notes |
|---|---|---|
| `identifier` | yes | Or supply it in the path, `/contribute/{identifier}` |
| `title` | yes | |
| `brand`, `category`, `description` | no | |
| `attributes` | no | Flat object of key to value. Nested objects are dropped. |
| `image_url` | no | |
| `photo` | no | Base64 JPEG, PNG or WebP of the package, up to 4 MB. Private review evidence only, see below. |
| `contributor_hash` | no | 16 to 64 characters of `A-Z`, `a-z`, `0-9`, `_` or `-`. A stable opaque value identifying the end user or device this entry came from. See below. |

```json
{
  "success": true,
  "submission": {
    "gtin14": "00099482000110",
    "status": "pending",
    "agreement_count": 1,
    "agreement_needed": 2,
    "auto_promotable": false
  },
  "message": "Thank you. This is queued for review."
}
```

Submissions are not written straight into the shared store. Each is checked first, and either publishes, is rejected, or goes to a person, as described below. Agreement between two **independent accounts** also promotes a submission. Casing and spacing differences still count as agreement.

Contributed values carry the lowest trust weight in the system, so they fill gaps rather than overwrite anything a provider supplied. Every published field records `"source": "user-contributed"` so you can tell where a value came from.

Resending for the same barcode with the same key updates your submission rather than creating another, so a retry loop is safe.

**What happens next.** The first submission for a barcode is checked automatically against what we already know about it, including the other products under the same GS1 company prefix. If that check is confident, it publishes without waiting for anyone else, so one of your users can close a gap on their own. Obvious junk is rejected. Anything unclear goes to a person.

A later submission for the same barcode is compared against what is already recorded. If it describes the same product it simply joins it, and differences of spelling, spacing, or a missing brand do not matter. If it describes something different, or the check cannot tell, both go to a person and neither overwrites the other.

`status` is one of `approved`, `rejected` or `contested`, and `review_required` is true when a person will decide.

**Older behaviour.** A submission is queued, not published. Two independent contributors describing a barcode the same way promote it automatically, otherwise a person reviews it. `auto_promotable` tells you which of those is happening. Once published it resolves for every caller, and `GET /lookup` returns it from our own store in a few milliseconds. There is no callback: poll the barcode if you need to know, or simply carry on, since your own app already has the data the user typed.

**If one key fronts many users.** A server key used by a proxy sees every end user as the same caller, so two of your users independently naming the same barcode would count once and go to review. Send `contributor_hash`, a stable opaque value per user or per install, and they count independently. We store it exactly as sent, never decode it, and never correlate it across keys. Same hash resending updates that submission; a different hash creates a new one.

Same-key hashes count toward **automatic** promotion only for applications approved as trusted contributors, which is set by us and cannot be requested through the API. For anyone else, promotion treats the whole **account** as a single contributor: every key, every application and every hash it holds counts as one voice, and a submission is never compared against another from the same account. The hashes still create separate submissions and still raise `agreement_count`. That is what stops an account inventing contributors, or registering a second app, to publish a wrong title.

Never derive `contributor_hash` from anything that identifies a person. A random UUID generated once per install and stored locally is correct.

**Attaching a photo.** A photo of the package lets the person reviewing a contested submission see what your user saw, which turns guesswork into a five second decision. It is never served to any caller and never used as a product image. Before it is stored it is decoded and re-encoded, which strips EXIF metadata including any GPS position. It is deleted once a person has decided, when the submission publishes, or after 30 days, whichever comes first. A submission that is rejected outright has its photo deleted immediately. The response carries `photo_attached: true` when it was kept.

**A note on what you send.** Only send what the user can see on the package. Do not send anything about the user, and do not send a guess: a wrong title published against a barcode is worse than no title, because it is served to everyone.

### POST /report/{identifier}

Flag a published record as wrong. **Free, never billed.**

```bash
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"reason":"wrong_size","note":"Package says 15 oz"}' \
  https://api.upcguru.com/v1/report/099482000110
```

| Field | Required | Notes |
|---|---|---|
| `reason` | yes | `wrong_product`, `wrong_size`, `wrong_brand`, `junk` or `other` |
| `note` | no | Up to 500 characters, shown to the reviewer |
| `contributor_hash` | no | Same rules as on `/contribute`, so users behind one key report independently |

Returns `202`. A report does not change what is served by itself: it puts the record in front of a person, who either hides it or dismisses the report. A hidden record returns `404` to every caller, including with `refresh=1`, until a new contribution for that barcode is published. One live report per reporter per barcode; resending updates it. A key may file 200 reports a day.

Reporting a barcode we hold no record for returns `404`.

This is the exit for the single contribution publish path. Put a quiet "Not this?" on your product screen and call this.

### GET /recalls

Regulator recalls matched to barcodes, for checking against your own inventory. **Free, never billed.**

| Name | Default | Meaning |
|---|---|---|
| `since` | 30 days ago | `YYYY-MM-DD`, by report date |
| `min_confidence` | `0.9` | Matches below this are left out |
| `limit` | `1000` | Maximum 5000 |

```json
{
  "success": true,
  "since": "2026-09-01",
  "count": 1,
  "recalls": [
    {
      "gtin14": "00028400090858",
      "agency": "fda",
      "recall_number": "F-1234-2026",
      "classification": "II",
      "status": "Ongoing",
      "product_description": "Classic Potato Chips, 8 oz bag. UPC 0 28400 09085 8",
      "reason": "Undeclared milk",
      "firm": "Example Foods Inc.",
      "distribution": "Nationwide",
      "recall_date": "2026-09-10",
      "report_date": "2026-09-17",
      "source_url": "https://api.fda.gov/food/enforcement.json?search=recall_number:%22F-1234-2026%22",
      "match_confidence": 0.95,
      "match_method": "labelled_upc"
    }
  ]
}
```

**How far to trust a match.** FDA does not publish barcodes as a field. They appear, when they appear at all, inside free text. So each match carries a confidence:

| `match_method` | `match_confidence` | Meaning |
|---|---|---|
| `labelled_upc` | 0.95 | Digits introduced by "UPC", "GTIN" or "EAN", with a valid check digit |
| `valid_code_in_text` | 0.60 | An unlabelled 12 to 14 digit run whose check digit happens to be valid |

The second is deliberately low: one random digit run in ten passes a check digit by chance, and lot numbers are digit runs. **Only send a push notification on `labelled_upc` matches**, which is what the default `min_confidence=0.9` gives you. A false recall alert is worse than none.

`classification` follows FDA: `I` is a reasonable probability of serious harm, `III` is unlikely to cause harm. In-store barcodes are never matched, because they identify nothing outside one shop.

Source: openFDA food enforcement reports, refreshed nightly. USDA FSIS meat and poultry recalls are not yet included. `source_url` is the agency's own record.

### GET /popular

The most scanned products for a market, for shipping a lookup table inside your app so scans resolve offline.

| Name | Default | Meaning |
|---|---|---|
| `region` | `US` | Only `US` is available |
| `vertical` | `grocery` | `grocery`, `beauty`, `pet` or `household` |
| `limit` | `50000` | Maximum 100000 |

Returns newline delimited JSON (`application/x-ndjson`), streamed. The first line is a header carrying the licence notice, and every following line is one product:

```
{"type":"header","region":"US","vertical":"grocery","generated_at":"2026-10-04T12:00:00+00:00","attribution":"Contains data from Open Food Facts, available under the Open Database Licence (ODbL)","licence":"ODbL 1.0. ..."}
{"gtin14":"00028400090858","title":"Classic Potato Chips","brand":"Lay's","category_normalized":"Potato crisps","net_content":{"value":8,"unit":"oz"},"popularity":1840}
```

Ranked by scans in the source dataset, limited to products the source lists as sold in the United States. Hidden records are excluded.

**Billing:** one unit per 1,000 rows requested, so a 50,000 row export costs 50 units. The cost is sent in the `X-Billable-Units` header.

**Licence.** This data is ODbL. Shipping it inside an app is redistribution: you must show the attribution from the header line somewhere a user can find it, such as an about screen, and a database derived from it must stay under ODbL. That obligation comes from the source and travels with the data.

### POST /identify

Read a product off a photo of its package.

```bash
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"image":"<base64>","identifier":"099482000110"}' \
  https://api.upcguru.com/v1/identify
```

| Field | Required | Notes |
|---|---|---|
| `image` | yes | Base64 JPEG, PNG or WebP, up to 4 MB. A `data:` URI prefix is accepted. |
| `identifier` | no | The barcode your user scanned. Without it we use any digits legible on the photo. |

```json
{
  "success": true,
  "read": {
    "title": "Nutella Hazelnut Spread with Cocoa",
    "brand": "Nutella",
    "category": "Hazelnut spread",
    "category_normalized": "Hazelnut spread",
    "net_content": { "value": 400, "unit": "g" },
    "net_content_text": "400 G",
    "barcode_digits": null,
    "confidence": 0.98
  },
  "identifier": { "gtin14": "03017620422003" },
  "plausibility": {
    "decision": "accept",
    "confidence": 0.95,
    "reasoning": "Consistent with the other products under this company prefix."
  }
}
```

`read.confidence` is how sure the model is that it read the title and brand correctly. **A reading is not an identification**: confirm it with your user before saving.

`plausibility` is present when there is a barcode to check against. It is the same check a contribution gets: does this product plausibly belong to the manufacturer that owns this barcode range, judged against the products we already hold under the same GS1 company prefix. `reject` with high confidence usually means the user photographed a different product from the one they scanned.

The photo is never stored. It is decoded, re-encoded without metadata, read, and discarded.

**Billing:** 2 units a call.

### GET /usage

Your own consumption: plan, app, quota used and remaining, rate limit, month to date totals and a 30 day daily breakdown. **Free.**

### POST /jobs

Asynchronous bulk resolution, up to 100,000 identifiers. Returns `202` with a job id. Units bill per identifier as the job runs.

```json
{ "identifiers": ["...", "..."], "webhook_url": "https://your.app/hook" }
```

### GET /jobs/{id}

Job status. You can only read jobs submitted with your own key.

### Not yet available

`GET /asin/{asin}` and `GET /gtin/{gtin}/asins` return `501 PLAN_FEATURE_UNAVAILABLE`. They arrive with the Amazon layer.

## Recommended flow for a scanner app

The interesting case is the miss. Handled well it makes the database better for everyone; handled badly it is a dead end for the user.

1. **Validate locally first.** `GET /validate/{code}` is free and returns in a few milliseconds. Scanners misread, and a failed check digit is far more often a bad scan than a bad product. Offer a rescan before spending a lookup.
2. **Look it up.** `GET /lookup/{code}`.
3. **On `422`,** the code is an in-store or coupon barcode. Tell the user it is a shop's own label and cannot identify a product anywhere else. Do not offer to contribute it: it is not globally unique, so the data would be wrong for the next person who scans that number.
4. **On `404`,** show what you know (`identifier.gs1_prefix`, `issuing_country`) and offer a short form: name, brand, size. Post it to `/contribute`.
5. **Show the result immediately in your own app.** Do not wait for our review. The contribution improves the shared database on its own schedule, and your user should not be blocked on that.

```
scan
 └─ GET /validate/{code}      free, catches misreads
     ├─ invalid  → offer rescan, show the corrected check digit
     └─ valid
         └─ GET /lookup/{code}
             ├─ 200  → done
             ├─ 422  → shop's own barcode, explain and stop
             └─ 404  → POST /identify with a photo to prefill the form,
                        ask the user to confirm, POST /contribute with the photo,
                        then continue locally
```

On every product screen, offer a quiet "Not this?" that calls `POST /report`. One confident contribution can publish to every caller, so a wrong publish needs an exit of the same weight.

For recall alerts, store the GTINs in each user's inventory, call `GET /recalls?since=` once a day, and notify on any match. The default confidence threshold is set for push notifications.

Contributions are free and never billed, so this flow costs you one unit for the lookup and nothing else.

## Codes that are refused before any lookup

Some barcodes are not globally unique and cannot identify a product. These return `422` and are never billed, cached or sent to a provider.

| GS1 prefix | Meaning | Error code |
|---|---|---|
| `02`, `04`, `20`–`29` | In-store or variable weight, assigned by a shop | `IDENTIFIER_RESTRICTED_RANGE` |
| `05`, `99` | Coupon | `IDENTIFIER_COUPON_CODE` |
| `980` | Refund receipt | `IDENTIFIER_NOT_A_TRADE_ITEM` |

If you scan supermarket shelf or scale labels you will meet these often. They are a property of the barcode, not a failure of the lookup.

## Errors

Every error carries a stable `error.code`. Switch on the code, never on the message.

| Code | HTTP | Meaning |
|---|---|---|
| `API_KEY_MISSING` | 401 | No key supplied |
| `API_KEY_INVALID` | 401 | Key not recognised |
| `API_KEY_REVOKED` | 401 | Key revoked |
| `API_KEY_EXPIRED` | 401 | Key past its expiry |
| `APP_NOT_APPROVED` | 403 | App pending, rejected or suspended |
| `ORIGIN_NOT_ALLOWED` | 403 | Origin not registered for this web app |
| `BUNDLE_ID_MISMATCH` | 403 | Missing or wrong `X-Bundle-Id` |
| `RATE_LIMITED` | 429 | Per minute limit exceeded, see `retry_after_seconds` |
| `QUOTA_EXCEEDED` | 402 | Monthly allowance used |
| `IDENTIFIER_INVALID` | 400 | Not a recognisable identifier |
| `IDENTIFIER_CHECK_DIGIT_FAILED` | 400 | Check digit wrong, correct one returned |
| `IDENTIFIER_RESTRICTED_RANGE` | 422 | In-store or variable weight code |
| `IDENTIFIER_COUPON_CODE` | 422 | Coupon code |
| `CONVERSION_UNAVAILABLE` | 422 | No such representation exists |
| `PRODUCT_NOT_FOUND` | 404 | No provider had it, miss recorded, bills 0.25 |
| `BATCH_TOO_LARGE` | 400 | Over your plan's batch limit |
| `QUERY_TOO_SHORT` | 400 | Search needs two characters |
| `PLAN_FEATURE_UNAVAILABLE` | 422, 501 | Not on your plan, or not built yet |
| `SUBMISSION_INCOMPLETE` | 400 | A contribution needs at least a title |
| `SUBMISSION_INVALID` | 400 | `attributes` was not a flat object, or `contributor_hash` was malformed |
| `REPORT_INVALID` | 400 | Unknown `reason`, or a malformed `contributor_hash` |
| `INVALID_PARAMETER` | 400 | A query or body parameter is out of range or the wrong shape |
| `UNSUPPORTED_REGION` | 422 | `/popular` only serves `region=US` |
| `METHOD_NOT_ALLOWED` | 405 | Wrong HTTP method for this endpoint |
| `PROVIDER_UNAVAILABLE` | 502, 503 | `/identify` could not reach its model, or is not configured |
| `INTERNAL_ERROR` | 500 | Our fault |

## Rate limits, quota and billing

Response headers on every authenticated request:

```
X-RateLimit-Limit      requests per minute on your plan
X-RateLimit-Remaining  left in this minute
X-RateLimit-Reset      seconds until the window resets
X-Quota-Limit          billable units per month
X-Quota-Remaining      units left
```

Billing units:

| Action | Units |
|---|---|
| Lookup, served from our store | 1 |
| Lookup, resolved upstream | 1 |
| Lookup, not found | 0.25 |
| Batch | 1 per identifier |
| Search | 1 |
| Barcode image | 0 on paid plans, 1 on free |
| `/popular` export | 1 per 1,000 rows requested |
| `/identify` | 2 |
| `validate`, `convert`, `prefix`, `usage`, `contribute`, `report`, `recalls` | 0 |

## Notes for implementers

- Treat `gtin14` as the join key in your own database. It is stable across every input form.
- `meta.attribution`, when present, is a licence requirement rather than a courtesy. Open Food Facts data is ODbL and must be attributed wherever you display it.
- `product.attributes` is a list, not an object, because each value carries its own `source` and `confidence`. Different fields of one product can come from different providers.
- `confidence` is 0 to 1. Values below about 0.5 usually mean a single low trust source with thin data.
- A `404` is a real answer, not an error to retry. Retrying a miss costs units and will not change the result until we add a provider that covers it, or until someone contributes it.
- Treat a `404` as an opportunity: `POST /contribute` turns it into a hit for whoever scans that barcode next, and costs nothing.
