Free card centering API: PSA ratios and photo measurement

Centering Lab is a free card centering REST API with no API key. At https://centeringlab.com/api/v1, it returns photo measurements, ratios and PSA, BGS, CGC or SGC ceilings from centering alone. It does not predict a final grade or authenticate cards. Call it from your server. OpenAPI.

Quick start

Run this curl from your terminal. The front has two supplied axes; the back is missing, so incomplete is true. PSA 10 is a ceiling from the supplied centering, not a final-grade prediction.

POST /api/v1/grade

Request

curl --fail-with-body 'https://centeringlab.com/api/v1/grade' \
  -H 'Content-Type: application/json' \
  --data '{"front_lr":0.55,"front_tb":"50/50","house":"PSA"}'

HTTP 200 response

Excerpt from a real local HTTP response; omitted fields and additional bands are not shown. JSON keys and server messages stay in English.

{
  "front": {
    "ratios": {
      "lr": 55,
      "tb": 50
    }
  },
  "combined": {
    "PSA": {
      "maximum_grade": 10,
      "source_status": "official"
    }
  },
  "normalizations": [
    {
      "field": "front_lr",
      "input": 0.55,
      "ratio": "55/45",
      "note": "0.55 read as 55/45"
    }
  ],
  "incomplete": true,
  "missing_faces": [
    "back"
  ],
  "disclaimer": "This is a ceiling from centering alone, not a prediction of the final grade."
}

Endpoints and fields

Known centering ratios

POST /grade accepts at least one of front_lr, front_tb, back_lr, back_tb. Values are normalized to the larger side. Send a percentage such as 55, a fraction such as 0.55, or a pair summing to 100 (55/45) or 1 (0.55/0.45). Accepted separators are /, -, –, —, :, x and spaces; optional % is accepted. Scalar 1 is ambiguous and rejected. Optional L/R or left/right suffixes belong only to *_lr; T/B or top/bottom only to *_tb.

house optionally filters PSA, BGS, CGC or SGC. normalizations discloses fractional conversions. missing_axes, missing_faces and incomplete describe gaps: missing axes or the other face can lower the ceiling. combined joins supplied faces; limiting_face and limiting_axis identify the constraint. Source status and links are attached to each ceiling.

Contract fields (exact JSON names)

