The full reference.

Every field, parameter, error and limit of the triage API. One photo in, one answer out. The Developers page is the short version.

OpenAPI 3.1.0 engine engine-v2-vote-1.0.0 rubric v1.1-2026-09-11 reply triage-response-v1@1.1.0
A resident's photo: soapy water pooling on the floor under a dishwasher, with a towel soaking it up One photo in
EMERGENCYAppliance Technician
POST /api/triage200 · one answer out
"asset_type": "Dishwasher",
"tier": "EMERGENCY",
"trade_required": "Appliance Technician",
"safety_hazard": true
real reply · 18 Sep 2026 · three of three reads found a fault · abridged
WHO IT'S FORA developer with a photo and nowhere to send it.

A resident-reporting app, a maintenance inbox, your own work-order tool. You send the photo. One fixed reply comes back: what it is, whether a fault is visible, one of four urgency words, which trade, a line for the resident, and a link to tell us what it turned out to be.

WHAT YOU DON'T GET TODAYNo dashboard. The reply is the product.

A demo key is free, and the key is emailed to you as soon as you ask. A direct Buildium connection is built and proven only in our own Buildium test account, needs Buildium Premium, and isn't open to manager accounts yet (once signed in, /connect shows what it would read and write). For any other PMS or CMMS, your developers map our reply to your work-order fields. The field notes below are written for that.

THE ONE RULE TO REMEMBERWiring you could touch, or water near anything electrical, is an EMERGENCY.

That's set in code, not by the model. The one exception comes first: if the photo isn't of a building at all (a person, a pet, a vehicle), the answer is P4, not urgent. How urgency works ›

Make a request

1Encode the photo

Base64 it (JPEG, PNG or WebP, 1 KB to 3 MB) and put it in the image field of a JSON body.

2Send it with your key

Key in the x-triage-key header. ?config=fast reads the photo three times, ?config=deep five. &extended=1 adds the rest of the reply.

3Read it, keep the id

Read core, keep diagnosis_id, and when you learn what it really was, send that to callback_url.

All three send the same request and get the same reply. The two clients take the photo as bytes, base64 or a data URL, send your key as x-triage-key, and turn a refusal into an error carrying the status, our error code, its sentence and the Retry-After seconds. To use fixRAgent inside Claude, ChatGPT, Cursor or Gemini, set it up for your AI assistant.

