Chips Story API

Photograph a table of poker chips, get back a denomination-by-denomination count and a total. One endpoint, one call, no setup: you tell us which denominations are in play, we do the rest.

Try itGet an API key

Authentication

Every request needs a key.

Authorization: Bearer cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx

X-API-Key is accepted as an alternative. Keys are shown once, at creation, and stored only as a hash — if you lose one, revoke it and issue another.

There are no CORS headers on this API, deliberately: a key in a browser bundle is a key every visitor has. Call it from your server.

Test your key

Runnable as-is — it pipes one of our sample photos straight in, so you don't need a picture of poker chips to hand.

Set CHIPS_STORY_API_KEY to the key you copied when you created it, then run:

curl -s https://portal.chipsstory.com/samples/pokerstars-four.jpg \
| curl -s https://api.chipsstory.com/api/v1/count \
    -H "Authorization: Bearer $CHIPS_STORY_API_KEY" \
    -F "image=@-;filename=chips.jpg;type=image/jpeg" \
    -F 'denominations=[
          {"value": 100,   "color_name": "purple"},
          {"value": 500,   "color_name": "red"},
          {"value": 1000,  "color_name": "orange"},
          {"value": 5000,  "color_name": "blue"}
        ]'

A working key returns a chip_count of 55 across 4 stacks. The count is stable; the values can move a little between runs, because the denomination read is a model output, not a lookup.

401 invalid_key means the key is wrong or was revoked; 402 quota_exceededmeans the key is valid but the month's allowance is gone. Both confirm authentication is wired up correctly.

Note this spends one request against your quota — counting is the only thing the API does, so there is no free way to check a key.

Framing rules

Not style advice — the API rejects photos that break these.

The counting model is calibrated for one horizontal row of side-by-side stacks, photographed side-on at a consistent distance. That is the arrangement it was trained on, and on anything else it does not fail loudly — it returns a confident, wrong number. So we check the arrangement first and reject the photo rather than hand you a total you cannot trust.

A rejected request returns 422 with invalid_framing and a message naming the specific rule that failed, which is safe to show to your end user verbatim.

Centre the row in the frame. The training photos all place the stacks in the middle, so a row shoved to one edge is outside what the model has seen. Note this is not enforced by the check above — a badly centred photo returns 200 and a number, so a 200 is not confirmation that your camera is aimed well. Worth getting right once when you set a fixed camera up, rather than discovering it in your totals. See Getting good results.

RuleWhy
One row, side by sideStack bases must sit on a common line. Stacks arranged in depth, or in a second row behind the first, are rejected — the far row is both occluded and at a different scale.
Not bunched togetherNeighbouring stacks may touch but must not substantially overlap. Each stack is cropped and counted in isolation, and a stack hidden behind another cannot be.
Even distanceAll stacks roughly the same distance from the camera. Wildly differing apparent widths mean a receding shot, where the far stacks are too small to segment reliably.
Subject fills the frameStacks smaller than ~0.4% of the image area are ignored, so chips on a neighbouring table are not counted as yours.

A single stack is always accepted — the rules above only apply once there are two or more.

You can opt out with strict_framing=false, which skips the check and counts whatever it finds. Only do this if you are showing the result to a human who will sanity-check it; the totals are not reliable on arrangements the model was not built for.

Count chips

POST /api/v1/count

Send the image as multipart/form-data, or as base64 in a JSON body via image_base64. Images are capped at 15 MB and downscaled to 1600px on the long edge before analysis — sending anything larger only costs you upload time.

FieldTypeDescription
imagefileRequired. JPEG, PNG, WebP or HEIC. A flat-on view of the stacks with the printed faces of the top chips visible.
denominationsJSON arrayRequired. 1–24 entries, each with a positive integer value and optionally color_name, a hex color, and a reference photo (see below). Values must be unique.
denominations[i].image_base64stringOptional. A reference photo of that denomination’s own chip — a whole-table shot or a crop is fine. In a multipart/form-data request you can send the same photo as a file part named reference_image_<i>instead (0-based, matching the denomination’s position in the array); the file part is preferred if both are sent. Same size cap as image.

Supply colour names where you can. The value printed on a chip is read directly, but glare, blur and rotation all happen. A colour name does two jobs: it cross-checks the reading, and it recovers stacks whose faces were unreadable. Denominations resolved that way come back flagged needs_review.

