API reference · v1.0.0

Precisino API

Photo verdicts as a service — a shot, a reference, a checklist, an answer.

Precisino judges a photo against a reference. You register a standard (the reference photo plus an ordered checklist of what "done right" looks like), you post a shot, and you get back a verdict: pass, fail or uncertain, one line per checklist item, and one line for the person holding the phone.

There are three objects and nothing else: tenant, standard, verdict. Anything that is not one of those — a room number, a batch, a work order, a shift, an operator name — belongs in metadata, which Precisino stores and hands back untouched. This is on purpose: the service knows what a verdict is, it does not know what your business is.

Base URL

https://precisino.it/api/v1

Questa pagina è in inglese come l’API: il contratto, i codici di errore e i nomi dei campi lo sono. Il resto del sito no — i prezzi e i contatti si leggono in italiano.

Authentication

Two doors.

API keys (Authorization: Bearer prc_live_…). A key is not a user: it is a tenant, a set of permissions and a scope. It is shown once, at creation, and only its SHA-256 and a readable prefix are stored. Keys are created from the owner's panel, never from the API itself.

Owner session — the person who signed up, authenticated with a Supabase session cookie or with that session's access token as a bearer. The owner has every permission a key can have, plus the panel-only routes (/keys, /webhooks, /tenant, /station-links), which no key can reach.

Header

Authorization: Bearer prc_live_a1b2c3d4...
Authorization: Bearer prc_test_a1b2c3d4...

Authorization: Bearer prc_live_… (or prc_test_…).

Shown once at creation; only its SHA-256 and prefix are stored. Compared in constant time. A revoked or expired key is a 401, not a 403: what was presented is not a key, rather than a person missing a permission.

Scope

A key's scope is tags, and only tags. An empty scope_tags means the whole tenant. Otherwise the key sees the standards carrying at least one of those tags, and the verdicts of those standards.

A resource outside the scope answers 404 not_found, never 403. A 403 would confirm that the resource exists and belongs to someone — so it does not exist, as far as that key is concerned.

Errors

Every error has the same shape: { "error": "...", "message": "...", "required": "..." }. error is a stable machine code, message is a sentence for whoever reads the logs, required appears only on 403 permission_denied and names the missing permission.

message is in English by default. Send Accept-Language with Italian first (it, it-IT, it-IT,it;q=0.9,en;q=0.8) and it comes back in Italian; any other value, or no header at all, gets English. Only the first language in the header counts. Nothing else changes with the language: the status, error, required and the shape of the body are the same, so write your if against error, never against message. Error responses carry Vary: Accept-Language.

The one exception is 502 engine_error: its message is the same line the photo check writes on the verdict, so it follows the request's language field (it by default), like the verdict's own text.

Until 2 October 2026 every message was in Italian whatever the request said. A client that sends no Accept-Language now gets English.

403

{
  "error": "permission_denied",
  "message": "This key cannot create verdicts.",
  "required": "verdicts:create"
}

Rate limit

120 requests per minute per key. Over that: 429 rate_limited.

Photo size

A photo is 4 MB maximum; over that every route answers 413 photo_too_large, with max_bytes and a hint. Resize before uploading: longest side 2048 px, JPEG quality 0.85, and apply the EXIF orientation before resizing, or portrait shots arrive on their side. A phone photo treated that way lands under 1 MB, which is less to upload, less to bill and a shorter wait for the person holding the phone — full resolution buys nothing for a "is the cap on or not" check. The reference web client does exactly this before every upload.

Above 4.5 MB the request does not even reach Precisino: the platform rejects it with its own 413, which carries no JSON body.

413

{
  "error": "photo_too_large",
  "max_bytes": 4194304,
  "hint": "Resize before upload: longest side 2048 px, JPEG quality 0.85, apply EXIF orientation first."
}

Pagination

Cursor-based. ?limit= is 1..100 (default 50), ?cursor= is the opaque next_cursor of the previous page. Lists are ordered newest first (created_at desc, id desc). When next_cursor is null you have the last page. Offsets are not supported: a register that is being written while it is being read cannot be paged by offset without skipping or repeating rows.

Page

{
  "data": [ ... ],
  "next_cursor": "dj0xJmM9MjAyNi0wOS0xNlQxMDoxMjozM1o"
}

Idempotency

Send the same key again to retry safely: the original verdict comes back with 200, nothing is judged twice and nothing is billed twice. The key names the *verdict*, not the request, so a second call with the same key and a different body gets that first verdict too — retrying is the reason it exists, and guessing which of two bodies was meant would be worse than answering with the one that was judged. Unique per tenant, kept forever.

Header

Idempotency-Key: shot-2026-09-16-0007

Test mode

Every route works in both modes. A prc_test_… key consumes nothing and gets a deterministic verdict from the photo itself, with no model call. Verdicts are separated by mode: a test key sees test verdicts, a live key sees live ones. Standards are shared between the two — a reference photo is a reference photo.

The owner switches with ?mode=test; otherwise the tenant's mode_default applies.

Standards

The reference photo and its checklist — what "done right" looks like.

GET/standards

List standards

Auth:standards:read· or the owner session

The standards this caller can see. Archived ones are left out unless archived=true. A scoped key only ever sees the standards carrying one of its tags.

Parameters

  • limitintegerin query

    1 to 100. Default 50.

  • cursorstringin query

    The next_cursor of the previous page. Opaque.

  • modeModein query

    live or test. Only meaningful for the owner session — a key already carries its own mode and this parameter does not move it.

  • tagstringin query

    Only standards carrying this tag slug.

  • qstringin query

    Case-insensitive match on the standard's name.

  • archivedbooleanin query

    Include the archived ones as well. They are never the default.

  • includestringin query

    Comma-separated extras. month_counts adds verdicts_this_month to every row — shots judged against that standard since the first of the current calendar month (UTC), in the caller's mode. It costs one extra read for the whole page, so you ask for it rather than get it for free. This is what the web client's job list shows.

  • metadata[key]objectin query

    Filter on any metadata key, compared as text: ?metadata[batch]=A-12. Repeat for more than one (AND, up to ten). Keys match ^[A-Za-z0-9_.-]{1,64}$; anything else is a 422.

Responses

  • 200PageStandards

    A page of standards.

  • 400Error

    The cursor is not readable.

    Example

    {
      "error": "invalid_cursor",
      "message": "Invalid cursor. Pass the `next_cursor` from the previous page, unchanged."
    }
  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    The key lacks a permission. required names it.

    Example

    {
      "error": "permission_denied",
      "message": "This API key doesn't have the verdicts:create permission.",
      "required": "verdicts:create"
    }
  • 422Error

    The body parsed, but what it says does not stand up.

    Example

    {
      "error": "validation_failed",
      "message": "These tags aren't in this account's vocabulary: corsie."
    }
  • 429Error

    More than 120 requests in a minute on this key.

    Example

    {
      "error": "rate_limited",
      "message": "Too many requests. Try again in a minute."
    }