input: front_lr, front_tb, back_lr, back_tb, house
output: front, front.ratios, front.ratios.lr, front.ratios.tb, front.missing_axes, front.ceilings, front.ceilings.PSA, front.ceilings.PSA.maximum_grade, front.ceilings.PSA.label, front.ceilings.PSA.rank, front.ceilings.PSA.limiting_face, front.ceilings.PSA.limiting_axis, front.ceilings.PSA.distance_to_next, front.ceilings.PSA.next_grade, front.ceilings.PSA.next_label, front.ceilings.PSA.borderline, front.ceilings.PSA.unconstrained, front.ceilings.PSA.source_status, front.ceilings.PSA.standard_status, front.ceilings.PSA.source, front.ceilings.PSA.sources, front.ceilings.PSA.verified, front.ceilings.PSA.verification, front.ceilings.BGS, front.ceilings.BGS.maximum_grade, front.ceilings.BGS.label, front.ceilings.BGS.rank, front.ceilings.BGS.limiting_face, front.ceilings.BGS.limiting_axis, front.ceilings.BGS.distance_to_next, front.ceilings.BGS.next_grade, front.ceilings.BGS.next_label, front.ceilings.BGS.borderline, front.ceilings.BGS.unconstrained, front.ceilings.BGS.source_status, front.ceilings.BGS.standard_status, front.ceilings.BGS.source, front.ceilings.BGS.sources, front.ceilings.BGS.verified, front.ceilings.BGS.verification, front.ceilings.CGC, front.ceilings.CGC.maximum_grade, front.ceilings.CGC.label, front.ceilings.CGC.rank, front.ceilings.CGC.limiting_face, front.ceilings.CGC.limiting_axis, front.ceilings.CGC.distance_to_next, front.ceilings.CGC.next_grade, front.ceilings.CGC.next_label, front.ceilings.CGC.borderline, front.ceilings.CGC.unconstrained, front.ceilings.CGC.source_status, front.ceilings.CGC.standard_status, front.ceilings.CGC.source, front.ceilings.CGC.sources, front.ceilings.CGC.verified, front.ceilings.CGC.verification, front.ceilings.SGC, front.ceilings.SGC.maximum_grade, front.ceilings.SGC.label, front.ceilings.SGC.rank, front.ceilings.SGC.limiting_face, front.ceilings.SGC.limiting_axis, front.ceilings.SGC.distance_to_next, front.ceilings.SGC.next_grade, front.ceilings.SGC.next_label, front.ceilings.SGC.borderline, front.ceilings.SGC.unconstrained, front.ceilings.SGC.source_status, front.ceilings.SGC.standard_status, front.ceilings.SGC.source, front.ceilings.SGC.sources, front.ceilings.SGC.verified, front.ceilings.SGC.verification, back, back.ratios, back.ratios.lr, back.ratios.tb, back.missing_axes, back.ceilings, back.ceilings.PSA, back.ceilings.PSA.maximum_grade, back.ceilings.PSA.label, back.ceilings.PSA.rank, back.ceilings.PSA.limiting_face, back.ceilings.PSA.limiting_axis, back.ceilings.PSA.distance_to_next, back.ceilings.PSA.next_grade, back.ceilings.PSA.next_label, back.ceilings.PSA.borderline, back.ceilings.PSA.unconstrained, back.ceilings.PSA.source_status, back.ceilings.PSA.standard_status, back.ceilings.PSA.source, back.ceilings.PSA.sources, back.ceilings.PSA.verified, back.ceilings.PSA.verification, back.ceilings.BGS, back.ceilings.BGS.maximum_grade, back.ceilings.BGS.label, back.ceilings.BGS.rank, back.ceilings.BGS.limiting_face, back.ceilings.BGS.limiting_axis, back.ceilings.BGS.distance_to_next, back.ceilings.BGS.next_grade, back.ceilings.BGS.next_label, back.ceilings.BGS.borderline, back.ceilings.BGS.unconstrained, back.ceilings.BGS.source_status, back.ceilings.BGS.standard_status, back.ceilings.BGS.source, back.ceilings.BGS.sources, back.ceilings.BGS.verified, back.ceilings.BGS.verification, back.ceilings.CGC, back.ceilings.CGC.maximum_grade, back.ceilings.CGC.label, back.ceilings.CGC.rank, back.ceilings.CGC.limiting_face, back.ceilings.CGC.limiting_axis, back.ceilings.CGC.distance_to_next, back.ceilings.CGC.next_grade, back.ceilings.CGC.next_label, back.ceilings.CGC.borderline, back.ceilings.CGC.unconstrained, back.ceilings.CGC.source_status, back.ceilings.CGC.standard_status, back.ceilings.CGC.source, back.ceilings.CGC.sources, back.ceilings.CGC.verified, back.ceilings.CGC.verification, back.ceilings.SGC, back.ceilings.SGC.maximum_grade, back.ceilings.SGC.label, back.ceilings.SGC.rank, back.ceilings.SGC.limiting_face, back.ceilings.SGC.limiting_axis, back.ceilings.SGC.distance_to_next, back.ceilings.SGC.next_grade, back.ceilings.SGC.next_label, back.ceilings.SGC.borderline, back.ceilings.SGC.unconstrained, back.ceilings.SGC.source_status, back.ceilings.SGC.standard_status, back.ceilings.SGC.source, back.ceilings.SGC.sources, back.ceilings.SGC.verified, back.ceilings.SGC.verification, combined, combined.PSA, combined.PSA.maximum_grade, combined.PSA.label, combined.PSA.rank, combined.PSA.limiting_face, combined.PSA.limiting_axis, combined.PSA.distance_to_next, combined.PSA.next_grade, combined.PSA.next_label, combined.PSA.borderline, combined.PSA.unconstrained, combined.PSA.source_status, combined.PSA.standard_status, combined.PSA.source, combined.PSA.sources, combined.PSA.verified, combined.PSA.verification, combined.BGS, combined.BGS.maximum_grade, combined.BGS.label, combined.BGS.rank, combined.BGS.limiting_face, combined.BGS.limiting_axis, combined.BGS.distance_to_next, combined.BGS.next_grade, combined.BGS.next_label, combined.BGS.borderline, combined.BGS.unconstrained, combined.BGS.source_status, combined.BGS.standard_status, combined.BGS.source, combined.BGS.sources, combined.BGS.verified, combined.BGS.verification, combined.CGC, combined.CGC.maximum_grade, combined.CGC.label, combined.CGC.rank, combined.CGC.limiting_face, combined.CGC.limiting_axis, combined.CGC.distance_to_next, combined.CGC.next_grade, combined.CGC.next_label, combined.CGC.borderline, combined.CGC.unconstrained, combined.CGC.source_status, combined.CGC.standard_status, combined.CGC.source, combined.CGC.sources, combined.CGC.verified, combined.CGC.verification, combined.SGC, combined.SGC.maximum_grade, combined.SGC.label, combined.SGC.rank, combined.SGC.limiting_face, combined.SGC.limiting_axis, combined.SGC.distance_to_next, combined.SGC.next_grade, combined.SGC.next_label, combined.SGC.borderline, combined.SGC.unconstrained, combined.SGC.source_status, combined.SGC.standard_status, combined.SGC.source, combined.SGC.sources, combined.SGC.verified, combined.SGC.verification, normalizations, normalizations[].field, normalizations[].input, normalizations[].ratio, normalizations[].note, incomplete, missing_faces, summary, disclaimer