Attach a reference photo for a bigger accuracy gain.When a denomination carries a reference photo, its label read compares the chip’s whole appearance — rim colour, edge pattern, inlay — against the reference, not just the printed digits. Measured on our labelled set: value accounted for rose from 86.0% to 88.7%, and denomination accuracy from 89.4% to 90.8%, driven mostly by abstentions turning into confident reads. It costs roughly 3x the normal label-read time per stack, and only applies to requests that actually attach a reference photo — no flag to opt in, and no cost if you don’t use it.

curl https://api.chipsstory.com/api/v1/count \
  -H "Authorization: Bearer $CHIPS_STORY_API_KEY" \
  -F "image=@table.jpg" \
  -F "reference_image_0=@chip-25.jpg" \
  -F 'denominations=[
        {"value": 25,  "color_name": "green"},
        {"value": 100, "color_name": "black"}
      ]'
curl https://api.chipsstory.com/api/v1/count \
  -H "Authorization: Bearer $CHIPS_STORY_API_KEY" \
  -F "image=@table.jpg" \
  -F 'denominations=[
        {"value": 25,   "color_name": "green"},
        {"value": 100,  "color_name": "black"},
        {"value": 500,  "color_name": "purple"},
        {"value": 1000, "color_name": "yellow"},
        {"value": 5000, "color_name": "orange"}
      ]'

Response

A clean count: five stacks, every denomination read straight off the printed face. Subtotals sum to total and stack counts sum to chip_count, always — you can reconcile against either.

{
  "object": "chip_count",
  "total": 1203000,
  "chip_count": 73,
  "stack_count": 5,
  "image": { "width": 1600, "height": 1200 },
  "denominations": [
    { "value": 100000, "count": 8,  "subtotal": 800000, "needs_review": false },
    { "value": 25000,  "count": 12, "subtotal": 300000, "needs_review": false },
    { "value": 5000,   "count": 15, "subtotal": 75000,  "needs_review": false },
    { "value": 1000,   "count": 18, "subtotal": 18000,  "needs_review": false },
    { "value": 500,    "count": 20, "subtotal": 10000,  "needs_review": false }
  ],
  "stacks": [
    { "id": 0, "value": 500,    "count": 20, "subtotal": 10000,
      "color_name": "Purple", "resolved_by": "ocr", "needs_review": false },
    { "id": 1, "value": 1000,   "count": 18, "subtotal": 18000,
      "color_name": "Yellow", "resolved_by": "ocr", "needs_review": false },
    { "id": 2, "value": 5000,   "count": 15, "subtotal": 75000,
      "color_name": "Red",    "resolved_by": "ocr", "needs_review": false },
    { "id": 3, "value": 25000,  "count": 12, "subtotal": 300000,
      "color_name": "Green",  "resolved_by": "ocr", "needs_review": false },
    { "id": 4, "value": 100000, "count": 8,  "subtotal": 800000,
      "color_name": "Blue",   "resolved_by": "ocr", "needs_review": false }
  ],
  "unmatched_chips": 0,
  "usage": { "units": 1, "quota_limit": 250, "quota_remaining": 243 }
}

The same shape when we are less sure. Stack 1 was recovered from its colour rather than its face, so it is flagged. Stack 2 could not be valued at all: its 11 chips are counted in chip_count and unmatched_chips, and deliberately left out of total — we would rather return a total that is short than one that is invented.