curl

curl https://precisino.it/api/v1/standards \
  -H "Authorization: Bearer prc_live_..."
POST/standards

Create a standard from a reference photo

Auth:standards:write· or the owner session

Creates the standard and drafts its checklist from the photo — the same function the website's demo uses. The draft comes back in the response: correct it with PATCH /standards/{id} and the corrected version is the one verdicts are judged against.

In test mode the checklist is deterministic and no model is called.

The draft is written in Italian unless you pass language=en.

sector is inherited from the tenant when it is not given. A key with a scope inherits its own tags when none are passed, and cannot create a standard outside them: it would be writing something it could not read back.

Parameters

  • skip_checklistbooleanin query

    Create the standard with an empty checklist and no model call — the panel's "blank" button. Write the checklist yourself with PATCH /standards/{id}.

Request body · multipart/form-data

  • namestringrequired
  • reference_photostring (binary)required

    JPEG, PNG, WebP or HEIC. 4 MB maximum: over that, 413 photo_too_large. Resize before uploading — longest side 2048 px, JPEG quality 0.85, EXIF orientation applied before resizing; the reference web client does exactly this, and a phone photo treated that way lands under 1 MB. Requests above 4.5 MB never reach Precisino at all: the platform rejects them itself, with a 413 and no JSON body.

    The file is checked before anything is stored: something that is not an image is a 422 invalid_photo. It is stored as it was taken — the engine makes its own smaller copy.

  • sectorSectorvedi Sector

    Closed vocabulary. The values are Italian; they are identifiers, not copy.

  • tagsstring[]

    Tag slugs, from the tenant's vocabulary. A slug that is not in GET /tags is a 422, not a new tag: tags are created on purpose, never by typing. Repeat the field, use tags[], or send one comma-separated line.

  • metadatastring

    A JSON object, serialized. Stored and returned untouched.

  • languageit | en

    The language the drafted checklist is written in: it (the default) or en. Only the item texts change — item ids never depend on the language, and in test mode the same item has the same id in both. The message of a 502 engine_error follows it too (every other error message follows Accept-Language).

    It is a parameter of this request, not a property of the standard: it is not stored and does not come back. It is still validated, but has no effect, with skip_checklist=true. Any other value is a 422 validation_failed.

Responses

  • 201Standard

    Created, with the drafted checklist.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    The key lacks a permission. required names it.

    Example

    {
      "error": "permission_denied",
      "message": "This API key doesn't have the verdicts:create permission.",
      "required": "verdicts:create"
    }
  • 413Error

    The photo is over 4 MB. max_bytes is the exact limit and hint is the rule to follow before uploading: longest side 2048 px, JPEG quality 0.85, EXIF orientation applied first. A phone photo treated that way is normally under 1 MB.

    Note that a request over 4.5 MB never gets here: the platform rejects it with its own 413, which has no JSON body.

    Example

    {
      "error": "photo_too_large",
      "message": "The photo is over 4 MB. Resize it before uploading: longest side 2048 px at JPEG quality 0.85 is plenty for the photo check, and usually under 1 MB.",
      "max_bytes": 4194304,
      "hint": "Resize before upload: longest side 2048 px, JPEG quality 0.85, apply EXIF orientation first."
    }
  • 415Error

    A route that takes a file was sent something that is not multipart/form-data.

    Example

    {
      "error": "unsupported_media_type",
      "message": "This endpoint expects a `multipart/form-data` body."
    }
  • 422Error

    The body parsed, but what it says does not stand up.

    Example

    {
      "error": "validation_failed",
      "message": "These tags aren't in this account's vocabulary: corsie."
    }
  • 429Error

    More than 120 requests in a minute on this key.

    Example

    {
      "error": "rate_limited",
      "message": "Too many requests. Try again in a minute."
    }
  • 502Error

    The photo check did not produce anything usable. It is a 502 and not a 422 on purpose: the request was fine, the thing behind it was not, and the answer to that is to try again. Unlike every other error, message follows the request's language field, not Accept-Language (the example is language=en).

    Example

    {
      "error": "engine_error",
      "message": "The photo check didn't answer in time: try again."
    }

curl

curl -X POST https://precisino.it/api/v1/standards \
  -H "Authorization: Bearer prc_live_..." \
  -F name="..." \
  -F reference_photo=@reference_photo.jpg
GET/standards/{id}

Retrieve a standard

Auth:standards:read· or the owner session

Parameters

  • idstringin pathrequired
  • modeModein query

    live or test. Only meaningful for the owner session — a key already carries its own mode and this parameter does not move it.

Responses

  • 200Standard

    The standard.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    The key lacks a permission. required names it.

    Example

    {
      "error": "permission_denied",
      "message": "This API key doesn't have the verdicts:create permission.",
      "required": "verdicts:create"
    }
  • 404Error

    No such resource — or one outside this key's scope. The two are answered identically on purpose: a 403 would confirm the thing exists.

    Example

    {
      "error": "not_found",
      "message": "Standard not found."
    }
  • 429Error

    More than 120 requests in a minute on this key.

    Example

    {
      "error": "rate_limited",
      "message": "Too many requests. Try again in a minute."
    }

curl

curl https://precisino.it/api/v1/standards/{id} \
  -H "Authorization: Bearer prc_live_..."
PATCH/standards/{id}

Update a standard

Auth:standards:write· or the owner session

Name, checklist, tags and metadata. Every change to the checklist creates a new version and increments version; changing only the name, the tags or the metadata does not. Existing verdicts keep pointing at the version they were judged against.

A new version fires standard.versioned.

Parameters

  • idstringin pathrequired

Request body · application/json

  • namestring
  • sectorSectorvedi Sector

    Closed vocabulary. The values are Italian; they are identifiers, not copy.

  • checklistChecklistItemInput[]

    The whole list, in order. Keep the id of an item you are renaming: the id is the item's identity and the text is only what it says today. A new item may omit id and one is assigned.

  • tagsstring[]
  • metadataobject

