> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://onepin.ai/docs/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://onepin.ai/docs/_mcp/server.

# Get Run Analysis

GET https://api.onepin.ai/api/v1/workflows/{workflow_id}/runs/{run_id}/analysis

Fetch delivery, quality and cost analysis for a workflow run.

Three independent blocks, each carrying its own `status` so one unavailable
block never blanks the others:

* `audio` — delivered vs replaced audio duration per locale. "Replaced"
  counts persisted non-delivered takes only; discarded takes and split-line
  concat entries are excluded so nothing is double counted.
* `validators` — one entry per validator NODE (not per kind: two validators
  of the same kind on different generators are legal and may carry different
  thresholds), with per-locale scores read from each line's DELIVERED take.
* `credits` — per-node breakdown of the run's charge, mirroring the ledger.
  Amounts are unrounded decimal STRINGS; the run-level floor and minimum are
  applied once at settlement and reported separately as `charged`, which is
  the balance debit PLUS `overage_credits` — the remainder billed against the
  next invoice when a paid plan's balance ran out mid-run.

The `run` block's `duration_ms` is ACTIVE duration — wall-clock
(`completed_at - started_at`) minus time the run spent paused — and
`paused_ms` reports that excluded paused total (POD-417).

Scores here can differ from `GET /runs/{run_id}/overview`: that endpoint
reads each line's last take and is frozen on those semantics, while this one
reads the take actually delivered. When a retry produced a worse clip and an
earlier one won, the two legitimately disagree.

Related sub-resources:

* `GET /runs/{run_id}` — full run record including the raw definition snapshot.
* `GET /runs/{run_id}/status` — volatile status fields only; for polling.
* `GET /runs/{run_id}/overview` — pre-aggregated metrics and node state map.
* `GET /runs/{run_id}/data` — paginated script+audio rows for a data table.

Reference: https://onepin.ai/docs/reference/api-reference/workflows/get-run-analysis-api-v-1-workflows-workflow-id-runs-run-id-analysis-get

## Authentication

- `Authorization` header (bearer token, required) — Clerk JWT token
- `Authorization` header (bearer token, required) — Onepin live API key (`op_live_...`). Test and public keys are reserved in Phase 1.

## Servers

- `https://api.onepin.ai` (prod, default)
- `https://dev-api.onepin.ai` (dev)

## Request

### Path parameters

- `workflow_id` (string, required)
- `run_id` (string, required)

### Headers

- `X-Workspace-Id` (string, optional, nullable)

## Response

### 200

Successful Response

- `data` (WorkflowRunAnalysisOut, required)
- `meta` (Meta, required)

## Errors

### 422 Unprocessable Entity Error

Validation Error

- `detail` (list of ValidationError, optional)

## Types

### WorkflowRunAnalysisOut

- `run` (WorkflowRunAnalysisRunOut, required)
- `audio` (WorkflowRunAnalysisAudioOut, required) — Delivered vs replaced audio duration. ``replaced_*`` counts only PERSISTED non-delivered takes. It excludes unwired-``fail`` discarded takes (reconstructed at read time, capped) and split-line concat entries. A split line's real non-winning takes live under ``sublines[]`` and never reach the flat list, so its replaced duration is **unknown here, not zero** — such a line sets ``replaced_partial``. ``generated_total_ms`` is ``None`` whenever ``replaced_partial`` is set, so an undercount can never be mistaken for a total. Lines whose delivered take has no usable duration (a decode failure persists ``0``) are skipped and counted in ``delivered_missing_take_count`` rather than voiding the block; the block only goes ``unavailable`` when no line resolved at all.
- `credits` (WorkflowRunAnalysisCreditsOut, required) — Per-node credit breakdown for the run. ``charged`` is what the run COST the customer: the credits settlement debited from the balance plus ``overage_credits``, the remainder billed against the next invoice when the balance ran out mid-run. A Free plan's shortfall is absorbed by donut, emits no overage event, and so is excluded from both. ``truncated`` marks a response where at least one node's line union hit the server-side cap. Row amounts still come from the ledger and stay exact; only the ``free`` rows reached via the observed-work signal are best-effort on such a response. ``minimum_charge_fractional`` is the credits the per-run 1-credit floor added to what the run ACTUALLY paid — non-zero only when fractional usage was under 1 credit and the run-level minimum rounded it UP. It is the mirror of ``absorbed_fraction`` (the round-*down* amount when usage ≥ 1 credit) and is BOUNDED by ``charged``, so an absorbed Free-plan run (which settles below the nominal floor, ``charged`` as low as 0) never claims an uncollected minimum. It lets a client explain a sub-1 total that settled to 1 as a minimum charge rather than a bare ``0.5 → 1`` up-arrow. Null when the run is not settled (``charged`` is null).
- `validators` (list of WorkflowRunAnalysisValidatorOut, optional)

