Skip to content

Per-Session Recording API (User Record API)

Customer backend ──appId/appSecret (appSecret is the signing key)──> mmx-recorder direct (split + upload)
Customer backend ──appId/appSecret──────────────────────────────────> ppcenter (round-events, audit only, optional)

A "session" here is a business activity with a clear start and end: a game round, an auction, a class — any of them.

1. Endpoint

POST https://record.pp-cdn.org/api/record/split
Content-Type: application/json

2. Two-phase protocol

One session maps to a "start" and an "end" call, distinguished by whether the gameRound field is empty:

Phase gameRound Effect
Session start (round-start) empty / omitted Locks (appId, tableId) to this call's owner (appEnv+gameId, or just gameId when no appEnv), and immediately splits every configured view path resolved from tableId, starting to accumulate new segments
Session end (round-end) a concrete value Verifies the caller is the owner that started this session, splits and renames each view path's current segment to the final file name, triggers asynchronous upload, and unlocks (appId, tableId)

The same (appId, tableId) pair can be held by only one owner at a time; another owner calling round-start meanwhile is rejected (see §5). The same tableId string under different appIds is independent — they resolve to different physical stream paths (see §4), so they don't lock each other. Repeated calls with the same owner (same appEnv+gameId) are allowed.

Calling round-end for a session that never started is a silent no-op success, not an error — the round-end signal is emitted unconditionally and shouldn't fail or alarm just because no matching round-start exists.