Responses

  • 200Standard

    The updated standard.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    The key lacks a permission. required names it.

    Example

    {
      "error": "permission_denied",
      "message": "This API key doesn't have the verdicts:create permission.",
      "required": "verdicts:create"
    }
  • 404Error

    No such resource — or one outside this key's scope. The two are answered identically on purpose: a 403 would confirm the thing exists.

    Example

    {
      "error": "not_found",
      "message": "Standard not found."
    }
  • 422Error

    The body parsed, but what it says does not stand up.

    Example

    {
      "error": "validation_failed",
      "message": "These tags aren't in this account's vocabulary: corsie."
    }
  • 429Error

    More than 120 requests in a minute on this key.

    Example

    {
      "error": "rate_limited",
      "message": "Too many requests. Try again in a minute."
    }

curl

curl -X PATCH https://precisino.it/api/v1/standards/{id} \
  -H "Authorization: Bearer prc_live_..." \
  -H "Content-Type: application/json" \
  -d '{ ... }'
DELETE/standards/{id}

Archive a standard

Auth:standards:write· or the owner session

Archives, it does not delete: archived_at is set and the standard drops out of lists. Its verdicts stay readable and the standard itself is still retrievable by id — a register whose rows point at nothing is not a register.

Archiving something already archived is not an error: the point is that it is out of the way, and it is.

Parameters

  • idstringin pathrequired

Responses

  • 204

    Archived. No body: there is nothing to say that the caller does not already know. GET /standards/{id} still answers, with archived_at set.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    The key lacks a permission. required names it.

    Example

    {
      "error": "permission_denied",
      "message": "This API key doesn't have the verdicts:create permission.",
      "required": "verdicts:create"
    }
  • 404Error

    No such resource — or one outside this key's scope. The two are answered identically on purpose: a 403 would confirm the thing exists.

    Example

    {
      "error": "not_found",
      "message": "Standard not found."
    }
  • 429Error

    More than 120 requests in a minute on this key.

    Example

    {
      "error": "rate_limited",
      "message": "Too many requests. Try again in a minute."
    }

curl

curl -X DELETE https://precisino.it/api/v1/standards/{id} \
  -H "Authorization: Bearer prc_live_..."
POST/standards/{id}/reference-photo

Replace the reference photo

Auth:standards:write· or the owner session

A new reference photo is always a new version. With ?regenerate=true the checklist is drafted again from the new photo, replacing the current one — item ids are reassigned, so do it when the job itself changed, not when you just took a sharper picture.

Fires standard.versioned.

Parameters

  • idstringin pathrequired
  • regeneratebooleanin query

Request body · multipart/form-data

  • reference_photostring (binary)required

    JPEG, PNG, WebP or HEIC. 4 MB maximum: over that, 413 photo_too_large. Resize before uploading — longest side 2048 px, JPEG quality 0.85, EXIF orientation applied before resizing; the reference web client does exactly this. Requests above 4.5 MB are rejected by the platform itself, with a 413 and no JSON body.

  • languageit | en

    The language the checklist is drafted in again, with regenerate=true: it (the default) or en. Without regenerate it has no effect, but it is still validated: any other value is a 422 validation_failed. Not stored.

Responses

  • 200Standard

    The standard, one version up.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    The key lacks a permission. required names it.

    Example

    {
      "error": "permission_denied",
      "message": "This API key doesn't have the verdicts:create permission.",
      "required": "verdicts:create"
    }
  • 404Error

    No such resource — or one outside this key's scope. The two are answered identically on purpose: a 403 would confirm the thing exists.

    Example

    {
      "error": "not_found",
      "message": "Standard not found."
    }
  • 413Error

    The photo is over 4 MB. max_bytes is the exact limit and hint is the rule to follow before uploading: longest side 2048 px, JPEG quality 0.85, EXIF orientation applied first. A phone photo treated that way is normally under 1 MB.

    Note that a request over 4.5 MB never gets here: the platform rejects it with its own 413, which has no JSON body.

    Example

    {
      "error": "photo_too_large",
      "message": "The photo is over 4 MB. Resize it before uploading: longest side 2048 px at JPEG quality 0.85 is plenty for the photo check, and usually under 1 MB.",
      "max_bytes": 4194304,
      "hint": "Resize before upload: longest side 2048 px, JPEG quality 0.85, apply EXIF orientation first."
    }
  • 415Error

    A route that takes a file was sent something that is not multipart/form-data.

    Example

    {
      "error": "unsupported_media_type",
      "message": "This endpoint expects a `multipart/form-data` body."
    }
  • 422Error

    The body parsed, but what it says does not stand up.

    Example

    {
      "error": "validation_failed",
      "message": "These tags aren't in this account's vocabulary: corsie."
    }
  • 429Error

    More than 120 requests in a minute on this key.

    Example

    {
      "error": "rate_limited",
      "message": "Too many requests. Try again in a minute."
    }
  • 502Error

    The photo check did not produce anything usable. It is a 502 and not a 422 on purpose: the request was fine, the thing behind it was not, and the answer to that is to try again. Unlike every other error, message follows the request's language field, not Accept-Language (the example is language=en).

    Example

    {
      "error": "engine_error",
      "message": "The photo check didn't answer in time: try again."
    }

curl

curl -X POST https://precisino.it/api/v1/standards/{id}/reference-photo \
  -H "Authorization: Bearer prc_live_..." \
  -F reference_photo=@reference_photo.jpg

Verdicts

A shot, judged.

GET/verdicts

List verdicts

Auth:verdicts:read· or the owner session

The register. Verdicts of the standards this caller can see, in the mode this caller is working in, newest first.

Any metadata key can be a filter: ?metadata[batch]=A-12. Values are compared as text — the query string has no types and guessing them would make the same filter behave two ways. Up to ten metadata filters per request, combined with AND.

Parameters

  • limitintegerin query

    1 to 100. Default 50.

  • cursorstringin query

    The next_cursor of the previous page. Opaque.

  • modeModein query

    live or test. Only meaningful for the owner session — a key already carries its own mode and this parameter does not move it.

  • standard_idstringin query
  • tagstringin query

    Verdicts of the standards carrying this tag slug.

  • fromstringin query

    Inclusive lower bound on created_at (RFC 3339 or YYYY-MM-DD).

  • tostringin query

    Exclusive upper bound on created_at.

  • resultVerdictResultin query
  • reviewconfirmed | disputed | nonein query

    confirmed, disputed, or none for the ones nobody has looked at yet.

  • statusVerdictStatusin query
  • metadata[key]objectin query

    Filter on any metadata key, compared as text: ?metadata[batch]=A-12. Repeat for more than one (AND, up to ten). Keys match ^[A-Za-z0-9_.-]{1,64}$; anything else is a 422.