### Meta

- `request_id` (string, required)
- `timestamp` (datetime, required)

### ValidationError

- `loc` (list of ValidationErrorLocItems, required)
- `msg` (string, required)
- `type` (string, required)
- `input` (any, optional)
- `ctx` (ValidationErrorCtx, optional)

### WorkflowRunAnalysisRunOut

- `id` (string, required)
- `status` (string, required)
- `started_at` (datetime, optional, nullable)
- `completed_at` (datetime, optional, nullable)
- `duration_ms` (integer, optional, nullable)
- `paused_ms` (integer, optional, default: 0)

### WorkflowRunAnalysisAudioOut

Delivered vs replaced audio duration. ``replaced_*`` counts only PERSISTED non-delivered takes. It excludes unwired-``fail`` discarded takes (reconstructed at read time, capped) and split-line concat entries. A split line's real non-winning takes live under ``sublines[]`` and never reach the flat list, so its replaced duration is **unknown here, not zero** — such a line sets ``replaced_partial``. ``generated_total_ms`` is ``None`` whenever ``replaced_partial`` is set, so an undercount can never be mistaken for a total. Lines whose delivered take has no usable duration (a decode failure persists ``0``) are skipped and counted in ``delivered_missing_take_count`` rather than voiding the block; the block only goes ``unavailable`` when no line resolved at all.

- `status` (enum, required)
  - Allowed values: `available`, `partial`, `unavailable`
- `reason` (string, optional, nullable)
- `truncated` (boolean, optional, default: false)
- `delivered_total_ms` (integer, optional, nullable)
- `generated_total_ms` (integer, optional, nullable)
- `replaced_partial` (boolean, optional, default: false)
- `replaced_missing_take_count` (integer, optional, default: 0)
- `delivered_missing_take_count` (integer, optional, default: 0)
- `by_locale` (list of WorkflowRunAnalysisAudioLocaleOut, optional)

### WorkflowRunAnalysisCreditsOut

Per-node credit breakdown for the run. ``charged`` is what the run COST the customer: the credits settlement debited from the balance plus ``overage_credits``, the remainder billed against the next invoice when the balance ran out mid-run. A Free plan's shortfall is absorbed by donut, emits no overage event, and so is excluded from both. ``truncated`` marks a response where at least one node's line union hit the server-side cap. Row amounts still come from the ledger and stay exact; only the ``free`` rows reached via the observed-work signal are best-effort on such a response. ``minimum_charge_fractional`` is the credits the per-run 1-credit floor added to what the run ACTUALLY paid — non-zero only when fractional usage was under 1 credit and the run-level minimum rounded it UP. It is the mirror of ``absorbed_fraction`` (the round-*down* amount when usage ≥ 1 credit) and is BOUNDED by ``charged``, so an absorbed Free-plan run (which settles below the nominal floor, ``charged`` as low as 0) never claims an uncollected minimum. It lets a client explain a sub-1 total that settled to 1 as a minimum charge rather than a bare ``0.5 → 1`` up-arrow. Null when the run is not settled (``charged`` is null).

- `status` (enum, required)
  - Allowed values: `ok`, `partial`, `unavailable`
- `reason` (enum, optional, nullable)
  - Allowed values: `run_not_settled`, `run_paused`, `no_recorded_spend`, `zero_spend_no_settle`, `settlement_missing`
- `truncated` (boolean, optional, default: false)
- `rows` (list of WorkflowRunAnalysisCreditRowOut, optional)
- `total_fractional` (string, optional, nullable)
- `unattributed_fractional` (string, optional, nullable)
- `charged` (integer, optional, nullable) — Total credits charged for the run: spendable-balance debit plus separately invoiced overage credits.
- `overage_credits` (integer, optional, default: 0)
- `absorbed_fraction` (string, optional, nullable)
- `minimum_charge_fractional` (string, optional, nullable)

### WorkflowRunAnalysisValidatorOut

One entry per validator NODE, not per kind. Two validators of the same kind on different generators are legal and may carry different thresholds, so ``node_id`` — not ``label`` — is the identity. Scores are read from each line's DELIVERED take (the R7 winner), which is why they can differ from ``/overview``'s, which reads ``audio_files[-1]`` and is frozen that way.

- `node_id` (string, required)
- `node_type` (string, required)
- `kind` (string, required)
- `label` (string, required)
- `status` (enum, required)
  - Allowed values: `available`, `partial`, `unavailable`
