# UGC video analyser: API for agents

Send one UGC video, get back a score and a rule-by-rule report against the Ryze filming guide
(https://ugc-filming-guide.vercel.app). Each video is checked by a lead agent plus seven check agents.
The report counts 14 scored rules; good-to-have and alternative rules are reported but never change the score.
Every report has a share link a person can open in the browser and a clean JSON file for you.

Base URL: `https://analyzer-production-1bcb.up.railway.app`

## API key

Send your key in a header. `$UGC_API_KEY` below is a placeholder; ask the Ryze team for a key.

```
Authorization: Bearer $UGC_API_KEY
```

`X-API-Key: $UGC_API_KEY` works too. Without a key you get the public limit (30 videos per hour per IP).

## 1. Send a video

By file (multipart, field `video`):

```bash
curl -s https://analyzer-production-1bcb.up.railway.app/api/jobs \
  -H "Authorization: Bearer $UGC_API_KEY" \
  -F video=@file.mp4 \
  -F ref=creator-042/take-3
```

By URL (JSON, `video_url` must be a public https link to the video file itself):

```bash
curl -s https://analyzer-production-1bcb.up.railway.app/api/jobs \
  -H "Authorization: Bearer $UGC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"video_url": "https://example.com/videos/take-3.mp4", "ref": "creator-042/take-3", "callback_url": "https://example.com/hooks/ugc"}'
```

Optional fields (multipart field or JSON key):

| field | what it does |
|---|---|
| `ref` | Free text to match results on your side. Echoed in the report as `ref`. |
| `name` | Display name of the video. Defaults to the file name. |
| `callback_url` | https. When the check ends, the server POSTs the report JSON there (3 tries with backoff). |

Answer `202`:

```json
{
  "id": "Qm7tX2c9LpZa",
  "status": "queued",
  "share_url": "https://ugc-filming-guide.vercel.app/r/Qm7tX2c9LpZa",
  "json_url": "https://analyzer-production-1bcb.up.railway.app/api/jobs/Qm7tX2c9LpZa/report.json",
  "status_url": "/api/jobs/Qm7tX2c9LpZa",
  "poll_after_s": 15
}
```

Errors come as `{"error": "plain message"}`: `400` wrong type, too long, too big or a bad URL; `429` limit reached;
`503` analyser offline.

## 2. Wait for the report

Poll `GET /api/jobs/<id>/report.json` every 15 seconds.

- `202` while the check runs: `{"id", "status": "queued" | "running", "progress": {"done": 3, "total": 7}, "poll_after_s": 10}`
- `200` when it ends: the report below. `status` is `"done"`, or `"error"` with a plain `error` message.
- `404` unknown id.

```bash
ID=Qm7tX2c9LpZa
while :; do
  code=$(curl -s -o report.json -w '%{http_code}' \
    https://analyzer-production-1bcb.up.railway.app/api/jobs/$ID/report.json)
  [ "$code" = 200 ] && break
  [ "$code" = 202 ] || { cat report.json; exit 1; }
  sleep 15
done
jq '{status, score, verdict, to_fix}' report.json
```

If you passed `callback_url` you can skip polling, but keep a slow poll as a fallback.
A finished report never changes: opening it again does not re-run the check.

## 3. Report JSON (schema `ugc-report/1`)

| field | meaning |
|---|---|
| `schema` | `"ugc-report/1"` |
| `id`, `ref` | job id; your `ref` if you sent one |
| `status`, `error` | `"done"` or `"error"`; plain message or `null` |
| `created_at`, `finished_at`, `duration_s` | ISO times; how long the check took |
| `video` | `{"name", "length_s"}` |
| `format` | `"switch"` (cuts between face and filmed laptop), `"split"` (face and screen share the frame) or `"other"` |
| `share_url`, `json_url` | page for people; this file |
| `score` | `{"hit", "total", "percent"}`. `total` = scored rules that apply to this video (max 14) |
| `verdict`, `verdict_text` | `"ready"` (hit >= total - 1), `"close"` (hit >= total - 3), `"work"` |
| `to_fix` | scored rules that failed, guide order: `{"number", "rule", "at", "note"}` |
| `scored` | the 14 scored rules, guide order |
| `optional` | good-to-have and alternative rules. Never affect the score. |

Each item in `scored` and `optional`:

| field | meaning |
|---|---|
| `number`, `id` | guide number (1-21) and rule id |
| `section`, `group`, `rule` | guide section, group and rule text |
| `kind` | `"required"`, `"good_to_have"` or `"alternative"` |
| `counts_toward_score` | `true` only for `required` |
| `status` | `"pass"`, `"fail"`, `"na"` (does not apply, left out of the total), `"unknown"` (check failed, left out) |
| `at_s`, `at` | moment in the video that shows it, seconds and `"m:ss"`, or `null` |
| `note` | one or two plain sentences: what was seen and what to change |
| `snapshot_url` | jpeg frame at that moment, or `null` |

For optional items `pass` means the creator used it and `fail` means they did not. Do not treat them as mistakes.

Full example:

```json
{
  "schema": "ugc-report/1",
  "id": "Qm7tX2c9LpZa",
  "status": "done",
  "error": null,
  "ref": "creator-042/take-3",
  "created_at": "2026-10-11T18:02:11Z",
  "finished_at": "2026-10-11T18:03:55Z",
  "duration_s": 104,
  "video": {
    "name": "ryze-demo-take3.mp4",
    "length_s": 59.4
  },
  "format": "switch",
  "share_url": "https://ugc-filming-guide.vercel.app/r/Qm7tX2c9LpZa",
  "json_url": "https://analyzer-production-1bcb.up.railway.app/api/jobs/Qm7tX2c9LpZa/report.json",
  "score": {
    "hit": 12,
    "total": 14,
    "percent": 86
  },
  "verdict": "close",
  "verdict_text": "Almost there. Fix the points below before you submit.",
  "to_fix": [
    {
      "number": 14,
      "rule": "Not too close, not too far. Show a vertical slice of the screen and part of the keyboard, with the backlight on.",
      "at": "0:44",
      "note": "Most screen shots are close-ups of one line. Step back so a vertical slice of the screen and part of the keyboard are in frame."
    },
    {
      "number": 20,
      "rule": "Start every new screen with a zoom in or a zoom out in the first two seconds.",
      "at": "0:03",
      "note": "Only 3 of 9 new screens open with a zoom. Start each new screen with a quick zoom in or out."
    }
  ],
  "scored": [
    {
      "number": 1,
      "id": "open-emotion",
      "section": "Opening",
      "group": "You on camera",
      "rule": "Say your first line with real emotion. Shock, anger or excitement is what stops the scroll.",
      "kind": "required",
      "counts_toward_score": true,
      "status": "pass",
      "at_s": 0.4,
      "at": "0:00",
      "note": "You open on your face with a shocked look and a raised hand on the first line.",
      "snapshot_url": "https://analyzer-production-1bcb.up.railway.app/api/jobs/Qm7tX2c9LpZa/snap/open-emotion.jpg"
    },
    {
      "number": 2,
      "id": "open-selfie",
      "section": "Opening",
      "group": "You on camera",
      "rule": "Hold the phone yourself and film your face on the front camera. No tripod. A slightly low angle is fine.",
      "kind": "required",
      "counts_toward_score": true,
      "status": "pass",
      "at_s": 1.2,
      "at": "0:01",
      "note": "Handheld front camera, your face fills most of the frame.",
      "snapshot_url": "https://analyzer-production-1bcb.up.railway.app/api/jobs/Qm7tX2c9LpZa/snap/open-selfie.jpg"
    },
    {
      "number": 4,
      "id": "open-glow",
      "section": "Opening",
      "group": "You on camera",
      "rule": "Let the glow of your laptop or phone light your face. The camera must be sharp.",
      "kind": "required",
      "counts_toward_score": true,
      "status": "pass",
      "at_s": 2.6,
      "at": "0:02",
      "note": "Soft light from the screen on your face, and the face is sharp.",
      "snapshot_url": "https://analyzer-production-1bcb.up.railway.app/api/jobs/Qm7tX2c9LpZa/snap/open-glow.jpg"
    },
    {
      "number": 6,
      "id": "open-title",
      "section": "Opening",
      "group": "On screen",
      "rule": "Put the main title at the top of the frame, above your head.",
      "kind": "required",
      "counts_toward_score": true,
      "status": "pass",
      "at_s": 0.8,
      "at": "0:00",
      "note": "The main title sits at the top of the frame, above your head.",
      "snapshot_url": "https://analyzer-production-1bcb.up.railway.app/api/jobs/Qm7tX2c9LpZa/snap/open-title.jpg"
    },
    {
      "number": 8,
      "id": "open-return",
      "section": "Opening",
      "group": "On screen",
      "rule": "When you are just talking on screen, keep your hands moving in the frame.",
      "kind": "required",
      "counts_toward_score": true,
      "status": "pass",
      "at_s": 31.5,
      "at": "0:31",
      "note": "Your hands move in the frame each time you talk to camera.",
      "snapshot_url": "https://analyzer-production-1bcb.up.railway.app/api/jobs/Qm7tX2c9LpZa/snap/open-return.jpg"
    },
    {
      "number": 9,
      "id": "open-captions",
      "section": "Opening",
      "group": "On screen",
      "rule": "Captions go at the bottom: white text, black outline, one line at a time. Words fill the line instead of flashing one by one.",
      "kind": "required",
      "counts_toward_score": true,
      "status": "pass",
      "at_s": 4.1,
      "at": "0:04",
      "note": "White captions with a black outline in the lower half, words build up on one line.",
      "snapshot_url": "https://analyzer-production-1bcb.up.railway.app/api/jobs/Qm7tX2c9LpZa/snap/open-captions.jpg"
    },
    {
      "number": 11,
      "id": "screen-clean",
      "section": "Filming the screen",
      "group": "Setup",
      "rule": "Keep the desk empty: laptop, second screen, cables. Nothing else.",
      "kind": "required",
      "counts_toward_score": true,
      "status": "pass",
      "at_s": 10.5,
      "at": "0:10",
      "note": "Only the laptop and a second screen are on the desk.",
      "snapshot_url": "https://analyzer-production-1bcb.up.railway.app/api/jobs/Qm7tX2c9LpZa/snap/screen-clean.jpg"
    },
    {
      "number": 13,
      "id": "screen-lens",
      "section": "Filming the screen",
      "group": "Setup",
      "rule": "Wipe the camera lens before you film. Every frame must be sharp.",
      "kind": "required",
      "counts_toward_score": true,
      "status": "pass",
      "at_s": 28.3,
      "at": "0:28",
      "note": "Screen text is sharp and readable all the way through.",
      "snapshot_url": "https://analyzer-production-1bcb.up.railway.app/api/jobs/Qm7tX2c9LpZa/snap/screen-lens.jpg"
    },
    {
      "number": 14,
      "id": "screen-framing",
      "section": "Filming the screen",
      "group": "Framing",
      "rule": "Not too close, not too far. Show a vertical slice of the screen and part of the keyboard, with the backlight on.",
      "kind": "required",
      "counts_toward_score": true,
      "status": "fail",
      "at_s": 44,
      "at": "0:44",
      "note": "Most screen shots are close-ups of one line. Step back so a vertical slice of the screen and part of the keyboard are in frame.",
      "snapshot_url": "https://analyzer-production-1bcb.up.railway.app/api/jobs/Qm7tX2c9LpZa/snap/screen-framing.jpg"
    },
    {
      "number": 15,
      "id": "move-hand",
      "section": "Movement",
      "group": "",
      "rule": "Glide a relaxed hand over the part of the screen you are talking about.",
      "kind": "required",
      "counts_toward_score": true,
      "status": "pass",
      "at_s": 8.4,
      "at": "0:08",
      "note": "A relaxed hand glides over the dashboard while you explain it.",
      "snapshot_url": "https://analyzer-production-1bcb.up.railway.app/api/jobs/Qm7tX2c9LpZa/snap/move-hand.jpg"
    },
    {
      "number": 17,
      "id": "move-point",
      "section": "Movement",
      "group": "",
      "rule": "When you need to show something specific, point right at it with the pen or your finger.",
      "kind": "required",
      "counts_toward_score": true,
      "status": "pass",
      "at_s": 20.4,
      "at": "0:20",
      "note": "The pen tip lands on the cost number and holds.",
      "snapshot_url": "https://analyzer-production-1bcb.up.railway.app/api/jobs/Qm7tX2c9LpZa/snap/move-point.jpg"
    },
    {
      "number": 19,
      "id": "screen-closeup",
      "section": "Movement",
      "group": "",
      "rule": "For the key detail, go very close and point at it with a pen or anything else that catches the eye.",
      "kind": "required",
      "counts_toward_score": true,
      "status": "pass",
      "at_s": 39.6,
      "at": "0:39",
      "note": "A very close shot of the key number, with the pen on it.",
      "snapshot_url": "https://analyzer-production-1bcb.up.railway.app/api/jobs/Qm7tX2c9LpZa/snap/screen-closeup.jpg"
    },
    {
      "number": 20,
      "id": "move-zoom",
      "section": "Movement",
      "group": "",
      "rule": "Start every new screen with a zoom in or a zoom out in the first two seconds.",
      "kind": "required",
      "counts_toward_score": true,
      "status": "fail",
      "at_s": 3,
      "at": "0:03",
      "note": "Only 3 of 9 new screens open with a zoom. Start each new screen with a quick zoom in or out.",
      "snapshot_url": "https://analyzer-production-1bcb.up.railway.app/api/jobs/Qm7tX2c9LpZa/snap/move-zoom.jpg"
    },
    {
      "number": 21,
      "id": "move-camera",
      "section": "Movement",
      "group": "",
      "rule": "Hold the phone still only while the screen animates. Otherwise glide the phone to each part as you explain it.",
      "kind": "required",
      "counts_toward_score": true,
      "status": "pass",
      "at_s": 12.2,
      "at": "0:12",
      "note": "The camera glides between parts of the screen and stays still while the page animates.",
      "snapshot_url": "https://analyzer-production-1bcb.up.railway.app/api/jobs/Qm7tX2c9LpZa/snap/move-camera.jpg"
    }
  ],
  "optional": [
    {
      "number": 3,
      "id": "open-dark",
      "section": "Opening",
      "group": "You on camera",
      "rule": "Film in a dark or dim room.",
      "kind": "good_to_have",
      "counts_toward_score": false,
      "status": "pass",
      "at_s": 2,
      "at": "0:02",
      "note": "The room behind you is dim.",
      "snapshot_url": "https://analyzer-production-1bcb.up.railway.app/api/jobs/Qm7tX2c9LpZa/snap/open-dark.jpg"
    },
    {
      "number": 5,
      "id": "open-distance",
      "section": "Opening",
      "group": "You on camera",
      "rule": "Film from further away, holding the laptop with the demo playing on it.",
      "kind": "alternative",
      "counts_toward_score": false,
      "status": "fail",
      "at_s": null,
      "at": null,
      "note": "You film close to your face, not from further away with the laptop.",
      "snapshot_url": null
    },
    {
      "number": 7,
      "id": "open-gesture",
      "section": "Opening",
      "group": "On screen",
      "rule": "Hook with a gesture in the first second: point up at the title, drop a pen, or turn your phone toward the laptop.",
      "kind": "good_to_have",
      "counts_toward_score": false,
      "status": "fail",
      "at_s": null,
      "at": null,
      "note": "No gesture in the first 2 seconds.",
      "snapshot_url": null
    },
    {
      "number": 10,
      "id": "screen-dark",
      "section": "Filming the screen",
      "group": "Setup",
      "rule": "Film the laptop in a dark room with a dim background.",
      "kind": "good_to_have",
      "counts_toward_score": false,
      "status": "pass",
      "at_s": 9.5,
      "at": "0:09",
      "note": "Screen shots are filmed in a dim room.",
      "snapshot_url": "https://analyzer-production-1bcb.up.railway.app/api/jobs/Qm7tX2c9LpZa/snap/screen-dark.jpg"
    },
    {
      "number": 12,
      "id": "screen-second",
      "section": "Filming the screen",
      "group": "Setup",
      "rule": "Put a related image from another demo on the second screen behind the laptop.",
      "kind": "good_to_have",
      "counts_toward_score": false,
      "status": "fail",
      "at_s": 12,
      "at": "0:12",
      "note": "The second screen is blank.",
      "snapshot_url": "https://analyzer-production-1bcb.up.railway.app/api/jobs/Qm7tX2c9LpZa/snap/screen-second.jpg"
    },
    {
      "number": 16,
      "id": "move-pen",
      "section": "Movement",
      "group": "",
      "rule": "Switch from your hand to a pen now and then, and hover over the screen with the pen.",
      "kind": "good_to_have",
      "counts_toward_score": false,
      "status": "pass",
      "at_s": 14.1,
      "at": "0:14",
      "note": "You switch to a pen and hover over the chart.",
      "snapshot_url": "https://analyzer-production-1bcb.up.railway.app/api/jobs/Qm7tX2c9LpZa/snap/move-pen.jpg"
    },
    {
      "number": 18,
      "id": "move-object",
      "section": "Movement",
      "group": "",
      "rule": "Swap the pen for something unexpected, like a watch.",
      "kind": "good_to_have",
      "counts_toward_score": false,
      "status": "fail",
      "at_s": null,
      "at": null,
      "note": "Only a hand and a pen are used.",
      "snapshot_url": null
    }
  ]
}
```

## 4. Share link

`share_url` (`https://ugc-filming-guide.vercel.app/r/<id>`) opens the same report as a page: score, verdict,
every rule with timestamps and snapshots. Send it to the creator or a reviewer. Anyone with the link can see
the report; the id is random and works as the access token. Reports are kept for 365 days. The uploaded video
is deleted as soon as the check ends.

## 5. Limits

- mp4, mov or webm, up to 300 MB and 180 seconds.
- About 2-3 minutes per video.
- 3 videos are checked at a time; the rest wait in a queue (`status: "queued"`).
- 60 videos per day per API key. Without a key: 30 per hour per IP.
- `video_url`: https only, public hosts only, 5 minute download limit.

## Other endpoints

- `GET /api/jobs/<id>` the live job the web page uses (steps and raw result).
- `GET /api/health` `{"ok", "auth", "running", "queued"}`.
- `GET /api/agent` this how-to as plain text.
- Human readable page: https://ugc-filming-guide.vercel.app/agent.html

## Errors

- `401 {"error": "Invalid API key."}`: the key in the header is wrong.
- `429`: the daily limit for the key (60 videos) or the hourly limit without a key was reached.
- `400`: the file is not a video, is over 300 MB or longer than 3 minutes, or the `video_url` can't be downloaded.
- `503`: the analyser is not connected to Claude right now. Try again later.
