# Ziggeo integration

Working with Ziggeo audio/video recording in this project? Follow this. It is generated
from Ziggeo's production API and SDK, not from documentation, and is re-verified on every
deploy.

## What Ziggeo is

Ziggeo is an embeddable audio/video/image **recorder and player**, plus the pipeline behind
it: transcoding, renditions, storage, playback, transcription and retention. It runs inside
someone else's product under their brand. Most Ziggeo traffic is hiring and assessment
platforms capturing candidate answers, so the typical integration is "let our users record
an answer in our app, then process and play it back".

## Rule 0: pick the right region

Every account lives in exactly ONE region. The two are separate deployments with separate
data and separate credentials, and calling the wrong one returns **401 — byte-identical to
sending no credentials at all**. There is no hint that the key is fine and the host is
wrong, so if auth fails, try the other region before you go looking at the key.

| Region | Server API base |
|---|---|
| US | `https://srvapi.ziggeo.com` |
| EU | `https://srvapi-eu-west-1.ziggeo.com` |

The browser SDK talks to a **different host** (`embed-api.ziggeo.com`) and figures that out
from the application token on its own. Authorization tokens authenticate against the browser
host, **not** against the server API — sending one to `srvapi` returns 401.

## Server API: authentication

HTTP **Basic auth**: username = application token, password = **private key**. The private
key is a server-side secret. The application token is not secret — it is what you put in the
browser.

```bash
curl -u "$ZIGGEO_APP_TOKEN:$ZIGGEO_PRIVATE_KEY" "https://srvapi.ziggeo.com/v1/videos?limit=10"
```

## Uploading a video from your server

This is three calls, and **all three are required**. There is no single-call upload, and the
call that looks like one silently does nothing (see Traps).

```bash
BASE="https://srvapi.ziggeo.com"
AUTH="$ZIGGEO_APP_TOKEN:$ZIGGEO_PRIVATE_KEY"

# 1. Ask for an upload target. video_type is REQUIRED (412 without it).
#    Returns {"video": {...}, "stream": {...}, "url_data": {"url": ..., "fields": {...}}}
RESP=$(curl -s -u "$AUTH" -X POST -d "video_type=video" "$BASE/v1/videos-upload-url")

VIDEO=$(echo "$RESP"  | jq -r .video.token)
STREAM=$(echo "$RESP" | jq -r .stream.token)

# 2. POST the bytes to S3 as a presigned FORM post. Every key in url_data.fields
#    becomes a form field, and "file" MUST come last — S3 ignores anything after it.
curl -s -X POST $(echo "$RESP" | jq -r '.url_data.fields | to_entries[] | "-F \(.key)=\(.value)"') \
     -F "file=@candidate-answer.mp4" \
     "$(echo "$RESP" | jq -r .url_data.url)"        # -> 204 No Content

# 3. Tell Ziggeo the bytes arrived. WITHOUT THIS the video stays EMPTY forever.
curl -s -u "$AUTH" -X POST "$BASE/v1/videos/$VIDEO/streams/$STREAM/confirm-video"
```

`confirm-video` returns 201 with the stream still showing `state: 2` (EMPTY). That is
normal — it is an acknowledgement, not a completion.

Then poll `GET /v1/videos/{token}`. A short clip is usually done in **seconds**; allow a
couple of minutes before you treat it as stuck.

`state: 5` is not quite the finish line: the video can report READY while its renditions are
still being produced, so listing streams at that moment returns fewer than you will
eventually have. **`pending_stream_jobs === 0` is the real settle signal** — poll for that if
you intend to pick a rendition.

### States

The video and its streams use **different numbering** — 4 means PROCESSING on a video and
BOUND on a stream. Poll the video unless you specifically want a rendition.

| Video / audio | `2` FAILED · `3` EMPTY · `4` PROCESSING · `5` READY · `6` DELETED |
| Stream | `2` EMPTY · `3` PROCESSING · `4` BOUND · `5` READY |

`FAILED` (2) is terminal: retrying the confirm will not help. If a video is still in
PROCESSING after ~10 minutes, treat it as stuck rather than slow — but read the re-confirm
trap below before you retry anything.

## Endpoints