Contract fields (exact JSON names)

input: maximum_grade, label, rank, limiting_face, limiting_axis, distance_to_next, next_grade, next_label, borderline, unconstrained, source_status, standard_status, source, sources, verified, verification
output: maximum_grade, label, rank, limiting_face, limiting_axis, distance_to_next, next_grade, next_label, borderline, unconstrained, source_status, standard_status, source, sources, verified, verification

Photograph measurement

POST /measure requires face: front or back. card_size defaults to standard (63×88 mm); small is 59×86 mm. Supply exactly one source: a public HTTPS image_url on port 443, or raw image_base64 without a data-URL prefix. Private addresses, credentials and non-image responses are rejected, including redirect targets. The REST adapter also accepts one openaiFileIdRefs entry supplied by the GPT Actions runtime; ordinary integrations should use URL or base64. JPG, PNG and WebP are practical choices; use a single-frame raster image.

A detector result can be HTTP 200 with status equal to ok, needs-review or failed. Only ok exposes ceilings. Inspect code, confidence, warnings, lr and tb; uncertainty_pp and sigma_pp use percentage points, and stations_major_percent contains the sampled ratios. Uncertain results require a new photo or manual inspection at adjust_url. The example sends a tiny plain image deliberately: no card is found. This is a real failure result, not a successful measurement demonstration.

Contract fields (exact JSON names)

