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, disclaimerContract 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, verificationPhotograph 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, disclaimerPOST /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.jsonHTTP 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, disclaimerGET /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))
raiseErrors 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.
| Code | HTTP | Meaning | What to do |
|---|---|---|---|
BODY_TOO_LARGE | 413 | Size limit exceeded | Reduce bytes or pixels; base64 also counts towards the JSON body limit. |
EMPTY_IMAGE | 400 | Image cannot be decoded | Send one supported raster image, preferably JPG, PNG or WebP; remove the data-URL prefix from base64. |
IMAGE_DNS_FAILED | 400 | Image URL rejected or unreachable | Use a public HTTPS image on port 443, without credentials; check DNS, redirects and its response. |
IMAGE_DOWNLOAD_FAILED | 400 | Image URL rejected or unreachable | Use a public HTTPS image on port 443, without credentials; check DNS, redirects and its response. |
IMAGE_DOWNLOAD_TIMEOUT | 504 | Time limit exceeded | Try a smaller image or a faster image host; use the website if needed. |
IMAGE_REDIRECT_LIMIT | 400 | Image URL rejected or unreachable | Use a public HTTPS image on port 443, without credentials; check DNS, redirects and its response. |
IMAGE_TOO_LARGE | 413 | Size limit exceeded | Reduce bytes or pixels; base64 also counts towards the JSON body limit. |
IMAGE_TOO_MANY_PIXELS | 413 | Size limit exceeded | Reduce bytes or pixels; base64 also counts towards the JSON body limit. |
INTERNAL_ERROR | 500 | Server or worker failure | Retry with backoff, try a smaller photo, or use the website. |
INVALID_ARGUMENTS | 400 | Invalid request or ratio | Correct the named fields; send valid, uncompressed JSON. |
INVALID_BASE64 | 400 | Image cannot be decoded | Send one supported raster image, preferably JPG, PNG or WebP; remove the data-URL prefix from base64. |
INVALID_BODY | 400 | Invalid request or ratio | Correct the named fields; send valid, uncompressed JSON. |
INVALID_HOST | 403 | Host or browser origin rejected | Call from your server at centeringlab.com; do not call from another website’s browser. |
INVALID_HTTP | 400 | Invalid request or ratio | Correct the named fields; send valid, uncompressed JSON. |
INVALID_IMAGE | 400 | Image cannot be decoded | Send one supported raster image, preferably JPG, PNG or WebP; remove the data-URL prefix from base64. |
INVALID_IMAGE_URL | 400 | Image URL rejected or unreachable | Use a public HTTPS image on port 443, without credentials; check DNS, redirects and its response. |
INVALID_INPUT | 400 | Invalid request or ratio | Correct the named fields; send valid, uncompressed JSON. |
INVALID_JSON | 400 | Invalid request or ratio | Correct the named fields; send valid, uncompressed JSON. |
INVALID_ORIGIN | 403 | Host or browser origin rejected | Call from your server at centeringlab.com; do not call from another website’s browser. |
INVALID_RATIO | 400 | Invalid request or ratio | Correct the named fields; send valid, uncompressed JSON. |
JSON_REQUIRED | 415 | Invalid request or ratio | Correct the named fields; send valid, uncompressed JSON. |
MEASURE_QUEUE_FULL | 503 | Service at capacity | Wait for Retry-After and retry with backoff. |
MEASURE_TIMEOUT | 504 | Time limit exceeded | Try a smaller image or a faster image host; use the website if needed. |
MEASURE_WORKER_FAILED | 500 | Server or worker failure | Retry with backoff, try a smaller photo, or use the website. |
METHOD_NOT_ALLOWED | 405 | Route or method unavailable | Use an endpoint and method listed here. |
NOT_AN_IMAGE | 400 | Image URL rejected or unreachable | Use a public HTTPS image on port 443, without credentials; check DNS, redirects and its response. |
NOT_FOUND | 404 | Route or method unavailable | Use an endpoint and method listed here. |
RATE_LIMIT_CAPACITY | 503 | Service at capacity | Wait for Retry-After and retry with backoff. |
RATE_LIMIT_DAY | 429 | Request quota reached | Wait for Retry-After (seconds); the daily measurement quota resets at UTC midnight. |
RATE_LIMIT_MINUTE | 429 | Request quota reached | Wait for Retry-After (seconds); the daily measurement quota resets at UTC midnight. |
REQUEST_CANCELLED | 499 | Cancelled request | Retry only if the client still needs the result. |
REQUEST_CAPACITY | 503 | Service at capacity | Wait for Retry-After and retry with backoff. |
UNSAFE_IMAGE_URL | 400 | Image URL rejected or unreachable | Use a public HTTPS image on port 443, without credentials; check DNS, redirects and its response. |
UNSUPPORTED_ENCODING | 400 / 415 | Invalid request or ratio | Correct the named fields; send valid, uncompressed JSON. |
UNSUPPORTED_IMAGE | 400 | Image cannot be decoded | Send 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.
| Limit | Current value |
|---|---|
POST /measure · IP | 20 per minute; 200 per UTC day |
POST /grade + GET /standards · IP | 120 per minute; No daily quota in the limiter |
GET /health + GET /openapi.json · IP | 120 per minute; No daily quota in the limiter |
JSON body | 12 MiB (12582912 bytes) |
Decoded image bytes | 15 MiB (15728640 bytes) |
Decoded pixels (all frames) | 40000000 |
Measurement, including queue | 15 s |
Image download | 10 s |
Active measurements | 2 |
Queued measurements | 8 |
In-flight HTTP requests | 16 |
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.
Centering Lab · Updated on