Responses

  • 200PageVerdicts

    A page of verdicts.

  • 400Error

    The cursor is not readable.

    Example

    {
      "error": "invalid_cursor",
      "message": "Invalid cursor. Pass the `next_cursor` from the previous page, unchanged."
    }
  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    The key lacks a permission. required names it.

    Example

    {
      "error": "permission_denied",
      "message": "This API key doesn't have the verdicts:create permission.",
      "required": "verdicts:create"
    }
  • 422Error

    The body parsed, but what it says does not stand up.

    Example

    {
      "error": "validation_failed",
      "message": "These tags aren't in this account's vocabulary: corsie."
    }
  • 429Error

    More than 120 requests in a minute on this key.

    Example

    {
      "error": "rate_limited",
      "message": "Too many requests. Try again in a minute."
    }

curl

curl https://precisino.it/api/v1/verdicts \
  -H "Authorization: Bearer prc_live_..."
POST/verdicts

Judge a shot

Auth:verdicts:create· or the owner session

Compares the photo against the standard's current reference and returns the verdict. Synchronous.

If the engine has not answered within 20 seconds the response is 202 with the verdict's id and status: "pending", and the finished verdict arrives on the verdict.completed webhook. Poll GET /verdicts/{id} if you have no endpoint. The verdict is billed once either way.

The shot is billed before the engine is called, and it is not refunded if the engine then fails: the verdict row says failed, the usage row stays. One shot asked for is one shot counted — a ledger with reversals in it is a second set of books.

metadata comes back exactly as you sent it. If the key carries an actor, it is written to verdict.actor and merged into metadata.actor.

In test mode nothing is consumed and the verdict is derived deterministically from the photo's own bytes — the same photo always gives the same answer, and no model is called.

Parameters

  • modeModein query

    live or test. Only meaningful for the owner session — a key already carries its own mode and this parameter does not move it.

  • Idempotency-Keystringin header

    Send the same key again to retry safely: the original verdict comes back with 200, nothing is judged twice and nothing is billed twice. The key names the *verdict*, not the request, so a second call with the same key and a different body gets that first verdict too — retrying is the reason it exists, and guessing which of two bodies was meant would be worse than answering with the one that was judged. Unique per tenant, kept forever.

Request body · multipart/form-data

  • standard_idstringrequired
  • photostring (binary)required

    JPEG, PNG, WebP or HEIC. 4 MB maximum: over that, 413 photo_too_large, answered before anything is stored or billed. Resize before uploading — longest side 2048 px, JPEG quality 0.85, EXIF orientation applied before resizing; the reference web client does exactly this, and it is also the fastest way to shorten the wait for the person holding the phone. Requests above 4.5 MB are rejected by the platform itself, with a 413 and no JSON body.

    Something that is not an image is a 422 invalid_photo, answered just as early.

  • metadatastring

    A JSON object, serialized. Returned untouched.

  • actorstring

    A JSON object { "id": "...", "label": "..." }. Only accepted when the key does not already carry one — a key with an actor speaks for that actor and cannot be told otherwise.

  • languageit | en

    The language of every item's note and of explanation: it (the default) or en. When result is pass, explanation is exactly Tutto a posto. or All good.. The checklist itself is not translated — items keep their text and their ids — and the outcome never depends on the language, in test mode either.

    It is a parameter of this request, not a property of the verdict: it is not stored and does not come back. A verdict that answers 202 still completes in this language; in the rare case it is finished later by the background sweep instead, it completes in it. A replay with the same Idempotency-Key returns the verdict as it was written. Any other value is a 422 validation_failed, answered before anything is stored or billed.

Responses

  • 200Verdict

    This Idempotency-Key already has a verdict on this tenant: here it is, unchanged. Nothing was judged again and nothing was billed again.

  • 201Verdict

    The verdict.

  • 202Verdict

    The engine is still thinking. status is pending, result is null and items is empty. Wait for verdict.completed or poll the verdict.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    The key lacks a permission. required names it.

    Example

    {
      "error": "permission_denied",
      "message": "This API key doesn't have the verdicts:create permission.",
      "required": "verdicts:create"
    }
  • 404Error

    The standard does not exist, or is outside this key's scope.

  • 413Error

    The photo is over 4 MB. max_bytes is the exact limit and hint is the rule to follow before uploading: longest side 2048 px, JPEG quality 0.85, EXIF orientation applied first. A phone photo treated that way is normally under 1 MB.

    Note that a request over 4.5 MB never gets here: the platform rejects it with its own 413, which has no JSON body.

    Example

    {
      "error": "photo_too_large",
      "message": "The photo is over 4 MB. Resize it before uploading: longest side 2048 px at JPEG quality 0.85 is plenty for the photo check, and usually under 1 MB.",
      "max_bytes": 4194304,
      "hint": "Resize before upload: longest side 2048 px, JPEG quality 0.85, apply EXIF orientation first."
    }
  • 415Error

    A route that takes a file was sent something that is not multipart/form-data.

    Example

    {
      "error": "unsupported_media_type",
      "message": "This endpoint expects a `multipart/form-data` body."
    }
  • 422Error

    The body parsed, but what it says does not stand up.

    Example

    {
      "error": "validation_failed",
      "message": "These tags aren't in this account's vocabulary: corsie."
    }
  • 429Error

    More than 120 requests in a minute on this key.

    Example

    {
      "error": "rate_limited",
      "message": "Too many requests. Try again in a minute."
    }
  • 502Error

    The engine answered within the time and what it answered was not a verdict. The row is kept with status: "failed" and a line in explanation; the shot stays billed. message is that same line, in the request's language (the example is language=en).

    Example

    {
      "error": "engine_error",
      "message": "The photo check didn't work: try again."
    }

curl

curl -X POST https://precisino.it/api/v1/verdicts \
  -H "Authorization: Bearer prc_live_..." \
  -F standard_id="..." \
  -F photo=@photo.jpg
GET/verdicts/{id}

Retrieve a verdict

Auth:verdicts:read· or the owner session

Parameters

  • idstringin pathrequired
  • modeModein query

    live or test. Only meaningful for the owner session — a key already carries its own mode and this parameter does not move it.

Responses

  • 200Verdict

    The verdict.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    The key lacks a permission. required names it.

    Example

    {
      "error": "permission_denied",
      "message": "This API key doesn't have the verdicts:create permission.",
      "required": "verdicts:create"
    }
  • 404Error

    No such resource — or one outside this key's scope. The two are answered identically on purpose: a 403 would confirm the thing exists.

    Example

    {
      "error": "not_found",
      "message": "Standard not found."
    }
  • 429Error

    More than 120 requests in a minute on this key.

    Example

    {
      "error": "rate_limited",
      "message": "Too many requests. Try again in a minute."
    }