input: face, card_size, image_url, image_base64, openaiFileIdRefs, openaiFileIdRefs[].name, openaiFileIdRefs[].id, openaiFileIdRefs[].mime_type, openaiFileIdRefs[].download_link
output: status, code, face, card_size, lr, lr.ratio, lr.major_percent, lr.minor_percent, lr.uncertainty_pp, lr.sigma_pp, lr.worst_station_px, lr.stations_major_percent, tb, tb.ratio, tb.major_percent, tb.minor_percent, tb.uncertainty_pp, tb.sigma_pp, tb.worst_station_px, tb.stations_major_percent, confidence, warnings, ceilings, ceilings.PSA, ceilings.PSA.maximum_grade, ceilings.PSA.label, ceilings.PSA.rank, ceilings.PSA.limiting_face, ceilings.PSA.limiting_axis, ceilings.PSA.distance_to_next, ceilings.PSA.next_grade, ceilings.PSA.next_label, ceilings.PSA.borderline, ceilings.PSA.unconstrained, ceilings.PSA.source_status, ceilings.PSA.standard_status, ceilings.PSA.source, ceilings.PSA.sources, ceilings.PSA.verified, ceilings.PSA.verification, ceilings.BGS, ceilings.BGS.maximum_grade, ceilings.BGS.label, ceilings.BGS.rank, ceilings.BGS.limiting_face, ceilings.BGS.limiting_axis, ceilings.BGS.distance_to_next, ceilings.BGS.next_grade, ceilings.BGS.next_label, ceilings.BGS.borderline, ceilings.BGS.unconstrained, ceilings.BGS.source_status, ceilings.BGS.standard_status, ceilings.BGS.source, ceilings.BGS.sources, ceilings.BGS.verified, ceilings.BGS.verification, ceilings.CGC, ceilings.CGC.maximum_grade, ceilings.CGC.label, ceilings.CGC.rank, ceilings.CGC.limiting_face, ceilings.CGC.limiting_axis, ceilings.CGC.distance_to_next, ceilings.CGC.next_grade, ceilings.CGC.next_label, ceilings.CGC.borderline, ceilings.CGC.unconstrained, ceilings.CGC.source_status, ceilings.CGC.standard_status, ceilings.CGC.source, ceilings.CGC.sources, ceilings.CGC.verified, ceilings.CGC.verification, ceilings.SGC, ceilings.SGC.maximum_grade, ceilings.SGC.label, ceilings.SGC.rank, ceilings.SGC.limiting_face, ceilings.SGC.limiting_axis, ceilings.SGC.distance_to_next, ceilings.SGC.next_grade, ceilings.SGC.next_label, ceilings.SGC.borderline, ceilings.SGC.unconstrained, ceilings.SGC.source_status, ceilings.SGC.standard_status, ceilings.SGC.source, ceilings.SGC.sources, ceilings.SGC.verified, ceilings.SGC.verification, adjust_url, advice, elapsed_ms, image_bytes, summary, disclaimer

POST /api/v1/measure

Request

curl --fail-with-body 'https://centeringlab.com/api/v1/measure' \
  -H 'Content-Type: application/json' \
  --data '{"face":"front","card_size":"standard","image_base64":"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAD0lEQVQImWNQwAEYhpYEAGFPGAHjXOFDAAAAAElFTkSuQmCC"}'

HTTP 200 response

Excerpt from a real local HTTP response; omitted fields and additional bands are not shown. JSON keys and server messages stay in English.

{
  "status": "failed",
  "code": "NO_CARD",
  "face": "front",
  "card_size": "standard",
  "lr": null,
  "tb": null,
  "confidence": null,
  "warnings": [],
  "ceilings": null,
  "adjust_url": "https://centeringlab.com/"
}

For an actual photograph, save your image as card.jpg and run the following. This response was captured with the website’s demo photograph: its uncertainty triggers needs-review, so ceilings is null. Your image will return its own measurements.

POST /api/v1/measure

Request

python3 - <<'PY' > measure.json
import base64, json
from pathlib import Path
print(json.dumps({
    'face': 'front', 'card_size': 'standard',
    'image_base64': base64.b64encode(Path('card.jpg').read_bytes()).decode('ascii'),
}))
PY
curl --fail-with-body 'https://centeringlab.com/api/v1/measure' \
  -H 'Content-Type: application/json' \
  --data-binary @measure.json

HTTP 200 response

Excerpt from a real local HTTP response; omitted fields and additional bands are not shown. JSON keys and server messages stay in English.

{
  "status": "needs-review",
  "code": "HIGH_UNCERTAINTY",
  "face": "front",
  "card_size": "standard",
  "lr": {
    "ratio": "56/44",
    "major_percent": 55.54242922739353,
    "minor_percent": 44.45757077260647,
    "uncertainty_pp": 5.1,
    "sigma_pp": 2.3393336872317274,
    "worst_station_px": 350.163778228702,
    "stations_major_percent": [
      55.54242922739353,
      54.38358480163707,
      53.25627521942576
    ]
  },
  "tb": {
    "ratio": "52/48",
    "major_percent": 52.059885599520676,
    "minor_percent": 47.940114400479324,
    "uncertainty_pp": 6.4,
    "sigma_pp": 2.9286347555409855,
    "worst_station_px": 250.03684789689908,
    "stations_major_percent": [
      52.059885599520676,
      51.766308753028554,
      51.468031630625774
    ]
  },
  "confidence": 0.8603421926529179,
  "warnings": [
    "GET_CLOSER"
  ],
  "ceilings": null,
  "adjust_url": "https://centeringlab.com/"
}