When a tableId has several views online at once (multiple cameras of the same table, stream name like {tableId}-{view}), one round-start/round-end splits each online view's path in one shot; the caller can't (and doesn't need to) call per view. Views are auto-discovered by the recorder from the currently known stream paths — no pre-configuration (the backend site_stream_configs is no longer used).

3. Request format

{
  "time": "1757280000",
  "appId": "app_xxx",
  "tableId": "table1",
  "gameId": "p2w001",
  "gameRound": "",
  "appEnv": ""
}
Field Required Notes
time yes Unix-seconds timestamp string — the request's signing time (not an expiry), used for both auth and anti-replay
appId yes The appId of the app you created in the console. It builds the actual stream path and owns the signing appSecret — whichever appId the request uses, the signature must be computed with that app's own appSecret. The appId is part of the signature too, so it can't be tampered into another app
tableId yes Table/camera id, ^[A-Za-z0-9_-]{1,128}$. Together with appId it prefix-matches the node's currently known stream paths as {appId}/{tableId}-{view}; view is auto-detected from the actual stream name ({appId}/{tableId}-{view}, optionally with /h264 /hevc codec suffixes, see multimatile note). If no path matches, it falls back to the default view fwh
gameId yes Session/business id, same charset. Together with appEnv it defines this session's owner
gameRound required for end, empty for start The "stage/round" within the session; decides start vs end (see §2), same charset
appEnv no Overrides the upload landing environment (see §6); also part of the owner identity — the same gameId under a different appEnv is a different owner

Field names are case-sensitive; unknown extra fields are ignored.

4. Authentication

The Authorization header is signed with your own appId's appSecret (the same credential generated when you create the app in the console — not a deployment-wide shared key). The exact format depends on the deployment's mode (default simple).

4.1 simple mode (default)

Authorization: <32-char hex md5>

Computed as md5(appSecret + time + appId + tableId + gameRound + gameId) — when gameRound is empty (session start) it is not concatenated; gameId is required so it is always included:

base = appSecret + time + appId + tableId
if gameRound:  base += gameRound
base += gameId
token = md5(base).hexdigest()

4.2 advance mode (must be enabled by us)

Stricter anti-replay; enabled per integration on the deployment side:

Authorization: HMAC-SHA256 <hex(hmac_sha256(appSecret, canonical_json))>
X-Split-Rec-Nonce: <16~128 chars, random, no \r\n>

where canonical_json serializes fields in a fixed order (time,appId,tableId,gameRound,gameId,nonce):

{"time":"1757280000","appId":"app_xxx","tableId":"table1","gameRound":"","gameId":"p2w001","nonce":"<the same nonce>"}

The time tolerance differs: simple mode requires time within ±30 s of the server clock (no nonce, so a wider window means a longer replay opportunity); advance mode allows ±5 min and relies on the nonce — the same nonce cannot be reused.

5. Response envelope and status codes

Unified envelope:

{"code": 200, "msg": "OK", "data": null}
HTTP code When
200 200 Success, including the silent no-op "end a session that never started"
400 400 Body is not valid JSON; any of time/appId/tableId/gameId/Authorization missing; illegal characters in appId/tableId/gameId/gameRound/appEnv
401 401 Signature verification failed (including a tampered appId); time outside the tolerance (±30 s simple / ±5 min advance); reused nonce in advance mode
429 429 Rate limited — up to 500 requests/second per IP
500 500 Business failure — including the conflict "this (appId, tableId) is already held by another gameId" (not 409); and "the segment to finalize has no new data since the last split"
500 (code=50001) 50001 None of the view paths resolved from this tableId under the app is online (the app is valid, there is just no active publish right now) — distinguished from other 500s by the code field, no need to parse msg

For 500-class errors (except code=50001), msg carries the specific text (e.g. game "p2w002" already has an active recording on table "table1"); integrations should branch on the text rather than the status code alone, because these "business rejections" share the same HTTP code as real server errors.

6. Upload

After round-end finalizes the files, they are uploaded to object storage asynchronously and best-effort — an upload failure does not affect the 200 already returned by /api/record/split, and a success response does not mean the upload finished or will succeed.

  • With no appEnv (empty), the object name has no prefix — it is <tableId>-<viewName>-<roundId>.mp4 (viewName is the view resolved from tableId, roundId is the request's gameRound; gameId is used only for reporting/audit and no longer appears in the file name). Omitting it neither errors nor substitutes a deployment default.
  • Only an explicitly non-empty appEnv adds a <lowercased appEnv>/ prefix, e.g. appEnv=test yields test/table1-...mp4; prod is not special — appEnv=prod also adds the prod/ prefix.
  • Regardless of appEnv, everything lands in the same OVH net-storage bucket — a constraint of OVH net-storage (no "one bucket per environment"), so environments are separated only by object-name prefix, not by bucket.
  • Practical use: during acceptance/integration you can explicitly pass an appEnv (e.g. test) so test files land under a separate prefix in the same bucket and don't mix with recordings that omit appEnv; this is path isolation within one bucket, not bucket-level access control.
  • Before upload the file is format-optimized (without re-encoding) so it can play directly in the browser without a full download; if optimization fails it falls back to uploading the original, so the recording is never lost.

7. Full example (simple mode)

APP_SECRET="your appId's own appSecret (generated when the app was created)"
APP_ID="app_xxx"; TABLE_ID="table1"; GAME_ID="p2w001"

# Session start
T=$(date +%s)
AUTH=$(printf '%s%s%s%s%s' "$APP_SECRET" "$T" "$APP_ID" "$TABLE_ID" "$GAME_ID" | md5sum | cut -d' ' -f1)
curl -s -X POST "https://record.pp-cdn.org/api/record/split" \
  -H "Authorization: $AUTH" -H "Content-Type: application/json" \
  -d "{\"time\":\"$T\",\"appId\":\"$APP_ID\",\"tableId\":\"$TABLE_ID\",\"gameId\":\"$GAME_ID\"}"

# ...session in progress (live stream / class)...

# Session end
T=$(date +%s); GAME_ROUND="gc001"
AUTH=$(printf '%s%s%s%s%s%s' "$APP_SECRET" "$T" "$APP_ID" "$TABLE_ID" "$GAME_ROUND" "$GAME_ID" | md5sum | cut -d' ' -f1)
curl -s -X POST "https://record.pp-cdn.org/api/record/split" \
  -H "Authorization: $AUTH" -H "Content-Type: application/json" \
  -d "{\"time\":\"$T\",\"appId\":\"$APP_ID\",\"tableId\":\"$TABLE_ID\",\"gameRound\":\"$GAME_ROUND\",\"gameId\":\"$GAME_ID\"}"

8. Deployment details to confirm before integrating

  • The target stream must use the appId/streamName publishing scheme (create the App in the console and publish as appId/streamName); streamName must be {tableId}-{view} — a hard requirement of the mmx publish whitelist. A stream that doesn't match is rejected and round-start resolves no path. view is arbitrary; the recorder auto-detects it by the {appId}/{tableId}- prefix from the currently known stream paths (no need to pre-configure view).
  • appId must be a currently valid app (not in arrears, not deleted): mmx nodes sync the valid app list (including appId+appSecret) from ppcenter every 30 s, so a new app, an arrears recovery, or a changed appSecret takes up to 30 s to take effect.
  • If no view for this tableId is publishing, round-start fails with code=50001 (see §5) — it's not a path-config problem; retry once publishing actually starts.
  • Multiple views (several views under one tableId) need no pre-configuration: as long as each view's stream ({appId}/{tableId}-{view}) is online, round-start records them all; only the default fwh fallback view is an implicit convention (used as the ingest fallback when no path matches).
  • Don't call end immediately after start: if the gap is too short (shorter than a segment, typically ~1 s), no new data has been written to the current segment and end fails with "nothing to finalize". Real sessions are far longer; only integration/load-test scripts tend to hit this.