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(viewNameis the view resolved fromtableId,roundIdis the request'sgameRound;gameIdis 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
appEnvadds a<lowercased appEnv>/prefix, e.g.appEnv=testyieldstest/table1-...mp4;prodis not special —appEnv=prodalso adds theprod/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 omitappEnv; 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/streamNamepublishing scheme (create the App in the console and publish asappId/streamName);streamNamemust 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.viewis arbitrary; the recorder auto-detects it by the{appId}/{tableId}-prefix from the currently known stream paths (no need to pre-configureview). appIdmust be a currently valid app (not in arrears, not deleted): mmx nodes sync the valid app list (includingappId+appSecret) from ppcenter every 30 s, so a new app, an arrears recovery, or a changedappSecrettakes up to 30 s to take effect.- If no view for this
tableIdis publishing, round-start fails withcode=50001(see §5) — it's not a path-config problem; retry once publishing actually starts. - Multiple views (several
views under onetableId) need no pre-configuration: as long as each view's stream ({appId}/{tableId}-{view}) is online, round-start records them all; only the defaultfwhfallback 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.