CritterKey ID — partner integration reference

The three partner-facing endpoints your device exercises at feed time, with copy-pasteable curl, exact response shapes, and the documented error envelopes. Pair this page with the live /demo reference client.

← Back to home

POST /api/pets/:id/biometric-signatures

Enroll a new biometric signature against an existing pet. Auth is owner-session: the request body’s owner_email must match the owner_email stored on the pets row.

Working curl — enroll a nose-print signature

curl -X POST http://localhost:3000/api/pets/<PET_ID>/biometric-signatures \
  -F "image=@/path/to/nose.jpg" \
  -F "owner_email=<OWNER_EMAIL>" \
  -F "signature_type=nose_print" \
  -F "confidence_score=0.92" \
  -F "device_id=feeder-A4-21" \
  -F "model_version=critterkey-v1" \
  -F "notes=Initial nose-print enrollment"

Replace <PET_ID> with the pet's id from /portal and <OWNER_EMAIL> with the value used at /register. The file mimetype must be image/* and the file must be ≤ 10 MB.

Request shape — multipart/form-data fields

FieldTypeRequiredNotes
imagemultipart filerequiredRaw signature image bytes; image/* mimetype; ≤ 10 MB.
owner_emailstringrequiredMust match pets.owner_email exactly (case-insensitive). Otherwise 403.
signature_typestring (≤ 50 chars)requiredOne of nose_print, face, iris, or your own modality label.
confidence_scorenumber 0..1optionalYour device’s read-time confidence; must be a finite number ≥ 0 and ≤ 1.
signature_timestampISO 8601 stringoptionalWhen the sample was captured; falls back to server time.
device_idstringoptionalFree-form identifier your hardware echoes back on the read path.
model_versionstring (≤ 50 chars)optionalEnrollment-pipeline version, surfaced on the read path’s metadata list.
notesstring (≤ 2000 chars)optionalOperator notes; trimmed and capped server-side.

Response shape — 201 Created

Body

{
  "signature": {
    "id": 17,
    "pet_id": 42,
    "signature_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
    "signature_type": "nose_print",
    "confidence_score": 0.92,
    "captured_at": "2026-08-11T14:32:01.000Z",
    "device_id": "feeder-A4-21",
    "model_version": "critterkey-v1",
    "notes": "Initial nose-print enrollment",
    "created_at": "2026-08-11T14:32:02.418Z"
  }
}

Error envelopes

404{"error": "Pet not found"}
403{"error": "owner_email does not match this pet"}
400{"error": "image file is required (multipart field \"image\")"}
400{"error": "signature_type is required (string, <=50 chars)"}
400{"error": "confidence_score must be 0..1"}
400{"error": "signature_timestamp is not a valid date"}

GET /api/pets/:id/biometric-signatures

List the metadata for every signature enrolled against a pet. Auth is owner-session via X-Owner-Email header or ?owner_email= query parameter — the same gating the owner’s dashboard uses.

Raw payload is never returned. Your device never receives the raw biometric — only the modality, model version, notes, and enrollment timestamp for each signature this pet has stored. The server deliberately strips signature_hash and the underlying image bytes from the response. Verify this on your own integration: the response shape below contains neither key.

Working curl — list a pet's enrolled signatures

curl -X GET http://localhost:3000/api/pets/<PET_ID>/biometric-signatures \
  -H "X-Owner-Email: <OWNER_EMAIL>"

Equivalent to curl ... &owner_email=<OWNER_EMAIL> as a query parameter if you cannot set headers in your device SDK.

Response shape — 200 OK

Body

{
  "pet_id": 42,
  "signatures": [
    {
      "id": 17,
      "modality": "nose_print",
      "model_version": "critterkey-v1",
      "notes": "Initial nose-print enrollment",
      "enrolled_at": "2026-08-11T14:32:01.000Z"
    }
  ]
}
Empty when none enrolled. When the pet has no signatures yet, the response is { "pet_id": 42, "signatures": [] } — an HTTP 200 with an empty array, not a 404. This is the expected shape after a fresh registration.

Error envelopes

404{"error": "Pet not found"}
403{"error": "owner_email does not match this pet"}

POST /api/identify

Partner-device read flow. Send a fresh signature image; the server hashes it and returns ranked matches against every enrolled pet, scoped to your integration key.

Matches are hash-only. The server computes a SHA-256 fingerprint of the uploaded bytes and compares it against the per-pet hashes stored in biometric_signatures. The raw image is buffered in memory only (multer.memoryStorage()) and discarded as soon as the hash check completes — it is never persisted to disk or echoed back in any response.

Auth — X-API-Key

Send your partner integration key in the X-API-Key request header. The server stores only the SHA-256 digest of the raw key — if your real key ever leaks, rotation is one DELETE + re-seed against partner_api_keys.

X-API-Key: "critterkey-test-key-demo"

The value above is the dev-mode seeded token — the same one shown on the live /demo reference client. Paste the curl below as-is against a running server.

Working curl — submit a fresh signature image

curl -X POST http://localhost:3000/api/identify \
  -H "X-API-Key: "critterkey-test-key-demo"" \
  -F "image=@/path/to/nose.jpg" \
  -F "device_id=feeder-A4-21"

Image must be image/* mimetype, ≤ 10 MB. device_id is optional and surfaces in dev-mode logs for debugging call provenance.

Request shape — multipart/form-data fields

FieldTypeRequiredNotes
imagemultipart filerequiredRaw signature image bytes; image/* mimetype; ≤ 10 MB. Buffered in memory only — never persisted.
device_idstringoptionalEchoed on the dev demo page; opaque to the server.

Response shape — 200 OK (matches found)

Body

{
  "matches": [
    { "pet_id": 42, "confidence_score": 1.0 }
  ],
  "count": 1,
  "partner": "Test Partner"
}
Empty when nothing matches. When no enrolled signature’s hash matches the uploaded image, the response is { "matches": [], "count": 0, "partner": "Test Partner" } — an HTTP 200 not a 404. Matches are sorted by confidence_score descending and any score below 0.5 is filtered out server-side.

Error envelopes

401{"error": "X-API-Key header is required"}
401{"error": "Invalid API key"}
400{"error": "image file is required (multipart field \"image\")"}