- `threshold` (double, optional, nullable)
- `max_retries` (integer, optional, nullable)
- `retry_count` (integer, optional, nullable)
- `reason` (string, optional, nullable)
- `truncated` (boolean, optional, default: false)
- `unscored_locale_count` (integer, optional, default: 0)
- `locales` (list of WorkflowRunAnalysisValidatorLocaleOut, optional)

### ValidationErrorLocItems

- string
- integer

### ValidationErrorCtx

### WorkflowRunAnalysisAudioLocaleOut

- `locale_code` (string, required)
- `delivered_duration_ms` (integer, required)
- `delivered_line_count` (integer, required)
- `replaced_duration_ms` (integer, required)
- `replaced_partial` (boolean, optional, default: false)

### WorkflowRunAnalysisCreditRowOut

One row per node that produced a step. ``credits_fractional`` is the UNROUNDED ledger value serialized as a decimal string — never a float, and never rounded per node. The run-level floor + minimum-1 rule is applied once, at settlement, and surfaces as ``charged``. ``status`` distinguishes the causes of an absent ledger row, which would otherwise all render as an indistinguishable zero or dash: ``free`` (structurally zero-cost, or a billable node that did no billable work), ``not_completed``, and ``missing`` — the only anomaly, meaning the node was reserved for, demonstrably worked, or always records an actual (the pronunciation corrector, whose zero is itself a measurement), yet no actual was ever recorded. ``processed_units`` is what this node was BILLED on, summed across every iteration, and ``unit`` says what one unit is: ``character`` for the text nodes, ``byte`` for a TTS model priced in UTF-8 bytes, ``word`` for the pronunciation corrector. Always render the number with its unit — a bare count is ambiguous, and the pair replaced a ``processed_chars`` field whose name was true for most nodes and silently wrong for the rest. It is the billed quantity rather than a friendlier restatement of it: a five-character Korean line on a byte-priced model reads ``15`` / ``byte``, because a number sitting next to ``credits_fractional`` that cannot account for it does not explain the charge. Note that multiplying it by a single rate still will not always reproduce the credits — a generator spanning several providers sums groups priced differently, a translator carries a language multiplier, and BYOK volume counts here while costing nothing. ``output_chars`` is the node's final emitted text, deduped per source line and locale so a node that re-ran is counted once rather than once per iteration. It is not a matched pair with ``processed_units`` — ``retries`` is what explains the gap, and on a per-word or per-byte node the two are not even the same unit. ``retries`` counts billed re-executions and is deliberately not ``validators[].retry_count``, which sums per-line re-records. Under truncation ``output_chars`` is a partial count, consistent with ``delivered_total_ms``, which is likewise emitted under ``truncated``. It is NOT nulled — ``generated_total_ms`` is nulled on ``replaced_partial``, a different axis that truncation is not part of. A partial ``output_chars`` may reflect earlier-iteration text, not a prefix of the final text, because the union breaks before later iterations overwrite. And ``credits.truncated`` is block-level and cross-type: it cannot identify which row is partial. ``detail`` is opaque display text, not a parseable structure: ``", "`` separates both locales and voices, so a two-voice line and a two-locale line are identical to a parser. Render it as text.

- `node_id` (string, required)
- `node_type` (string, required)
- `status` (enum, required)
  - Allowed values: `recorded`, `recorded_incomplete`, `free`, `missing`, `not_completed`
- `display_name` (string, optional, default: )
- `detail` (string, optional, nullable)
- `processed_units` (integer, optional, nullable)
- `unit` (string, optional, nullable)
- `output_ms` (integer, optional, nullable)
- `output_chars` (integer, optional, nullable)
- `retries` (integer, optional, nullable)
- `credits_fractional` (string, optional, nullable)

### WorkflowRunAnalysisValidatorLocaleOut

- `locale_code` (string, required)
- `avg_score` (double, optional, nullable)
- `line_count` (integer, optional, default: 0)
- `pass_count` (integer, optional, default: 0)
- `fail_count` (integer, optional, default: 0)
- `status` (enum, optional, default: available)
  - Allowed values: `available`, `unscored`

## Examples

**Response**