curl

curl https://precisino.it/api/v1/verdicts/{id} \
  -H "Authorization: Bearer prc_live_..."
POST/verdicts/{id}/review

Confirm or dispute a verdict

Auth:verdicts:review· or the owner session

The verb that makes the thing better. A human says the machine was right or wrong, and says why. Reviewing again overwrites the previous review.

Fires verdict.reviewed.

Parameters

  • idstringin pathrequired

Request body · application/json

Responses

  • 200Verdict

    The reviewed verdict.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    The key lacks a permission. required names it.

    Example

    {
      "error": "permission_denied",
      "message": "This API key doesn't have the verdicts:create permission.",
      "required": "verdicts:create"
    }
  • 404Error

    No such resource — or one outside this key's scope. The two are answered identically on purpose: a 403 would confirm the thing exists.

    Example

    {
      "error": "not_found",
      "message": "Standard not found."
    }
  • 422Error

    The body parsed, but what it says does not stand up.

    Example

    {
      "error": "validation_failed",
      "message": "These tags aren't in this account's vocabulary: corsie."
    }
  • 429Error

    More than 120 requests in a minute on this key.

    Example

    {
      "error": "rate_limited",
      "message": "Too many requests. Try again in a minute."
    }

curl

curl -X POST https://precisino.it/api/v1/verdicts/{id}/review \
  -H "Authorization: Bearer prc_live_..." \
  -H "Content-Type: application/json" \
  -d '{ ... }'

Tags

The tenant's vocabulary. The only thing a key's scope is made of.

GET/tags

List the tenant's tags

Auth:any key· or the owner session

Any key of the account can read the vocabulary: it is what you need in order to pick from a list instead of typing, and it says nothing that a key's own scope does not say already. No permission is required.

Parameters

  • limitintegerin query

    1 to 100. Default 50.

  • cursorstringin query

    The next_cursor of the previous page. Opaque.

Responses

  • 200PageTags

    A page of tags.

  • 400Error

    The cursor is not readable.

    Example

    {
      "error": "invalid_cursor",
      "message": "Invalid cursor. Pass the `next_cursor` from the previous page, unchanged."
    }
  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 429Error

    More than 120 requests in a minute on this key.

    Example

    {
      "error": "rate_limited",
      "message": "Too many requests. Try again in a minute."
    }

curl

curl https://precisino.it/api/v1/tags \
  -H "Authorization: Bearer prc_live_..."
POST/tags

Create a tag

Auth:any key· or the owner session

A key with a scope cannot create tags: it would be writing its own boundary. Only a full key — empty scope_tags *and* every permission — or the owner with a session.

The 403 for a scoped key carries no required: nothing is missing that could be granted, it is the scope itself that disqualifies it.

Request body · application/json

  • slugstringrequired

    Lower case, digits and dashes. It is a key, not a label: it is what standards carry in tags[] and what a key's scope_tags name.

  • labelstring

    Defaults to the slug.

Responses

  • 201Tag

    The tag.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    The key lacks a permission. required names it.

    Example

    {
      "error": "permission_denied",
      "message": "This API key doesn't have the verdicts:create permission.",
      "required": "verdicts:create"
    }
  • 409Error

    A tag with that slug already exists in this tenant.

  • 422Error

    The body parsed, but what it says does not stand up.

    Example

    {
      "error": "validation_failed",
      "message": "These tags aren't in this account's vocabulary: corsie."
    }
  • 429Error

    More than 120 requests in a minute on this key.

    Example

    {
      "error": "rate_limited",
      "message": "Too many requests. Try again in a minute."
    }

curl

curl -X POST https://precisino.it/api/v1/tags \
  -H "Authorization: Bearer prc_live_..." \
  -H "Content-Type: application/json" \
  -d '{ ... }'

Usage

Shots this calendar month, included and over.

GET/usage

Shots this month

Auth:any key· or the owner session

Calendar month, UTC. Computed from the register of consumed shots, not read from a counter — included shots do not roll over, so there is nothing to carry.

Test-mode shots are never counted.

Parameters

  • monthstringin query

    The month you want, YYYY-MM — or any day inside it, YYYY-MM-DD. Defaults to now. Anything else is a 422: a month nobody can read must not quietly become "this month".

Responses

  • 200Usage

    The month's usage.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 422Error

    The body parsed, but what it says does not stand up.

    Example

    {
      "error": "validation_failed",
      "message": "These tags aren't in this account's vocabulary: corsie."
    }
  • 429Error

    More than 120 requests in a minute on this key.

    Example

    {
      "error": "rate_limited",
      "message": "Too many requests. Try again in a minute."
    }

curl

curl https://precisino.it/api/v1/usage \
  -H "Authorization: Bearer prc_live_..."

Tenant

The account. Owner session only.

GET/tenant

The account

Auth:any key· or the owner session

Responses

  • 200TenantWithUsage

    The tenant, with this month's usage alongside.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    A panel route reached with an API key. keys:manage and webhooks:manage do not exist as permissions: the owner does these from the panel, with a session.

    Example

    {
      "error": "owner_only",
      "message": "This endpoint needs the account owner's session. API keys can't use it."
    }

curl

curl https://precisino.it/api/v1/tenant \
  -H "Authorization: Bearer prc_live_..."
PATCH/tenant

Update the account

Auth:any key· or the owner session

Name, sector and default mode. Not the plan: billing happens outside the product, by hand, and an account cannot upgrade itself.

Request body · application/json

  • namestring
  • sectorSectorvedi Sector

    Closed vocabulary. The values are Italian; they are identifiers, not copy.

  • mode_defaultModevedi Mode

Responses

  • 200Tenant

    The updated tenant.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    A panel route reached with an API key. keys:manage and webhooks:manage do not exist as permissions: the owner does these from the panel, with a session.

    Example

    {
      "error": "owner_only",
      "message": "This endpoint needs the account owner's session. API keys can't use it."
    }
  • 422Error

    The body parsed, but what it says does not stand up.

    Example

    {
      "error": "validation_failed",
      "message": "These tags aren't in this account's vocabulary: corsie."
    }

curl

curl -X PATCH https://precisino.it/api/v1/tenant \
  -H "Authorization: Bearer prc_live_..." \
  -H "Content-Type: application/json" \
  -d '{ ... }'

Keys

API keys and station links. Owner session only.

GET/keys

List API keys

Auth:any key· or the owner session

Secrets are not here and never will be: each key shows its prefix, which is enough to recognise it. Revoked keys are included, with revoked_at set.

