upcguru/api Markdown Status

UPCguru API

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

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

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

{
  "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.


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.

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:


{
  "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.


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.

{
  "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.


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

{
  "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.

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.


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.

{
  "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.


{ "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.

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