Published standards and sources

GET /standards accepts optional house and face query parameters. official, interpreted and approximate describe evidence, not accuracy guarantees. bands[].status applies to a particular band; source, sources, verified and verification expose provenance. TAG is excluded. SGC has no published back centering table: back bands are empty and the back alone establishes no SGC ceiling. Preserve this distinction in your interface.

Contract fields (exact JSON names)

input: house, face
output: standards, standards[].house, standards[].face, standards[].bands, standards[].bands[].value, standards[].bands[].label, standards[].bands[].rank, standards[].bands[].loose, standards[].bands[].tight, standards[].bands[].status, standards[].source, standards[].sources, standards[].status, standards[].verified, standards[].verification, standards[].note, rounding_rule, tag_note, summary, disclaimer

GET /api/v1/standards?house=PSA&face=front

Request

curl --fail-with-body 'https://centeringlab.com/api/v1/standards?house=PSA&face=front'

HTTP 200 response

Excerpt from a real local HTTP response; omitted fields and additional bands are not shown. JSON keys and server messages stay in English.

{
  "standards": [
    {
      "house": "PSA",
      "face": "front",
      "bands": [
        {
          "value": 10,
          "label": "GEM MT",
          "rank": 100,
          "loose": 55,
          "status": "official"
        }
      ],
      "status": "official",
      "source": "https://www.psacard.com/gradingstandards",
      "note": "Published percentage thresholds. Only the listed centering bands are used; no missing grades are invented."
    }
  ],
  "tag_note": "TAG is excluded because its centering assessment is proprietary and is not represented by these public percentage bands."
}

Health and machine-readable schema

GET /health returns service status, name, version and disclaimer. GET /openapi.json returns OpenAPI 3.1.0. Its measurement request schema is tailored to GPT Actions (URL or runtime file reference); raw base64 is also accepted by REST as described here. Use the exact REST field list above for byte integrations.

GET /api/v1/health

Request

curl --fail-with-body 'https://centeringlab.com/api/v1/health'

HTTP 200 response

Excerpt from a real local HTTP response; omitted fields and additional bands are not shown. JSON keys and server messages stay in English.

{
  "status": "ok",
  "name": "centering-lab",
  "version": "1.1.2",
  "disclaimer": "This is a ceiling from centering alone, not a prediction of the final grade."
}

GET /api/v1/openapi.json

Request

curl --fail-with-body 'https://centeringlab.com/api/v1/openapi.json'

HTTP 200 response

Excerpt from a real local HTTP response; omitted fields and additional bands are not shown. JSON keys and server messages stay in English.

{
  "openapi": "3.1.0",
  "info": {
    "title": "Centering Lab API",
    "version": "1.1.2"
  },
  "servers": [
    {
      "url": "https://centeringlab.com"
    }
  ]
}

JavaScript and Python

Use Node’s server-side fetch, not a script in another website’s browser. The examples submit the same request as the quick start. Python uses the standard library and requires no additional package. Check HTTP errors before using any ceiling; for measurement also check status even when HTTP is 200.

JavaScript · Node

const response = await fetch('https://centeringlab.com/api/v1/grade', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({"front_lr":0.55,"front_tb":"50/50","house":"PSA"}),
});
const result = await response.json();
if (!response.ok) throw new Error(result.code + ': ' + result.message);
console.log(result);

Python · urllib

import json
from urllib.request import Request, urlopen
from urllib.error import HTTPError

request = Request(
    'https://centeringlab.com/api/v1/grade',
    data=json.dumps({"front_lr":0.55,"front_tb":"50/50","house":"PSA"}).encode('utf-8'),
    headers={'Content-Type': 'application/json'},
    method='POST',
)
try:
    with urlopen(request, timeout=20) as response:
        print(json.load(response))