A real reply

  • One gas furnace and air handler, captured 13 Sep 2026. Three of three reads found no fault, so the tier is WHENEVER. Read five times, five of five agreed.
  • config_id names the setup that answered, and config_source.fallback_used: false says it was the measured one (when it isn't).
  • agreement counts matching reads. It covers the fault verdict only. Reads can agree and still be wrong, and it says nothing about the urgency, the trade or the parts.
  • The two calls took 4.2 s (three reads) and 4.7 s (five reads), from one machine, one call each. That's what was measured. It is not a service level.

Fast or deep

fast3× read

What production served on the one call measured, 13 Sep 2026 at 13:36:18Z. agreement.n on your reply says what yours got.

deep5× read

The same photo read five times, independently. Can take a second round when the first is undecided, and says when it did.

How the reads become one answer
  • Fact by fact, not a single read picked out. The fault verdict is the answer most reads gave; a tie reads no fault.
  • Wiring and water are voted the same way, and a tie reads false, so the EMERGENCY floor never fires on a coin toss.
  • Each of the four graded readings is the most common value. A tie takes the lower one, except a fuel-gas, carbon-monoxide or exposed-conductor tie, which rounds up.
  • Urgency is then worked out once, in code, from those voted facts. The wording comes from the read whose own urgency matches.
  • A failed read counts against agreement. It's in n, never in k, so the engine that fails most can't look the most consistent.
  • Decided when k >= ceil(n/2) (stamped on every reply as agreement.vote_rule). A tie is never decided. Undecided takes a second round of the same size, with both rounds in agreement.rounds and a sentence in warnings.

A Triage Profile prints agreement as a count of reads, never as a percentage.

Try it without a key

A mock server replays the real replies on this page, in full, so you can wire up a client before your key arrives. config=fast gets the three-read reply, config=deep the five-read one.

Don't test your key or query handling against it. It doesn't run the engine and ignores your photo. It matches on the path and query string only, ignores every header (a wrong key still gets a 200), and a POST with no query string returns the five-read reply. Measured 13 Sep 2026.
Postman collection

The same requests and examples (Collection v2.1). Import it, then point baseUrl at the mock or at https://fixragent.com. It's made from the captured replies, so it can't say something the API didn't.

Download the collection ›
Postman environment

Points the same requests at the real API. Import it beside the collection, put your key in the secret key variable, switch it on, and the fast request runs for real.

Download the environment ›

JavaScript and Python

Two thin clients, each made from openapi.json so the types can't drift from the API. Download the file and import it; that's the install today. The same files become the @fixragent/triage npm package and the fixragent-triage PyPI package, and those install lines land here with the first release. Source: sdk/, MIT licence.

JAVASCRIPTPYTHON
Works withNode 18+ or a browserPython 3.9+, standard library only
Triageawait triage(photo, { key, config, extended, problemText, reporter, lang })triage(photo, key=, config=, extended=, problem_text=, reporter=, lang=)
Outcomeawait reportOutcome({ callbackUrl, outcome: 'fixed' })report_outcome('fixed', callback_url=…)
ErrorsFixragentError with status, code, message, retryAfterFixragentError with status, code, message, retry_after
Typesindex.d.tstypes.py (TypedDict)
Counted aschannel api, sends source:sdk-jschannel api, sends source:sdk-python
MockbaseUrl: 'https://b1442b0b-4a7a-4135-ab4d-9c4b9c1ab0d2.mock.pstmn.io'base_url='https://b1442b0b-4a7a-4135-ab4d-9c4b9c1ab0d2.mock.pstmn.io'

Both clients replay the captured replies on this page in their own tests, and the JavaScript client has been checked against the mock byte for byte.

Other ways in

The photo drop, on your own site

Paste one tag where the box should sit. Change your-site to a short name; every Triage Profile made from your page carries it, so they count as yours. The box sets no cookies on your site.

<script src="https://fixragent.com/embed.js" data-source="your-site" async></script>Wix, Squarespace, WordPress steps ›
Inside your AI assistant

The fourth way in, for an agent. Claude, ChatGPT, Cursor or Gemini can send a photo and read the answer for you. With a key, each photo counts against the key's 60 a day, and every call against the same 20 an hour per address. With no key, it reads up to 3 photos per connection in a rolling 24 hours, then answers with a labelled SAMPLE instead of reading yours.

Set it up ›
The one-page sheet

The Triage Profile the deep reply makes, the reply behind it, and the pilot offer, on one printable page. It shows no photo: the photo behind it is held under a licence that covers processing only.

Open it ›

The three guards

1 · Per address429
20an hour

For callers without a key of their own, who also have a day limit per address (keyless_address_day). A key is judged by the key, never by the address it calls from.

rate_limited
2 · Per key401 · 429
60a UTC day

Each demo key. Resets at 00:00 UTC. A key also has a per-minute allowance of 20 (key_burst_limited), counted separately on each of our servers that answers it, not across all of them together.

demo_key_required · demo_key_quota
3 · Daily brake503
00:00UTC reset

A daily spending brake protects the service. It's checked before your photo is read, so hitting it costs nothing. A key with its own cap is bounded by that cap instead.

budget_exhausted

When one stops you, the reply says which, in a sentence, with a Retry-After worked out from the clock, not a round number. The 503 also carries retry_after_seconds and resets_at.

Keys with a usage plan

So a campaign can send hundreds of photos in minutes without failing and without a surprise bill.

Burst allowance429

The per-address hour limit doesn't apply. Your key gets its own per-minute allowance, so all your photos can come from one server. It is counted separately on each of our servers that answers, not across all of them; the hard cap is the one counted across the whole service.

key_burst_limited · Retry-After in seconds
Hard cap429

A fixed number of assessments per window (your pilot period or a calendar month). Past it, nothing runs and nothing is billed. Set with us in writing; it can be raised the same day.

key_cap_reached · usage block · Retry-After to the reset
Running total200

Every answer carries your usage. GET /api/usage with the same key returns it without sending a photo.

X-Key-Usage-Used · X-Key-Usage-Cap · X-Key-Usage-Remaining · X-Key-Usage-Window · X-Key-Usage-Resets
Retries are free200

Send an Idempotency-Key (8–128 characters) and a repeat within 24 hours gets the stored answer. Without one, the same photo, problem text, language and setup from the same key within 10 minutes counts the same. A retry sent while the first is still running can run twice; it's billed once.

replayed block · X-Triage-Replayed
Billing, busy engine, and checking your total
The billing rule. You're billed for distinct photos (or distinct Idempotency-Keys) in the window, never more than your cap. The cap is checked before the engine runs. Requests already in flight on other servers when the cap is crossed can still be answered, and are never billed.
When the engine is busy (our model provider is limiting or down, or our monthly engine limit is reached) you get 503 engine_busy with Retry-After, not a 502. Nothing is billed. Retry with the same photo or Idempotency-Key and it's never charged twice.

/api/usage answers for pilot keys with a usage cap, and for an issued key with its own count: engine runs since 00:00 UTC against the 60 a day. A wrong, dead or missing key gets 401 demo_key_required.

How urgency is set

The server sets both, in code. The model only gives observations: four graded readings and two yes-or-no facts from the photo.

EMERGENCYseverity P1
TODAYseverity P2
THIS WEEKseverity P3
WHENEVERseverity P4
  • The EMERGENCY floor (criterion 1). Visible wiring, or water near anything electrical, is EMERGENCY. By definition. criterion_1_fired tells you when that's what happened.
  • Checked first: not a building. When is_building_asset is false (a person, a pet, a vehicle) the answer is P4, not urgent.
  • Unsure doesn't fire it. Where either fact is uncertain, the decision table decides instead, or every dim basement photo would be an emergency. Low-voltage wiring (doorbell, thermostat, ethernet) never fires it; it's noted in fault_summary.
  • Never dropped for lack of a named fault. A photo can show an exposed conductor with no nameable fault, and that's still an emergency.
  • What the model proposed is kept, not used. It's in provenance.model_proposed_severity, and severity_overwritten records the overwrite.

Parameters

NAMEIN · VALUESWHAT IT DOES
configquery · fast | deepWhich setup answers. fast reads the photo three times, deep five. The setup comes from a file shipped beside the API and pinned to the prompt, schema and model the code would serve. config_source names the file, config_id the setup.
extendedquery · 1 | trueAlso send the extended block. Left out, it comes back null.
langquery · en | esThe language of the written fields. A lang in the body wins; with neither, the browser's Accept-Language decides (es-* gives Spanish); the default is English. Spanish ›
x-triage-keyheader · stringYour key. Required; 60 requests a UTC day. The one exception is fixRAgent's own Triage Profile on fixragent.com, which needs no key but is still rate-limited per address and bounded by the daily brake.
kquery · stringThe key in the address instead of the header. Prefer the header: a key in an address ends up in logs.
When the measured setup can't answer, and how the reply says so
Why it falls backThe setup file is absent (candidates_file_absent), unreadable (candidates_file_unparseable), missing the setup you asked for (config_not_in_candidates_file), disagrees with the code on the model, prompt or schema, rubric, engine version, read count or temperature (candidate_pin_mismatch, with mismatch listing which), or the multi-read part can't load (vote_module_unavailable).
How it tells youFive ways at once: config_source.fallback_used is true with fallback_reason; a sentence in config_source.warning; the same in the top-level warnings; a config_id starting fallback-; and an X-Triage-Config-Fallback header.
What it meansThat reply wasn't produced by a measured setup and shouldn't be reported as one. Neither sample on this page is a fallback. Whatever answers your call is stamped on your reply; treat that config_id as the authority.

Request body

FIELDTYPENOTES
imageREQUIREDstringThe photo, base64. A data:image/...;base64, prefix is accepted and stripped. At least 1 KB, at most 3 MB (3,145,728 bytes) once decoded.
mimeTypestringUsed to strip the photo's hidden data. A format that can't be cleaned is refused with 415.
problem_textstringThe reporter's own words. Treated as symptoms, never as fact: where the photo and the words disagree, the photo wins on the fault and the words still win on the hazard.
reporterstringWho wrote problem_text. Changes how the reply refers to them, nothing else.
langstringen or es. Wins over the query parameter.
configstringSame as the query parameter; the query wins.
extendedboolean | stringSame as the query parameter.
rolestringFree-form: who is on the other end. Stored as sent.
variantstringFree-form: which screen showed the Triage Profile.
keep_photobooleanSend false and we keep no copy of the photo, only its fingerprint. Left out, the cleaned photo is kept with its Triage Profile (what happens to the photo).
do_not_trainbooleantrue makes the request stricter about training; nothing can turn training on. The header x-triage-do-not-train: 1 does the same.
Why 3 MB. The photo travels base64-encoded inside JSON, which adds a third, so 3 MB of photo is about 4 MB sent. The hosting platform caps a request at 4.5 MB (its published limit, read 11 Sep 2026). That figure is cited, not measured.

Spanish

Comes back in Spanish

fault_summary, photo_subject, asset_type, trade_required, resident_explanation, resident_why_it_matters, resident_what_happens_next, root_cause, failure_mechanism and the rest of the written fields.

Stays the same, on purpose

Every key, value and taxonomy id. The four urgency words on the wire. And hazard_detail, resident_do_not and every *_reason field stay in English, so a safety line is always the engine's own sentence, never a machine translation.

Send lang=es on the address or "lang": "es" in the body. A browser that prefers Spanish gets the same, and so does the Triage Profile on the home page.

How to tell which language came back

The reply carries a language object: requested, served (en or es), source (body, query, header or default) and hazard_text, which reads the language the hazard line really came back in. That check is there because on 14 Sep 2026, nine of twelve hazard reads under the first wording came back in Spanish. When it says not-en, the Triage Profile holds that line back and prints a fixed sentence instead; your client should decide the same way. Read served before assuming the language: an unknown value is answered in English.

The reply, block by block

A versioned contract. The reply is published as a JSON Schema. Every response, errors included, names it in the X-Triage-Schema header, and every 200 repeats it in schema_version (today triage-response-v1@1.1.0). Within v1 nothing required is removed or renamed, no type changes, no value is taken away. New optional fields can appear, so ignore ones you don't know. A breaking change ships as v2 at a new address, and v1 keeps answering.

Errors

Every error is a code and a sentence written for a person. Never a stack trace, never an internal code. Every status comes from one table.

Retry-After. The six that ask you to come back later carry a Retry-After header, the same number as retry_after_seconds, and a retry_after_basis naming the clock: rate_limited (ip_rate_window), demo_key_quota and budget_exhausted (utc_day), key_cap_reached (cap_window), key_burst_limited (key_minute_window) and engine_busy (engine_backoff). It's worked out, never rounded: ask 30 minutes into a closed one-hour window and it says 1800, not 3600. The others send none and never promise a retry.
CODESTATUSMESSAGE
Five of them, as they arrive

What happens to the photo

Location data stripped first

EXIF, XMP, IPTC and the other carriers that hold GPS are removed before the photo is hashed, sent to the model, or stored.

Can't strip it? Refused.

The photo is turned away (415 image_unsupported or 500 metadata_strip_failed). There's no pass-through, because a photo that still carries GPS looks the same as a clean one.

Kept with its Triage Profile

The cleaned photo is kept privately with its Triage Profile, so whoever holds the profile's link sees it too. Send "keep_photo": false and only its fingerprint, provenance.photo_sha256, is kept. Google, our model provider, keeps what it's sent (prompt, reply and photo) for 55 days.

What we keep ourselves, and for how long, is in the privacy policy.

Tell us how it turned out

Every reply comes with a diagnosis_id and a callback_url. When you find out what it really was, send it back. No key needed.

Use an id from a triage you made. An id it has never seen is saved as a new outcome against nothing, so an id copied from an example adds noise.
Every field you can send back, and the replies
FIELDVALUESNOTES
diagnosis_idREQUIREDuuidRead from the JSON body. The id in the callback_url address is for people and logs; the API doesn't read it. Not a uuid: 400 outcome_orphan_refused.
outcomeREQUIREDfixed | not_fixed | partialOne tap is a complete answer on its own.
reported_viaREQUIREDresults_screen | scan_log | triage_linkWhere the answer came from. An API client reporting for itself uses triage_link. Anything else: 400 reported_via_invalid. Nothing is quietly changed to an allowed value.
fixed_bysteps | other_diy | pro | replacedOptional.
tradeplumber | electrician | hvac | appliance-repair | roofer | handyman | pest-control | landlord-maintenance | myself | otherOptional: who actually went.
fix_description · parts_textstringOptional; up to 1,000 and 500 characters.
emailstringOptional. Only used to attach the outcome to an existing account on our side.
  • Merge, not replace. Only the fields you send are updated, so a second, fuller report never wipes the first, and a report sent at the same moment is merged, not dropped.
  • Replies. 200 { ok: true, outcome: {…} } when it lands. 400 with outcome_orphan_refused, outcome_label_invalid, reported_via_invalid, fixed_by_invalid or trade_invalid, each listing what's allowed. 409 triage_lane_migration_pending if our side isn't ready for that lane yet; the write is refused, never downgraded. 500 is a plain internal error.
  • Not for you: GET /api/outcome?email=&diagnosis_ids= is the app's own scan log. It returns nothing for an id the account doesn't own.

Terms and help

The terms

The API terms, the terms of service and the privacy policy. In one line: this is an educational triage, not professional advice. Gas, water near electrics and exposed wiring are licensed-trade jobs.

Stuck?

Write to support@fixragent.com. A founder reads every message and replies within one business day. Help has the common questions in plain words.

Legal and deletion

Legal questions and deletion requests go to legal@fixragent.com, or use Delete my data.

This page is made from openapi.json, which is made from the API's own settings and checked against it. The samples are the API's own replies.

One photo in.
One answer out.