| Method | Path | What it does |
|---|---|---|
| `GET` | `/v1/videos` | list videos |
| `GET` | `/v1/videos/count` | count videos (states filter, default READY) |
| `POST` | `/v1/videos` | create a video record — METADATA ONLY, it will not accept a file |
| `GET` | `/v1/videos/{token}` | read one video by token or key |
| `POST` | `/v1/videos/{token}` | update a video |
| `DELETE` | `/v1/videos/{token}` | delete a video |
| `POST` | `/v1/videos/get_bulk` | read many videos in one call |
| `GET` | `/v1/videos/{token}/streams` | list a video's streams (renditions) |
| `GET` | `/v1/videos/{token}/streams/{stream}` | read one stream |
| `GET` | `/v1/videos/{token}/video` | 302 to the DEFAULT rendition's bytes |
| `GET` | `/v1/videos/{token}/image` | 302 to the default rendition's poster image |
| `GET` | `/v1/videos/{token}/streams/{stream}/video` | 302 to a SPECIFIC rendition's bytes |
| `POST` | `/v1/videos-upload-url` | step 1 of a server-side upload; `video_type` is required |
| `POST` | `/v1/videos/{token}/streams/{stream}/confirm-video` | step 3 — without this the video stays EMPTY forever |
| `POST` | `/v1/videos/{token}/streams-upload-url` | presigned URL to add a stream |
| `POST` | `/v1/authtokens` | create a server-side authorization token |
| `GET` | `/v1/metaprofiles` | list meta profiles (transcription etc.) |
| `POST` | `/v1/videos/{token}/metaprofile` | apply a meta profile to a video |
| `POST` | `/v1/videos/{token}/push` | push a video to a configured target |

Responses are bare JSON records, **except `/v1/videos-upload-url`**, which returns a
`{video, stream, url_data}` envelope.

## Reading media back

`GET /v1/videos` **defaults to `states=READY`**, so a video you just uploaded is missing
from the list until it finishes processing — it has not failed, it is not yet READY. Pass
`states=PROCESSING` (or another name) to see it.

The filter only understands state **names**. An unrecognized value — including the numeric
state the record itself exposes, e.g. `states=4` — is silently ignored and you get
everything, unfiltered. There is no error.

Paging: `limit` (default 50, **silently capped at 100**), `skip`, and `reverse=true` for
oldest-first. Asking for 500 returns 100 without complaint, so page with `skip`.

## Downloading

`GET /v1/videos/{token}/video` redirects to the **default stream** — a transcoded rendition
chosen for playback, not your original file. To get a specific one, list the renditions and
address it explicitly:

```
GET /v1/videos/{token}/streams                      -> choose by video_width / video_size
GET /v1/videos/{token}/streams/{stream}/video       -> that exact rendition's bytes
```

The stream whose `creation_type` marks it as the uploaded source is byte-identical to what
you sent; the transcoded renditions are not. Both are real files — verified.

Do not validate stream tokens by length. The token you get from `videos-upload-url` is 32 hex
characters, while a transcoded rendition's is a 24-character ObjectId. Anything asserting one
shape will reject real renditions.

## Transcription — read this before you promise it

**Meta profiles can only be created in the dashboard.** There is no `POST /v1/metaprofiles`;
the API can list profiles and apply an existing one, and that is all. With only an
application token and a private key you **cannot** turn transcription on.

The working order is:

1. In the dashboard, create a meta profile with a transcription process
   (<https://ziggeo.com/docs/dashboard/profiles/meta-profiles/>).
2. Attach it — at record time with the `ziggeo-meta-profile` embedding attribute, or after
   the fact with `POST /v1/videos/{token}/metaprofile`, whose parameter is
   `metaprofiletoken` (an unknown token returns 404 "Meta profile not found").
3. The result lands on the stream as `audio_transcription`, and as `subtitles` for the
   player. Both are `null` until a profile has run.

`transcript_language` is **stored exactly as sent and never validated** — `klingon` is
accepted with a 200. It is resolved when transcription runs: exact match, then the closest
supported variant of the same language, then `en-US`. So a typo does not error, it quietly
transcribes in the wrong language. Validate it yourself before sending.

Accepted values: `nl-NL`, `en-US`, `en-UK`, `en-AU`, `fr-FR`, `de-DE`, `it-IT`, `pt-BR`, `es-ES`, `es-LA`.

## Authorization tokens

`POST /v1/authtokens` mints a token for the browser. Three things will bite you:

- **`grants` must be a JSON-encoded string.** Sent as form arrays (`grants[read][all]=true`)
  it returns **201 with `"grants": []`** — a token that grants nothing, and looks like a
  success. Use `--data-urlencode 'grants={"read":{"all":true}}'`.
- **`usage_expiration_time` is in DAYS**, whatever you read elsewhere. Sending `3600`
  expecting an hour stores `311040000` seconds — a ten-year token.
- **A grant is not a lock.** Read access is open by default, so a token restricting reads to
  two videos changes nothing until you restrict the application itself in the dashboard's
  authorization settings. Minting tokens is not, by itself, a security boundary.

**To express anything shorter than a day, send a fraction**: `usage_expiration_time` is
multiplied by 86400, so `0.0416666667` stores exactly 3600 seconds. There is no
seconds-denominated alternative — `expiration_date` exists but is stored **completely
unvalidated** (`not-a-date-at-all` is accepted and echoed back with a 201), so do not use it.

Tokens cannot be read back or revoked — `GET` and `DELETE` on `/v1/authtokens/{token}` both
404 — so keep expiries short.

### Handing a token to the browser needs `auth: true` as well

Put the token on the embedding with `ziggeo-server-auth` (or `ziggeo-client-auth`) **and set
`auth: true` on the Application**:

```js
var app = new ZiggeoApi.V2.Application({ token: "APP_TOKEN", auth: true });
```

Miss the `auth: true` and the player renders its entire UI and then never requests the video.
No exception, no visible error, no network call — only a console warning:

> You are specifying auth tokens on your embedding yet your application is initialized with
> auth = false.

A page that works, plus an auth token added the obvious way, is a page that silently stops
playing.

## Browser recorder

```html
<link rel="stylesheet" href="https://assets.ziggeo.com/v2-stable/ziggeo.css" />
<script src="https://assets.ziggeo.com/v2-stable/ziggeo.js"></script>

<script>
  // App-wide configuration. A few settings are honored ONLY here and are
  // silently overwritten if you set them on the element — see "Traps".
  var app = new ZiggeoApi.V2.Application({
    token: "YOUR_APP_TOKEN",
    webrtc_on_mobile: true,
  });
</script>

<ziggeorecorder
  ziggeo-timelimit="120"
  ziggeo-recordingwidth="640"
  ziggeo-recordingheight="480"
></ziggeorecorder>
```

Playback: `<ziggeoplayer ziggeo-video="VIDEO_TOKEN"></ziggeoplayer>`.
`ziggeoaudiorecorder` and `ziggeoaudioplayer` are the audio-only equivalents.

## Traps

Mistakes that look correct, return a success code, and fail quietly in production.

### `POST /v1/videos` will not accept your file

Posting a file to it returns **201 with a complete video record** — and discards the bytes.
The video sits in `EMPTY` forever. No field name works: it is a metadata-only endpoint.
Ship this and you get an account full of empty videos and no errors anywhere. Use the
three-step upload above.

### Skipping `confirm-video` leaves everything looking fine

S3 returns 204, your code sees no error, and the video never leaves `EMPTY`. The confirm is
what tells Ziggeo the bytes exist.

### Re-confirming a stuck video costs you a second transcode

If a video stalls in PROCESSING, a second `confirm-video` can reconcile it — but it also
starts **another** transcode, leaving a duplicate rendition you will be billed for. Poll
patiently first, and prefer a fresh upload over repeated confirms.

### `webrtconmobile` as an element attribute does nothing

Setting `ziggeo-webrtconmobile="true"` has no effect. The SDK assigns that value from the
Application object on every embedding:

```js
this.set("webrtconmobile", this.application.data.get("webrtc_on_mobile"))
```

So the attribute is overwritten before it is read. Enable it on the Application instead.
Without it, mobile browsers fall back to the operating system's camera app — on iOS that
yields a 480x360 `.mov`, which is why "our mobile recordings are tiny" is almost always
this setting and not the recorder.

### `has_audio` is not the field you want

A stream with an audio track reports `has_audio: false` and `video_has_audio: true`. Gate
transcription on `video_has_audio`; the obvious-looking field will skip every video.

### `tags` is comma-separated, not JSON

`tags=["interview","round-2"]` is stored as the literal strings `["interview` and
`"round-2"]`, split on the comma, with no error. Send `tags=interview,round-2`.

### Do not promise these

Not available today, whatever an older page or an older model says: automatic transcription
language detection, NSFW/content moderation, general video tagging, and transcript `topics`
(the field exists and is always empty). If asked, say so rather than generating code for them.

## When you do not know

<https://ziggeo.com/docs/> is the current documentation and <https://ziggeo.com/llms.txt> is
a machine-readable index of the site.

The pages worth fetching before you guess at a name:

- <https://ziggeo.com/docs/sdks/javascript/browser-integration/parameters/> — every embedding
  attribute, with its type, default and which embeddings accept it. Prefix each with
  `ziggeo-` in HTML; use it bare in the JavaScript `attrs` object.
- <https://ziggeo.com/docs/sdks/javascript/browser-interaction/events/> — events you can bind.
- <https://ziggeo.com/docs/sdks/javascript/browser-interaction/methods/> — callable methods.

Prefer fetching one of those over recalling an API from memory: Ziggeo's docs predate most
training data, and the parts that changed are the parts above.