Parameters

  • limitintegerin query

    1 to 100. Default 50.

  • cursorstringin query

    The next_cursor of the previous page. Opaque.

  • kindApiKeyKindin query

Responses

  • 200PageApiKeys

    A page of keys.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    A panel route reached with an API key. keys:manage and webhooks:manage do not exist as permissions: the owner does these from the panel, with a session.

    Example

    {
      "error": "owner_only",
      "message": "This endpoint needs the account owner's session. API keys can't use it."
    }

curl

curl https://precisino.it/api/v1/keys \
  -H "Authorization: Bearer prc_live_..."
POST/keys

Create an API key

Auth:any key· or the owner session

The secret is in this response and in no other. It is not stored — only its SHA-256 is — so it cannot be shown again, resent or recovered. Lose it and you make a new key.

Request body · application/json

  • namestringrequired
  • modeobject
  • permissionsPermission[]required
  • scope_tagsstring[]

    Tag slugs. Empty means the whole tenant.

  • actorActor | null

    Stamped on every verdict this key creates.

  • expires_atstring | null

Responses

  • 201ApiKeyWithSecret

    The key, secret included, once.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    A panel route reached with an API key. keys:manage and webhooks:manage do not exist as permissions: the owner does these from the panel, with a session.

    Example

    {
      "error": "owner_only",
      "message": "This endpoint needs the account owner's session. API keys can't use it."
    }
  • 422Error

    The body parsed, but what it says does not stand up.

    Example

    {
      "error": "validation_failed",
      "message": "These tags aren't in this account's vocabulary: corsie."
    }

curl

curl -X POST https://precisino.it/api/v1/keys \
  -H "Authorization: Bearer prc_live_..." \
  -H "Content-Type: application/json" \
  -d '{ ... }'
GET/keys/self

The calling key, as it sees itself

Auth:any key

The only route under /keys a key may open, and it opens its own row only — there is no id to pass, the caller is the answer.

It exists for the station screen: a printed QR opens the shot screen with a key and no session, and before drawing anything the page has to know whether the key carries an actor. When it does, the station decides who is shooting and no name is asked; when actor is null, the operator is asked "who are you?" and the answer travels in metadata.actor of POST /verdicts. Guessing would mean asking for a name the API then throws away, because a key's own actor always wins.

Neither the secret nor the prefix come back: whoever is calling holds them already. An owner session gets 404 — "self" is not a thing a session has, so there is nothing to forbid.

Responses

  • 200SelfApiKey

    The calling key.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 404Error

    The caller is an owner session, not a key.

  • 429Error

    More than 120 requests in a minute on this key.

    Example

    {
      "error": "rate_limited",
      "message": "Too many requests. Try again in a minute."
    }

curl

curl https://precisino.it/api/v1/keys/self \
  -H "Authorization: Bearer prc_live_..."
DELETE/keys/{id}

Revoke a key

Auth:any key· or the owner session

Revokes, does not delete: revoked_at is set, the key stops working immediately, and the verdicts it created keep pointing at it. The key stays in GET /keys with its revoked_at filled in — it is the record of who had access.

Revoking an already revoked key answers 204 too: what matters is the state, not how many times it was asked for.

Parameters

  • idstringin pathrequired

Responses

  • 204

    Revoked.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    A panel route reached with an API key. keys:manage and webhooks:manage do not exist as permissions: the owner does these from the panel, with a session.

    Example

    {
      "error": "owner_only",
      "message": "This endpoint needs the account owner's session. API keys can't use it."
    }
  • 404Error

    No such resource — or one outside this key's scope. The two are answered identically on purpose: a 403 would confirm the thing exists.

    Example

    {
      "error": "not_found",
      "message": "Standard not found."
    }

curl

curl -X DELETE https://precisino.it/api/v1/keys/{id} \
  -H "Authorization: Bearer prc_live_..."
GET/keys/{id}/access-log

Recent accesses for a key

Auth:any key· or the owner session

The last 100, newest first. status is the outcome of *authentication* — 200 when the key got through, 401/403/429 when it did not.

There is no cursor here: the database itself keeps only the last 100 per key, so there is no second page to ask for. next_cursor is always null, and stays in the response only so that every list in this API has the same shape.

Parameters

  • idstringin pathrequired
  • limitintegerin query

    1..100. Defaults to 100.

Responses

  • 200PageAccessLog

    A page of accesses.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    A panel route reached with an API key. keys:manage and webhooks:manage do not exist as permissions: the owner does these from the panel, with a session.

    Example

    {
      "error": "owner_only",
      "message": "This endpoint needs the account owner's session. API keys can't use it."
    }
  • 404Error

    No such resource — or one outside this key's scope. The two are answered identically on purpose: a 403 would confirm the thing exists.

    Example

    {
      "error": "not_found",
      "message": "Standard not found."
    }

curl

curl https://precisino.it/api/v1/keys/{id}/access-log \
  -H "Authorization: Bearer prc_live_..."
GET/keys/{id}/visible-standards

What this key can see right now

Auth:any key· or the owner session

"This key sees N standards today: …". Answered with the same code that decides what the key may read, so the panel can never disagree with the gate.

Parameters

  • idstringin pathrequired

Responses

  • 200

    The standards inside this key's scope.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    A panel route reached with an API key. keys:manage and webhooks:manage do not exist as permissions: the owner does these from the panel, with a session.

    Example

    {
      "error": "owner_only",
      "message": "This endpoint needs the account owner's session. API keys can't use it."
    }
  • 404Error

    No such resource — or one outside this key's scope. The two are answered identically on purpose: a 403 would confirm the thing exists.

    Example

    {
      "error": "not_found",
      "message": "Standard not found."
    }

curl

curl https://precisino.it/api/v1/keys/{id}/visible-standards \
  -H "Authorization: Bearer prc_live_..."

Webhooks

Endpoints, deliveries and retries. Owner session only.

GET/webhooks

List webhooks

Auth:any key· or the owner session

Parameters

  • limitintegerin query

    1 to 100. Default 50.

  • cursorstringin query

    The next_cursor of the previous page. Opaque.

Responses

  • 200PageWebhooks

    A page of webhooks.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    A panel route reached with an API key. keys:manage and webhooks:manage do not exist as permissions: the owner does these from the panel, with a session.

    Example

    {
      "error": "owner_only",
      "message": "This endpoint needs the account owner's session. API keys can't use it."
    }

curl

curl https://precisino.it/api/v1/webhooks \
  -H "Authorization: Bearer prc_live_..."
POST/webhooks

Create a webhook

Auth:any key· or the owner session