except HTTPError as error:
    print(error.code, json.load(error))
    raise

Errors and retries

HTTP errors contain status, code, message and disclaimer. The table lists REST transport/service errors; MCP protocol errors and private statistics endpoint errors are outside this contract. UNSUPPORTED_ENCODING is 415 for compressed JSON and 400 for compressed image responses. Detector codes arrive with HTTP 200 and are listed separately below.

CodeHTTPMeaningWhat to do
BODY_TOO_LARGE413Size limit exceededReduce bytes or pixels; base64 also counts towards the JSON body limit.
EMPTY_IMAGE400Image cannot be decodedSend one supported raster image, preferably JPG, PNG or WebP; remove the data-URL prefix from base64.
IMAGE_DNS_FAILED400Image URL rejected or unreachableUse a public HTTPS image on port 443, without credentials; check DNS, redirects and its response.
IMAGE_DOWNLOAD_FAILED400Image URL rejected or unreachableUse a public HTTPS image on port 443, without credentials; check DNS, redirects and its response.
IMAGE_DOWNLOAD_TIMEOUT504Time limit exceededTry a smaller image or a faster image host; use the website if needed.
IMAGE_REDIRECT_LIMIT400Image URL rejected or unreachableUse a public HTTPS image on port 443, without credentials; check DNS, redirects and its response.
IMAGE_TOO_LARGE413Size limit exceededReduce bytes or pixels; base64 also counts towards the JSON body limit.
IMAGE_TOO_MANY_PIXELS413Size limit exceededReduce bytes or pixels; base64 also counts towards the JSON body limit.
INTERNAL_ERROR500Server or worker failureRetry with backoff, try a smaller photo, or use the website.
INVALID_ARGUMENTS400Invalid request or ratioCorrect the named fields; send valid, uncompressed JSON.
INVALID_BASE64400Image cannot be decodedSend one supported raster image, preferably JPG, PNG or WebP; remove the data-URL prefix from base64.
INVALID_BODY400Invalid request or ratioCorrect the named fields; send valid, uncompressed JSON.
INVALID_HOST403Host or browser origin rejectedCall from your server at centeringlab.com; do not call from another website’s browser.
INVALID_HTTP400Invalid request or ratioCorrect the named fields; send valid, uncompressed JSON.
INVALID_IMAGE400Image cannot be decodedSend one supported raster image, preferably JPG, PNG or WebP; remove the data-URL prefix from base64.
INVALID_IMAGE_URL400Image URL rejected or unreachableUse a public HTTPS image on port 443, without credentials; check DNS, redirects and its response.
INVALID_INPUT400Invalid request or ratioCorrect the named fields; send valid, uncompressed JSON.
INVALID_JSON400Invalid request or ratioCorrect the named fields; send valid, uncompressed JSON.
INVALID_ORIGIN403Host or browser origin rejectedCall from your server at centeringlab.com; do not call from another website’s browser.
INVALID_RATIO400Invalid request or ratioCorrect the named fields; send valid, uncompressed JSON.
JSON_REQUIRED415Invalid request or ratioCorrect the named fields; send valid, uncompressed JSON.
MEASURE_QUEUE_FULL503Service at capacityWait for Retry-After and retry with backoff.
MEASURE_TIMEOUT504Time limit exceededTry a smaller image or a faster image host; use the website if needed.
MEASURE_WORKER_FAILED500Server or worker failureRetry with backoff, try a smaller photo, or use the website.
METHOD_NOT_ALLOWED405Route or method unavailableUse an endpoint and method listed here.
NOT_AN_IMAGE400Image URL rejected or unreachableUse a public HTTPS image on port 443, without credentials; check DNS, redirects and its response.
NOT_FOUND404Route or method unavailableUse an endpoint and method listed here.
RATE_LIMIT_CAPACITY503Service at capacityWait for Retry-After and retry with backoff.
RATE_LIMIT_DAY429Request quota reachedWait for Retry-After (seconds); the daily measurement quota resets at UTC midnight.
RATE_LIMIT_MINUTE429Request quota reachedWait for Retry-After (seconds); the daily measurement quota resets at UTC midnight.
REQUEST_CANCELLED499Cancelled requestRetry only if the client still needs the result.
REQUEST_CAPACITY503Service at capacityWait for Retry-After and retry with backoff.
UNSAFE_IMAGE_URL400Image URL rejected or unreachableUse a public HTTPS image on port 443, without credentials; check DNS, redirects and its response.
UNSUPPORTED_ENCODING400 / 415Invalid request or ratioCorrect the named fields; send valid, uncompressed JSON.
UNSUPPORTED_IMAGE400Image cannot be decodedSend one supported raster image, preferably JPG, PNG or WebP; remove the data-URL prefix from base64.

