> ## Documentation Index
> Fetch the complete documentation index at: https://docs.moonbounce.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Evaluate content

> What you send to POST /v1/evaluate, what comes back, and how to retry and handle failures safely.

> [!NOTE]
>
> The examples on this page use Northpine, a fictional outdoor-gear shop whose `refund-exception` decision grants, escalates or denies refund requests outside its standard return policy. [How Moonbounce works](https://docs.moonbounce.ai/learn/how-it-works.md#the-running-example) introduces it.

[From answers to an outcome](https://docs.moonbounce.ai/learn/scores-and-outcomes.md) showed how answers become a score and an outcome. This page is the call that does it: one **evaluation** is one run of one decision on one item, through `POST /v1/evaluate`. You build the request up a piece at a time, send Northpine's ticket C, and read the response field by field.

## The smallest request

Four fields are required: the decision, its version, the item's id and the content.

```json theme={null}
{
  "decision_id": "refund-exception",
  "structure": "sha256:c21908531c5bb1e922591c1b6d2d9e238205a6ee4c87cddff4674003e880b313",
  "item_id": "t-001",
  "subject": [{ "type": "text", "text": "My tent showed up two weeks late. Can I still return it?" }]
}
```

`decision_id` is a name you choose: lowercase letters, digits, `.`, `_` and `-`, starting with a letter. This request names the version by its hash, which works once the spec is registered under the decision; the next section shows how the first call does that. Every field the rest of this page adds is optional.

## Which version runs

The version is the spec's features plus the numbers that combine their answers. You name it with two fields.

**The structure.** Send `spec`, the spec itself, or `structure`, its hash. Except on a dry run, an inline `spec` is registered the first time the API sees it (creating the decision if the id is new), so the first call can carry the file. A `structure` hash must already be registered for this decision, or the call is 404 `VERSION_UNKNOWN`. You may send both: the API hashes the spec, and if the result differs from your `structure`, the call is 409 `VERSION_MISMATCH` and nothing runs. That is a cheap way to assert that the file you deploy is the version you previewed.

**The weights.** `weights` is `"default"` (the starting numbers every spec gets) or a weights hash you pinned. Leave it out and it means `"default"`. [Versions and hashes](https://docs.moonbounce.ai/learn/versions.md) covers both hashes, registering, and pins.

## The item

`item_id` is required: your own id for the thing being judged, up to 256 characters, such as a ticket or order number. It does two jobs. It groups every evaluation of the same ticket, so a re-sent or edited ticket shows up as one item with several evaluations. And feedback can name an item instead of an evaluation (`{"decision_id", "item_id"}`); it then binds to that item's latest evaluation in your key's environment. Use an id that stays the same for the same ticket and differs between tickets.

## Content: subject and context

Content is a list of **parts**. `subject` (1 to 64 parts) is the thing being judged. `context` (up to 64 parts) is background the engine always reads alongside it.

Put per-request background in `context`, not in the spec. The spec's `guidance` is the same for every call and is part of the structure hash; if you pasted each customer's order into it, every call would mint a new version. Keep the spec static and send what changes per call as content.

### Text and json parts

A part is `text` or `json`:

```json theme={null}
[
  { "type": "text", "name": "customer_message", "text": "My tent showed up two weeks late..." },
  { "type": "json", "name": "order", "data": { "order_total_usd": 189, "days_since_delivery": 39 } }
]
```

`text` holds a non-empty string. `json` holds an object or an array in `data`. Image, audio, video, document and conversation-turn parts are recognized but not accepted yet: a request with one is 400 `CAPABILITY_UNSUPPORTED`, with `param` pointing at the part (`subject.1.type`), before anything is registered or run.

### Naming parts and `inputs`

`name` is optional: a lowercase slug such as `customer_message`. It lets a feature read only some of the subject. A feature with `"inputs": ["customer_message"]` reads that subject part and skips the others; a feature without `inputs` reads every subject part. Context parts are read by every feature either way.

The rule is strict. Every name in any feature's `inputs` must match the `name` of a subject part in this call, or the call is 400 `CONTENT_INVALID` with `param` at the unknown name (`nodes.defective.inputs.0`). A context part with that name does not count. So a typo in `inputs` is refused rather than silently reading nothing. If some calls omit a part a feature names, drop that feature's `inputs`, or send the part on every call. Avoid naming a subject part `context` when the request also sends `context`.

`"sensitive": true` on a part is display policy only: reviewers see it blurred or behind a link. The engine still reads it, and it is stored like any other part.

## Outcomes, metadata and summary

* **`outcomes`**: the cut points that turn the score into an outcome, `{"below", "cuts", "abstain"?}`, as on [From answers to an outcome](https://docs.moonbounce.ai/learn/scores-and-outcomes.md). They travel with every call and are never hashed, so you can change them in code without minting a version. Without them you get a score and no outcome.
* **`metadata`**: your own key-values, stored with the evaluation and never shown to the model. Keys are up to 64 characters; values are strings (up to 512), numbers, booleans or `null`. Keys starting `mb.` are reserved for where a call came from: `mb.commit`, `mb.branch`, `mb.service` and `mb.release`. You set them yourself.
* **`summary`**: one line (up to 140 characters) shown on review cards, so a reviewer knows what an item is before opening it. Never shown to the model.

## Ticket C, in full

Ticket C is the late tent: it arrived two weeks late because of the carrier, was used once, cost \$189, and the customer wrote on day 39. Northpine's app sends the customer's message as the subject and the order as a `json` context part.

Look at the order fields against the features. `outside_window` asks whether the request is "more than 30 days after delivery". A delivery date alone would leave the engine to guess today's date, so the app sends `days_since_delivery`, which answers the prompt directly. `order_total_usd` answers `high_value` ("above \$250") and `refunds_last_12_months` answers `repeat_refunder`. The tracking line is there too, but no feature captures carrier delays, so the engine reads it and nothing in the score can use it. That gap is what the loop finds later.

```ts theme={null}
import decision from "../moonbounce/refund-exception.json" with { type: "json" };

const body = {
  decision_id: decision.decision_id,
  spec: decision.spec,
  weights: decision.weights,
  outcomes: decision.outcomes,
  item_id: "t-001",
  summary: "Late tent, used once, day 39",
  subject: [
    {
      type: "text",
      name: "customer_message",
      text: "My tent showed up two weeks late, I used it once on the trip it was for, and now it's day 39. Can I still return it?",
    },
  ],
  context: [
    {
      type: "json",
      name: "order",
      data: {
        order_total_usd: 189,
        days_since_delivery: 39,
        refunds_last_12_months: 0,
        tracking: "Promised Sep 6, delivered Sep 20; 12 days at the regional hub.",
      },
    },
  ],
  metadata: { channel: "email", "mb.service": "support-app", "mb.commit": "3f9c2e1" },
};

const res = await fetch("https://api.moonbounce.ai/v1/evaluate", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.MOONBOUNCE_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": idempotencyKey, // stored with the ticket's job; see below
  },
  body: JSON.stringify(body),
});
```

The app reads the decision file, so whatever is merged is what runs. [Describe a decision](https://docs.moonbounce.ai/learn/decisions.md) builds the spec it sends.

## The response

The call answers `200` with the evaluation. Ticket C's, with the engine answers from the running example:

```json theme={null}
{
  "id": "evl_0199f2c4-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
  "decision_id": "refund-exception",
  "version": {
    "structure": "sha256:c21908531c5bb1e922591c1b6d2d9e238205a6ee4c87cddff4674003e880b313",
    "weights": "sha256:d1340418a598e440b1b7680807d525414e7b2ad6801caab117d123f731e62eba",
    "weights_source": "default",
    "weights_fitted": false,
    "binding": 1,
    "engine_profile": "defective=jev:jev-1.14.0@jev@0.1.0,high_value=jev:jev-1.14.0@jev@0.1.0,item_used=jev:jev-1.14.0@jev@0.1.0,outside_window=jev:jev-1.14.0@jev@0.1.0,repeat_refunder=jev:jev-1.14.0@jev@0.1.0"
  },
  "status": "scored",
  "score": 0.54,
  "outcome": "escalate",
  "nodes": {
    "outside_window": { "type": "boolean", "value": 0.95 },
    "item_used": { "type": "boolean", "value": 0.35 },
    "defective": { "type": "boolean", "value": 0.05 },
    "repeat_refunder": { "type": "boolean", "value": 0.02 },
    "high_value": { "type": "boolean", "value": 0.03 },
    "grant_score": {
      "type": "combine",
      "value": 0.54,
      "contributions": { "outside_window": -0.19, "item_used": -0.07, "defective": 0.01, "repeat_refunder": -0.004, "high_value": -0.006 }
    },
    "grant": { "type": "calibrate", "value": 0.54, "calibrated": false }
  },
  "engines": {
    "outside_window": { "engine": "jev", "model": "jev-1.14.0", "resolved_model": "jev-1.14.0", "adapter": "jev@0.1.0", "cached": false },
    "item_used": { "engine": "jev", "model": "jev-1.14.0", "resolved_model": "jev-1.14.0", "adapter": "jev@0.1.0", "cached": false },
    "defective": { "engine": "jev", "model": "jev-1.14.0", "resolved_model": "jev-1.14.0", "adapter": "jev@0.1.0", "cached": false },
    "repeat_refunder": { "engine": "jev", "model": "jev-1.14.0", "resolved_model": "jev-1.14.0", "adapter": "jev@0.1.0", "cached": false },
    "high_value": { "engine": "jev", "model": "jev-1.14.0", "resolved_model": "jev-1.14.0", "adapter": "jev@0.1.0", "cached": false }
  },
  "outcomes": { "below": "deny", "cuts": [{ "at": 0.38, "outcome": "escalate" }, { "at": 0.6, "outcome": "grant" }] },
  "usage": { "input_tokens": 1460, "output_tokens": 10 },
  "cost": { "engine_calls": 1, "cache_hits": 0, "input_tokens": 1460, "usd_micros": 61 },
  "item_id": "t-001",
  "summary": "Late tent, used once, day 39",
  "metadata": { "channel": "email", "mb.service": "support-app", "mb.commit": "3f9c2e1" }
}
```

* **`id`**: the evaluation's id, `evl_` followed by a time-ordered UUID. Keep it: feedback and reads name it. A dry run returns a `dry_` id that names nothing stored.
* **`decision_id`**: the decision you called.
* **`version`**: exactly what ran. `structure` and `weights` are the two hashes; `weights_source` says whether the weights were `"default"` or `"pinned"`; `weights_fitted` is `true` only when the weights were fit on feedback. `binding` is the revision of the decision's engine settings in this environment, and `engine_profile` lists which engine, model and adapter answered each feature that `grant_score` or `grant` reads. [Versions and hashes](https://docs.moonbounce.ai/learn/versions.md) and [Engines, keys and environments](https://docs.moonbounce.ai/learn/engines-and-environments.md) cover these.
* **`status`**: `scored` (a score, cut into an outcome), `no_outcomes` (a score, but you sent no `outcomes`) or `abstained` (the output had no usable answer, so there is no score; `outcome` is then `outcomes.abstain` if you set it). Branch on `status` to detect an abstention, since `abstain` may reuse an outcome name like `escalate`.
* **`score`** and **`outcome`**: the output node's value, and where it falls among your cut points. Ticket C's 0.54 is between 0.38 and 0.60, so `escalate`.
* **`nodes`**: every node's value by name, in spec order. A feature's `value` is the engine's probability that the answer is yes. A combine's `contributions` are weight × answer per term (the bias is not among them, so `value` = 0.8 − 0.19 − 0.07 + 0.01 − 0.004 − 0.006). `calibrated: false` means the calibrate node passed the score through. A node with no answer keeps its `type` and has `abstained: true` or `failed: true` with a `reason` instead of a value.
* **`engines`**: one entry per feature. `model` is what the engine settings asked for, `resolved_model` the exact versioned model that answered, `adapter` the adapter and its version. `cached` is whether the answer was reused from an earlier identical call instead of a fresh one; on evaluate it is `false` today.
* **`outcomes`**: the cut points you sent, echoed so your logs and Moonbounce's records agree.
* **`usage`**: the tokens the engine calls used.
* **`cost`**: recorded spend, including on a dry run: `engine_calls`, `cache_hits`, `input_tokens` and `usd_micros` (millionths of a US dollar). Calls, input tokens and money come from the same usage row as `getUsage`; `usd_micros` here is reported cost only, while `getUsage` adds the estimate for calls that reported no usage and breaks it out. `engine_calls` counts upstream engine calls on evaluate and seeds; one call can answer several features (Jev compiles them). On previews it counts features sent to engines instead. Only previews use the feature cache today; evaluate and seeds report `cache_hits: 0`. The read-back endpoint returns the same cost when a usage row exists; otherwise it omits `cost`. The example cost above is illustrative.
* **`item_id`**, **`summary`**, **`metadata`**: echoed from the request.
* **`warnings`**: present only when there is one. `FEATURE_FAILED` means a feature the score doesn't read failed (the call still succeeds); `WEIGHTS_PARTIAL` and `WEIGHTS_STALE_TERM` mean a pinned weights hash and the spec disagree ([Versions and hashes](https://docs.moonbounce.ai/learn/versions.md)).

> [!NOTE]
>
> Numbers come back at full precision, so ticket C's `item_used` contribution arrives as `-0.06999999999999999`. This page rounds; your code should compare the raw numbers, which is what the outcome was cut from.

Fresh responses always include `cost`. An idempotent replay of a response stored before costs were reported may omit it until 24 hours after deployment, when those snapshots expire. A replay returns the original cost when present; it is not new spend. Suggestion generation's own cost isn't in per-request `cost`; the usage report shows it under `other_spend`.

## Retries and `Idempotency-Key`

Send an `Idempotency-Key` header (1 to 255 printable ASCII characters) on every POST, and keep it with the work item until you have an answer. If the call times out or fails with a 503, send the same request with the same key:

* If the first attempt was stored, you get its response back, byte for byte, with `Idempotent-Replayed: true`, and nothing runs twice. Two requests in flight with the same key also produce one evaluation.
* If it failed before it was stored, the retry runs it.
* The same key with a different request is 422 `IDEMPOTENCY_KEY_REUSED`. "Different" means the parsed body: spelling out `"weights": "default"` is the same request as leaving it out, but a changed `metadata` value is not.
* If the first result has since been deleted, the key answers 410 and can't be replayed. Use a fresh key.

A key is remembered for 24 hours and belongs to your organization and the key's environment, shared across every operation, so use a fresh one per distinct request. A test key and a production key never share keys. On evaluate, a retry through a rotated API key in the same environment still replays. Feedback and review retries don't, because who sent them is part of the request ([Feedback](https://docs.moonbounce.ai/learn/feedback.md#retries)).

## Time

An evaluation answers within 10 seconds, counted from when the request arrives, or fails with an error you can act on. `Prefer: wait=<seconds>` (whole seconds, 1 or more) shortens that budget; it never lengthens it. For a normal call, the response follows the evaluation commit, so feedback can already bind to it. A dry run answers after its usage is stored and has no feedback target.

## When it fails

* **502 `ENGINE_FAILED`**: the engine couldn't answer a feature the score depends on, so there is no score. `details.features` names what failed. A retry may not help: route the item to a person. A failed call isn't stored, so a retry with the same key runs it again.
* **503** (`ENGINE_UNAVAILABLE`, `STORE_UNAVAILABLE`, `AUTH_UNAVAILABLE`): temporary. Retry with the same key after `Retry-After` seconds. `STORE_UNAVAILABLE` means the API couldn't confirm the evaluation was saved, and the keyed retry settles it.
* **429 `RATE_LIMITED`**: wait `Retry-After` seconds and retry with the same key.
* **429 `QUOTA_EXCEEDED`**: your team's daily work budget is used up until `details.reset_at`. Route the item to a person, or wait for the reset ([Rate limits](https://docs.moonbounce.ai/learn/engines-and-environments.md#rate-limits)).
* **Any other 4xx**: the request needs fixing; retrying it unchanged gives the same answer.

The full list of statuses, reasons and limits is in the [quickstart](https://docs.moonbounce.ai/quickstart.md#errors-retries-and-limits).

## Reading an evaluation back

`GET /v1/evaluations/{evaluation_id}` returns the evaluation result plus `assignment_mode` (`primary`, `seed`, `shadow` or `backtest`) and the submitted `subject` when stored. `subject` is absent when content was not stored (`store_content: false`). It is scoped to the key's environment: an evaluation made with a production key is 404 to a test key, so test code never reads production traffic. Both calls need the `decisions:evaluate` scope.

## Finding decisions and past runs

All three lists return `environment`, `items`, `next_cursor` and `has_more`. `limit` defaults to 50 and accepts 1–100. Pass `next_cursor` as `cursor` while `has_more` is true.

* `GET /v1/decisions` lists decisions with a binding in your key's environment, newest decision first. Each item has `decision_id` and the shared registry's `structures` count. Either `decisions:evaluate` or `decisions:develop` is sufficient.
* `GET /v1/decisions/{decision_id}/structures` lists registered hashes newest first, with `created_at`, `nodes`, `default_weights` and `meta` when present. The decision must exist in your environment through a binding; its registry is shared across your organization's environments. Spec bodies come from `getStructure` (`StructureRead`); `registerStructure` returns `StructureRecord` without `spec`, including on an idempotent replay.
* `GET /v1/decisions/{decision_id}/evaluations` lists undeleted evaluations newest first, without content. Filter by `item_id`, `assignment_mode`, `since` (inclusive) or `until` (exclusive). Each item has `id`, `item_id`, `assignment_mode`, `outcome`, `score`, `created_at` and `version` with `structure` and `weights`. The evaluation list requires `decisions:evaluate`; structure reads and lists accept `decisions:evaluate` or `decisions:develop`.

## Trying a decision without storing an evaluation

Add `"dry_run": true` to either evaluate request form. It still spends engine calls and counts against normal team admission limits. The response has the usual result fields with `dry_run: true` and a `dry_` id that names nothing stored. Normal responses have an `evl_` id and omit `dry_run`. Reading or submitting feedback about a `dry_` id is refused; MCP may reject its pattern before calling the API.

A dry run stores only the usage row. It creates no evaluation, feedback target, structure, binding, content key or feature-cache entry. An inline spec works without registration; when the decision has no binding in this environment, the deployment default is used in memory. A request by hash still requires a registered structure.

Omit `Idempotency-Key`: a dry run with that header is a `400`, because no replay is stored. Retrying a dry run can spend again. Usage totals include it under `unknown` engine profile and assignment mode; the per-engine breakdown requires a stored evaluation trace and therefore excludes dry runs.

## One decision per call

Each call runs one decision. If an item goes through two decisions, make two calls and chain them in your code, for example only asking a second decision when the first says `escalate`. A feature that both specs share is answered again in the second call.

## What is stored, and why

A normal evaluation stores, when content storage is enabled, the content you sent (subject and context), every node's value, which engine answered each feature, the version and engine settings that ran, and the cut points you sent, along with `item_id`, `summary` and `metadata`. That is what lets the rest of the loop work without calling your app again: feedback binds to an exact evaluation, reviewers see the content that was judged, and a [preview](https://docs.moonbounce.ai/learn/improving.md) replays a changed spec or new cut points over past cases.

## Next

[Versions and hashes](https://docs.moonbounce.ai/learn/versions.md) explains the two hashes in `version`: what changes each one, how to register and pin them, and how the decision file keeps them in step with your code.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.