{
  "object": "chip_count",
  "total": 11900,
  "chip_count": 54,
  "stack_count": 3,
  "image": { "width": 1600, "height": 1200 },
  "denominations": [
    { "value": 500, "count": 19, "subtotal": 9500, "needs_review": true  },
    { "value": 100, "count": 24, "subtotal": 2400, "needs_review": false }
  ],
  "stacks": [
    { "id": 0, "value": 100,  "count": 24, "subtotal": 2400,
      "color_name": "Black", "resolved_by": "ocr",       "needs_review": false },
    { "id": 1, "value": 500,  "count": 19, "subtotal": 9500,
      "color_name": "Red",   "resolved_by": "colorName", "needs_review": true  },
    { "id": 2, "value": null, "count": 11, "subtotal": 0,
      "color_name": "Blue",  "resolved_by": null,        "needs_review": true  }
  ],
  "unmatched_chips": 11,
  "usage": { "units": 1, "quota_limit": 250, "quota_remaining": 242 }
}
FieldMeaning
totalSum of every chip we could both count and value. Chips whose denomination could not be resolved are excluded — see unmatched_chips.
chip_countIndividual chips detected across all stacks.
resolved_byocr — the printed value was read directly, highest confidence. colorName — inferred from colour, which uniquely identified one of your denominations. colorMatch — matched by colour distance, last resort. null — unresolved. Anything other than ocr also sets needs_review.
needs_reviewWe are not confident in this denomination. Surface it for human confirmation rather than treating it as settled.
unmatched_chipsCounted but not valued — included in chip_count, excluded from total.

Rate limits and quota

Both are reported on every successful response.

Two independent limits: requests per minute per key, and billable units per calendar month per account. Several keys share one monthly budget but each get their own per-minute allowance.

X-RateLimit-Limit: 10
X-RateLimit-Remaining: 7
X-RateLimit-Reset: 1786000860     # unix seconds
X-Quota-Limit: 250
X-Quota-Remaining: 243

Exceeding the per-minute limit returns 429 with a Retry-After header. Exhausting the monthly quota returns 402 until the next period. A request that fails with a 5xx is refunded — you are not charged for our failures.

Errors

Every error shares one shape, so you can branch on error.code.

{
  "error": {
    "code": "rate_limited",
    "message": "Rate limit exceeded. Slow down and retry.",
    "retry_after": 34
  }
}
StatusCodeMeaning
400invalid_requestMalformed body, or denominations failed validation.
401missing_api_keyNo key was sent.
401invalid_keyThe key does not exist.
401revoked_keyThe key was revoked in the portal.
401expired_keyThe key passed its expiry date.
402quota_exceededMonthly quota exhausted.
403org_suspendedThe account is suspended.
413payload_too_largeImage exceeds 15 MB.
415unsupported_media_typeContent-Type was neither multipart nor JSON.
422invalid_framingThe photo is not a single row of side-by-side stacks. Message says which rule failed.
429rate_limitedPer-minute limit exceeded. Honour Retry-After.
502upstream_errorCounting failed. Safe to retry; not billed.
500internal_errorUnexpected failure. Not billed.

Node example

const form = new FormData();
form.append("image", new Blob([bytes], { type: "image/jpeg" }), "table.jpg");
form.append("denominations", JSON.stringify([
  { value: 100,   color_name: "black" },
  { value: 500,   color_name: "purple" },
  { value: 1000,  color_name: "yellow" },
  { value: 5000,  color_name: "red" },
  { value: 25000, color_name: "green" },
]));

const res = await fetch("https://api.chipsstory.com/api/v1/count", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.CHIPS_STORY_API_KEY}` },
  body: form,
});

if (res.status === 429) {
  const wait = Number(res.headers.get("Retry-After") ?? 60);
  // back off and retry after `wait` seconds
}

const { total, stacks, unmatched_chips } = await res.json();

// Anything we could not read off the printed face is flagged rather than
// guessed — route those to a human instead of treating the total as settled.
const uncertain = stacks.filter((s) => s.needs_review);
if (uncertain.length || unmatched_chips > 0) {
  // e.g. show the operator the stacks in question before booking the total
}

Getting good results

  • Put the row of stacks in the middle of the frame. Every photo the model was trained on is composed that way, so a row pushed into a corner or up against an edge is something it has not seen. This is the one to get right in a fixed camera setup: aim it once and every request after that inherits the framing.
  • Fill the frame with the stacks. Accuracy tracks how large the chips are in the picture more than any other single factor — shot from across a table, individual chips get too thin to separate and the count comes back low.
  • Shoot roughly level with the table, not from directly above.
  • Keep the printed face of each stack's top chip visible and in focus.
  • Avoid direct glare on the top faces — it is the single biggest cause of a stack falling back to colour matching.
  • Leave a little space between stacks. Touching stacks are separated automatically, but a gap makes it cleaner — and heavy overlap is rejected outright, see Framing rules.
  • Pass every denomination that could be present, and no more. Extra values the table doesn't contain give the reader more chances to pick wrong.