Detector outcomes: HIGH_UNCERTAINTY, NEEDS_REVIEW, LOW_CONFIDENCE, NO_MEASUREMENT, NO_CARD, CARD_TOO_SMALL, CARD_CUT_OFF, NO_PRINTED_BORDER. A failed or needs-review result has no grading ceilings. Retake a whole-card photograph, straight from above, out of its sleeve, on a dark flat surface, or use the website’s manual editor. Do not present an uncertain ratio as a final grade.

Usage limits

Quotas are per IP and shared with the corresponding engine/protocol buckets. Grade and standards share one minute bucket; health and OpenAPI share another. Fixed minute windows and UTC day boundaries apply. There is no daily quota for simple/protocol calls in the current limiter. Base64 expands image bytes and counts towards the JSON body limit, so the full image-byte allowance may not fit in a base64 request.

LimitCurrent value
POST /measure · IP20 per minute; 200 per UTC day
POST /grade + GET /standards · IP120 per minute; No daily quota in the limiter
GET /health + GET /openapi.json · IP120 per minute; No daily quota in the limiter
JSON body12 MiB (12582912 bytes)
Decoded image bytes15 MiB (15728640 bytes)
Decoded pixels (all frames)40000000
Measurement, including queue15 s
Image download10 s
Active measurements2
Queued measurements8
In-flight HTTP requests16

429 and capacity 503 responses include Retry-After in seconds. A full measurement queue and REQUEST_CAPACITY use 5 seconds; limiter capacity uses 60 seconds. Respect the header with backoff. Capacity is shared across the service, not reserved per client. There is no promised availability, response time or SLA.

Browser, CORS and privacy

A browser call from another website currently receives 403 INVALID_ORIGIN; use your own backend. No API key is required, and adding a key does not unlock cross-origin browser calls. Do not build a client-only integration expecting permissive CORS.

API images reach the server, are processed only in memory and are not saved to disk. The website instead processes photos locally on your device. There are no per-request logs: only aggregate counts by endpoint/tool and outcome code. Privacy and terms explain the existing service policy.

MCP and further reading

AI clients can use https://centeringlab.com/mcp: the same three tools and the same centering engine. MCP packages the tools for assistants; REST offers direct HTTP calls for your application. Read AI and MCP setup, how measurement works and about Centering Lab.

API questions

Is the card centering API free for developers?

Yes. All Centering Lab API endpoints are free, with no account or paid plan. Usage limits still apply; the result is a centering ceiling, not authentication or a final-grade prediction.

Do REST integrations need an API key?

No API key is needed. Send valid requests from your server to the documented endpoints; the current browser-origin restriction still applies.

What are the card centering API quotas?

Photo measurement allows 20 calls per minute and 200 per UTC day per IP. Grade and standards share 120 calls per minute per IP. Respect Retry-After and the body, image, pixel and capacity limits documented above.

Does the REST API save uploaded photographs?

No. Photos reach the server but are processed only in memory. There are no per-request logs; only aggregate usage and outcome-code counts. Website photos are processed on your device.

Can another website call this API directly in its browser?

No. The current origin check returns 403 INVALID_ORIGIN for another website’s browser. Call the API from your backend, which does not need an API key.

How does this REST API differ from the MCP endpoint?

REST uses direct HTTP endpoints; MCP exposes three tools to AI clients. Both use the same centering engine and standards. API and MCP images reach the server; the website processes images locally.