The signing secret is in this response only. Store it: every delivery is signed with it and a signature you cannot check is a signature that is not doing its job.

Request body · application/json

  • urlstring (uri)required
  • eventsWebhookEvent[]required
  • activeboolean

Responses

  • 201WebhookWithSecret

    The webhook, secret included, once.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    A panel route reached with an API key. keys:manage and webhooks:manage do not exist as permissions: the owner does these from the panel, with a session.

    Example

    {
      "error": "owner_only",
      "message": "This endpoint needs the account owner's session. API keys can't use it."
    }
  • 422Error

    The body parsed, but what it says does not stand up.

    Example

    {
      "error": "validation_failed",
      "message": "These tags aren't in this account's vocabulary: corsie."
    }

curl

curl -X POST https://precisino.it/api/v1/webhooks \
  -H "Authorization: Bearer prc_live_..." \
  -H "Content-Type: application/json" \
  -d '{ ... }'
GET/webhooks/{id}

One webhook

Auth:any key· or the owner session

Without its signing secret, which is only ever shown at creation or rotation.

Parameters

  • idstringin pathrequired

Responses

  • 200Webhook

    The webhook.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    A panel route reached with an API key. keys:manage and webhooks:manage do not exist as permissions: the owner does these from the panel, with a session.

    Example

    {
      "error": "owner_only",
      "message": "This endpoint needs the account owner's session. API keys can't use it."
    }
  • 404Error

    No such resource — or one outside this key's scope. The two are answered identically on purpose: a 403 would confirm the thing exists.

    Example

    {
      "error": "not_found",
      "message": "Standard not found."
    }

curl

curl https://precisino.it/api/v1/webhooks/{id} \
  -H "Authorization: Bearer prc_live_..."
PATCH/webhooks/{id}

Update a webhook

Auth:any key· or the owner session

Parameters

  • idstringin pathrequired

Request body · application/json

  • urlstring (uri)
  • eventsWebhookEvent[]
  • activeboolean
  • rotate_secretboolean

    Generates a new signing secret and returns it, once. The old one stops working immediately.

Responses

  • 200WebhookWithSecret

    The updated webhook. The secret appears only if it was rotated.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    A panel route reached with an API key. keys:manage and webhooks:manage do not exist as permissions: the owner does these from the panel, with a session.

    Example

    {
      "error": "owner_only",
      "message": "This endpoint needs the account owner's session. API keys can't use it."
    }
  • 404Error

    No such resource — or one outside this key's scope. The two are answered identically on purpose: a 403 would confirm the thing exists.

    Example

    {
      "error": "not_found",
      "message": "Standard not found."
    }
  • 422Error

    The body parsed, but what it says does not stand up.

    Example

    {
      "error": "validation_failed",
      "message": "These tags aren't in this account's vocabulary: corsie."
    }

curl

curl -X PATCH https://precisino.it/api/v1/webhooks/{id} \
  -H "Authorization: Bearer prc_live_..." \
  -H "Content-Type: application/json" \
  -d '{ ... }'
DELETE/webhooks/{id}

Delete a webhook

Auth:any key· or the owner session

Deletes the endpoint and its delivery history.

Parameters

  • idstringin pathrequired

Responses

  • 204

    Gone.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    A panel route reached with an API key. keys:manage and webhooks:manage do not exist as permissions: the owner does these from the panel, with a session.

    Example

    {
      "error": "owner_only",
      "message": "This endpoint needs the account owner's session. API keys can't use it."
    }
  • 404Error

    No such resource — or one outside this key's scope. The two are answered identically on purpose: a 403 would confirm the thing exists.

    Example

    {
      "error": "not_found",
      "message": "Standard not found."
    }

curl

curl -X DELETE https://precisino.it/api/v1/webhooks/{id} \
  -H "Authorization: Bearer prc_live_..."
GET/webhooks/{id}/deliveries

Delivery attempts

Auth:any key· or the owner session

Parameters

  • idstringin pathrequired
  • limitintegerin query

    1 to 100. Default 50.

  • cursorstringin query

    The next_cursor of the previous page. Opaque.

  • statusDeliveryStatusin query
  • eventWebhookEventin query

Responses

  • 200PageWebhookDeliveries

    A page of deliveries.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    A panel route reached with an API key. keys:manage and webhooks:manage do not exist as permissions: the owner does these from the panel, with a session.

    Example

    {
      "error": "owner_only",
      "message": "This endpoint needs the account owner's session. API keys can't use it."
    }
  • 404Error

    No such resource — or one outside this key's scope. The two are answered identically on purpose: a 403 would confirm the thing exists.

    Example

    {
      "error": "not_found",
      "message": "Standard not found."
    }

curl

curl https://precisino.it/api/v1/webhooks/{id}/deliveries \
  -H "Authorization: Bearer prc_live_..."
POST/webhooks/{id}/deliveries/{dlv}/resend

Send it again

Auth:any key· or the owner session

Puts the delivery back at the head of the queue (status: "pending", attempts: 0, next_attempt_at: now) with the payload it was born with. A retry sends what happened when it happened, not a recomputed version of it.

It then tries straight away, without waiting for the next sweep: whoever pressed this has just fixed their endpoint and wants to know now. attempts restarts from zero on purpose — a delivery that burned its five tries deserves five more, or the button would work only once.

Parameters

  • idstringin pathrequired
  • dlvstringin pathrequired

Responses

  • 200WebhookDelivery

    The delivery as it stands after that attempt: delivered, back in the queue with its next wait, or failed.

  • 401Error

    No credentials (unauthorized), or a key that is invalid, revoked or expired (invalid_api_key).

    Example

    {
      "error": "unauthorized",
      "message": "No API key or session provided."
    }
  • 403Error

    A panel route reached with an API key. keys:manage and webhooks:manage do not exist as permissions: the owner does these from the panel, with a session.

    Example

    {
      "error": "owner_only",
      "message": "This endpoint needs the account owner's session. API keys can't use it."
    }
  • 404Error

    No such resource — or one outside this key's scope. The two are answered identically on purpose: a 403 would confirm the thing exists.

    Example

    {
      "error": "not_found",
      "message": "Standard not found."
    }

curl

curl -X POST https://precisino.it/api/v1/webhooks/{id}/deliveries/{dlv}/resend \
  -H "Authorization: Bearer prc_live_..."

Auth

Sign-up. The only route that needs no credentials.

POST/auth/signup

Create an account

Auth:any key

The only public route in the API: it creates the owner's user and their tenant in one call, and needs no credentials — there are none yet.