```json
{
  "data": {
    "run": {
      "id": "string",
      "status": "string",
      "started_at": "2024-01-15T09:30:00Z",
      "completed_at": "2024-01-15T09:30:00Z",
      "duration_ms": 1,
      "paused_ms": 0
    },
    "audio": {
      "status": "available",
      "reason": "string",
      "truncated": false,
      "delivered_total_ms": 1,
      "generated_total_ms": 1,
      "replaced_partial": false,
      "replaced_missing_take_count": 0,
      "delivered_missing_take_count": 0,
      "by_locale": [
        {
          "locale_code": "string",
          "delivered_duration_ms": 1,
          "delivered_line_count": 1,
          "replaced_duration_ms": 1,
          "replaced_partial": false
        }
      ]
    },
    "credits": {
      "status": "ok",
      "reason": "run_not_settled",
      "truncated": false,
      "rows": [
        {
          "node_id": "string",
          "node_type": "string",
          "status": "recorded",
          "display_name": "",
          "detail": "string",
          "processed_units": 1,
          "unit": "string",
          "output_ms": 1,
          "output_chars": 1,
          "retries": 1,
          "credits_fractional": "string"
        }
      ],
      "total_fractional": "string",
      "unattributed_fractional": "string",
      "charged": 1,
      "overage_credits": 0,
      "absorbed_fraction": "string",
      "minimum_charge_fractional": "string"
    },
    "validators": [
      {
        "node_id": "string",
        "node_type": "string",
        "kind": "string",
        "label": "string",
        "status": "available",
        "threshold": 1.1,
        "max_retries": 1,
        "retry_count": 1,
        "reason": "string",
        "truncated": false,
        "unscored_locale_count": 0,
        "locales": [
          {
            "locale_code": "string",
            "avg_score": 1.1,
            "line_count": 0,
            "pass_count": 0,
            "fail_count": 0,
            "status": "available"
          }
        ]
      }
    ]
  },
  "meta": {
    "request_id": "string",
    "timestamp": "2024-01-15T09:30:00Z"
  }
}
```

**SDK Code**

```python
import requests

url = "https://api.onepin.ai/api/v1/workflows/workflow_id/runs/run_id/analysis"

headers = {"Authorization": "Bearer <token>"}

response = requests.get(url, headers=headers)

print(response.json())
```

```javascript
const url = 'https://api.onepin.ai/api/v1/workflows/workflow_id/runs/run_id/analysis';
const options = {method: 'GET', headers: {Authorization: 'Bearer <token>'}};

try {
  const response = await fetch(url, options);
  const data = await response.json();
  console.log(data);
} catch (error) {
  console.error(error);
}
```

```go
package main

import (
	"fmt"
	"net/http"
	"io"
)

func main() {

	url := "https://api.onepin.ai/api/v1/workflows/workflow_id/runs/run_id/analysis"

	req, _ := http.NewRequest("GET", url, nil)

	req.Header.Add("Authorization", "Bearer <token>")

	res, _ := http.DefaultClient.Do(req)

	defer res.Body.Close()
	body, _ := io.ReadAll(res.Body)

	fmt.Println(res)
	fmt.Println(string(body))

}
```

```ruby
require 'uri'
require 'net/http'

url = URI("https://api.onepin.ai/api/v1/workflows/workflow_id/runs/run_id/analysis")

http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true

request = Net::HTTP::Get.new(url)
request["Authorization"] = 'Bearer <token>'

response = http.request(request)
puts response.read_body
```

```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;

HttpResponse<String> response = Unirest.get("https://api.onepin.ai/api/v1/workflows/workflow_id/runs/run_id/analysis")
  .header("Authorization", "Bearer <token>")
  .asString();
```

```php
<?php
require_once('vendor/autoload.php');

$client = new \GuzzleHttp\Client();

$response = $client->request('GET', 'https://api.onepin.ai/api/v1/workflows/workflow_id/runs/run_id/analysis', [
  'headers' => [
    'Authorization' => 'Bearer <token>',
  ],
]);

echo $response->getBody();
```

```csharp
using RestSharp;

var client = new RestClient("https://api.onepin.ai/api/v1/workflows/workflow_id/runs/run_id/analysis");
var request = new RestRequest(Method.GET);
request.AddHeader("Authorization", "Bearer <token>");
IRestResponse response = client.Execute(request);
```

```swift
import Foundation

let headers = ["Authorization": "Bearer <token>"]

let request = NSMutableURLRequest(url: NSURL(string: "https://api.onepin.ai/api/v1/workflows/workflow_id/runs/run_id/analysis")! as URL,
                                        cachePolicy: .useProtocolCachePolicy,
                                    timeoutInterval: 10.0)
request.httpMethod = "GET"
request.allHTTPHeaderFields = headers

let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
  if (error != nil) {
    print(error as Any)
  } else {
    let httpResponse = response as? HTTPURLResponse
    print(httpResponse)
  }
})

dataTask.resume()
```