It does not return a session. Sign in right after with Supabase Auth (signInWithPassword with the same credentials, or signInWithOtp for the magic link): sessions belong to Supabase, and a second place that knows how to mint them is a second place that can get them wrong.

Rate limited to 5 sign-ups per hour per IP address.

Request body · application/json

  • emailstring (email)required
  • passwordstringrequired
  • namestringrequired

    The account's name, not the person's.

  • sectorSectorrequiredvedi Sector

    Closed vocabulary. The values are Italian; they are identifiers, not copy.

Responses

  • 201

    The account.

  • 409Error

    email_taken — there is already an account with that address. Sign in instead.

  • 422Error

    The body parsed, but what it says does not stand up.

    Example

    {
      "error": "validation_failed",
      "message": "These tags aren't in this account's vocabulary: corsie."
    }
  • 429Error

    More than 120 requests in a minute on this key.

    Example

    {
      "error": "rate_limited",
      "message": "Too many requests. Try again in a minute."
    }

curl

curl -X POST https://precisino.it/api/v1/auth/signup \
  -H "Authorization: Bearer prc_live_..." \
  -H "Content-Type: application/json" \
  -d '{ ... }'

Webhooks

One endpoint per tenant, with its own secret — separate from your API keys. Every delivery is signed.

Signature

Each request carries Precisino-Signature: a timestamp and an HMAC-SHA256 of `${t}.${body}`, keyed with the endpoint's secret. Compare it in constant time, and reject a timestamp older than five minutes — that is what stops a replay.

Header

Precisino-Signature: t=1789556400,v1=6f1b...c9

// the signed string is exactly:
//   "1789556400." + rawRequestBody

Events

  • verdict.createdA verdict was created

    Fired when a shot enters the system, before the engine has answered. Use it to know a shot happened; use verdict.completed to know what it says.

  • verdict.completedA verdict finished

    The other half of a 202. The payload carries the full verdict, result and items included.

  • verdict.reviewedA human confirmed or disputed a verdict
  • standard.versionedA standard has a new version

    The reference photo or the checklist changed.

Payload

Body

{
  "id": "dlv_9Qm4tR7xK2nP5vL8bW3zJc",
  "event": "verdict.completed",
  "created_at": "2026-09-16T10:12:33Z",
  "data": { ... }
}

Retries

Any 2xx means delivered. Anything else is retried five times, with longer waits each time — 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours — and then marked failed. Every delivery and its attempts are listed in the owner's Settings, where you can send it again.

Objects

The shapes the API responds with.

Standard

  • idstringrequired
  • tenant_idstringrequired
  • namestringrequired
  • sectorSectorrequiredvedi Sector

    Closed vocabulary. The values are Italian; they are identifiers, not copy.

  • reference_photostring | null

    A signed URL, valid for one hour. The buckets are private and stay private: there is no permanent address for a photo of someone's workplace.

  • checklistChecklistItem[]required
  • versionintegerrequired

    Goes up by one every time the reference photo or the checklist changes, and never otherwise. Verdicts point at the version they were judged against.

  • tagsstring[]required

    Slugs from the tenant's vocabulary.

  • metadataobjectrequired
  • archived_atstring | null
  • created_atstring (date-time)required
  • updated_atstring (date-time)
  • verdicts_this_monthinteger

    Shots judged against this standard since the first of the current calendar month (UTC), in the caller's mode. Only present on GET /standards?include=month_counts.

ChecklistItem

  • idstringrequired

    itm_ plus 10 base32 characters. Assigned by Precisino.

  • textstringrequired
  • anchorChecklistAnchor | null

Verdict

  • idstringrequired
  • tenant_idstringrequired
  • standard_idstringrequired
  • standard_versionintegerrequired
  • photostring | null

    A signed URL for the judged shot, valid for one hour.

  • statusVerdictStatusrequiredvedi VerdictStatus

    Whether the verdict has been *given*. What it *says* is result — the two are different questions, and a pending verdict has no result yet.

  • resultVerdictResult | null

    Null while status is pending.

  • itemsVerdictItem[]

    One entry per checklist item. Empty while pending.

  • explanationstring | null

    One line for the person holding the phone — what to fix. In the language the verdict was asked in (it by default).

  • metadataobjectrequired

    Exactly what you sent, returned untouched. If the key carries an actor, metadata.actor is merged in.

  • actorActor | null
  • api_key_idstring | null
  • modeModerequiredvedi Mode
  • reviewReviewStatus | null
  • review_notestring | null
  • reviewed_atstring | null
  • idempotency_keystring | null
  • created_atstring (date-time)required
  • completed_atstring | null

VerdictItem

  • item_idstringrequired

    The ChecklistItem.id this judges.

  • statusVerdictItemStatusrequiredvedi VerdictItemStatus
  • notestringrequired

    One short line. Empty when there is nothing to add.

Tag

  • idstringrequired
  • tenant_idstringrequired
  • slugstringrequired
  • labelstringrequired
  • created_atstring (date-time)required

Usage

  • planPlanrequiredvedi Plan
  • includedintegerrequired

    500 on standard, 5000 on pro. Does not roll over.

  • usedintegerrequired
  • overageintegerrequired

    max(used - included, 0). Invoiced by hand at the end of the month.

  • monthstring (date)required

    First day of the calendar month, UTC.

ApiKey

  • idstringrequired
  • tenant_idstringrequired
  • namestringrequired
  • modeModerequiredvedi Mode
  • key_prefixstringrequired

    Enough to recognise the key. The secret is not stored.

  • permissionsPermission[]required
  • scope_tagsstring[]required

    Tag slugs. Empty means the whole tenant.

  • actorActor | null
  • kindApiKeyKindrequiredvedi ApiKeyKind
  • expires_atstring | null
  • last_used_atstring | null
  • revoked_atstring | null
  • created_atstring (date-time)required
  • visible_standards_countinteger

    "This key sees N standards today" — computed live, with the same code that decides what the key may actually read. A zero here is almost always a scope pointing at the wrong tag.

Webhook

  • idstringrequired
  • tenant_idstringrequired
  • urlstring (uri)required
  • eventsWebhookEvent[]required
  • activebooleanrequired
  • created_atstring (date-time)required

Error

  • errorstringrequired

    A stable machine code. Write your if against this one.

  • messagestring

    A sentence for whoever is reading the logs. May change. English by default, Italian when Accept-Language puts it first (on 502 engine_error it follows the request's language instead).

  • requiredPermissionvedi Permission

    Every permission there is. There are no others.

  • max_bytesinteger

    Only on 413 photo_too_large: the largest photo the API accepts, in bytes.

  • hintstring

    Only on 413 photo_too_large: how to get under the limit before uploading.