Lersia API v3 Reference

Base path: /api/v3

Every request
  • x-device-type header is mandatory on every request — one of web, mobile, desktop, tv. Missing/unrecognized → 422.
  • Non-web types also need a version header: mobilex-app-version, desktopx-desktop-app-version, tvx-tv-app-version. Below the configured minimum → 422.
  • Accept: application/json should be sent on every request.
  • Session vs. token: auth-sensitive endpoints branch on x-device-type. web gets a session cookie; every other type gets a Sanctum bearer token (Authorization: Bearer <token>).
  • throttle:login: 5 requests/minute/IP on every unauthenticated credential/OTP endpoint. Over limit → 429.
  • Phone numbers always need an explicit country code (+<country code><number>, e.g. +911234567890). No endpoint infers or prepends one.
  • Endpoints marked auth require a valid token/session. Missing/invalid → 401.

Content

Public unless noted. List endpoints return {"data": [...], "meta": {"page", "per_page", "total"}} unless noted otherwise.

GET/moviespublic

Query: page, per_page, term, featured, excludeIds[], sortBy, descending

Response: paginated Movie summaries — id, type, title, slug, release_year, rating, duration, category, featured, published, aired_at, posters, genres[].

GET/movies/{movie}public

Response: Movie detail — summary fields plus synopsis, starring, credits, trailer, purchase_expires_after, cost_1920, cost_1280, cost_800, cost_640, director, owner, paymentInfo?. paymentInfo only for a user with active access.

GET/seriespublic

Response: paginated Series summaries — id, type: "Series", title, slug, release_year, rating, featured, published, aired_at, posters, genres[], episodes_count.

GET/series/{series}public

Response: Series detail — summary fields plus synopsis, starring, trailer, disable_single_episode_purchase, purchase_expires_after, cost_*, owner, videos[], paymentInfo?. 404 if unpublished.

GET/series/{series}/episodes/{episode}public

Response: {"series": SeriesSummary, "episode": VideoDetail} — episode includes pivot episode_number/season_number.

GET/upcomingpublic

Query: type = movies | series | all (default all)

Response: flat array, future aired_at.

GET/latestpublic

Query: type = movies | series | all

Response: flat array, newest 5 per type.

GET/site-configpublic

Response: {"paymentGateway", "paymentGatewayStatus", ...}.

GET/homepublic

Response: {"featured": [...], "newArrivals": [...], "comingSoon": [...]}.

GET/metapublic

Response: app-wide metadata (genres, categories, etc).

GET/active-contentsauth

Response: content the authenticated user currently has active access to.

Media

GET/check-media-access/{payableType}/{payableId}auth

payableType = video | series (case-insensitive)

Response: the Payment object on active access (200); 403 {"message": "no access"} otherwise.

GET/check-video/{video}public (guest ok)

Episode response: {"canWatch": resolution|false, "paid_at", "payment_id", "expires_at", "validity", "file_name", "sources", "allowed": bool}

Movie response: {"video_id", "allowed": bool, "expires_at"}

Conflicting session lock on another device → 403 {"lock", "lockMessage"}.

GET/check-series/{series}public

Response: {"canWatch": resolution|false, "paid_at", "expires_at", "validity", "allowed": bool}

GET/init-playback/{video}auth

Query: encrypted = 1 (default) | 0

Response: DRM playback payload on access; 403 {"message": "Unauthorized. Invalid or expired payment"} otherwise. Admins / (if enabled) iPhone devices bypass the check.

GET/ezdrm-authorizationpublic (server-to-server)

Query: payment_id OR user_id+video_id

Response: play=true (text/html, 200) or {"cannotplay"} (402).

GET/session-locksauth

Query: status, video_id, per_page

Response: {"data": [SessionLock, ...], "meta"}, scoped to the caller.

POST/session-locks/{lock}/unlockauth

Response: {"status": "ok"} if owned by caller, 403 otherwise.

POST/session-locks/clear-allauth

Body: {"device_id"}

Response: {"status": "ok"} — unlocks locks matching device_id + caller.

POST/playback-sessionauth

Body: {"paymentId"}

Response: 1 (no-op, raw scalar — not JSON-quoted).

PUT/playback-session/{sessionId}auth

Body: {"currentTime"}

Response: 200, no-op.

POST/buy-mediaauth

Body: {"type": "Video"|"Series", "id", "resolution"}

Response (Razorpay, IN): {"base_url", "amount", "currency", "key", "payment_id", "email", "name", "phone", "user_id", "order_id", "payable_id", "payable_type", "payable_title"}

Response (PayU): {"payuKey", "merchantId", "txnId", "amount", "phoneNumber", "email", "userFirstName", "successUrl", "failedUrl", "description", "hashValue"}

409 {"message"} if unpublished or single-episode purchase locked.

POST/buy-media/apple-iapauth

Body: {"receipt_data", "id", "resolution", "type", "status"}

Response: same shape as /buy-media Razorpay response on status === 'Success'. 400 {"error": "Payment Failed"|"Payment Pending"} otherwise.

⚠ Known security gap (tracked separately, not fixed by design): confirms purchase based solely on the client-supplied status field — never validates Apple's verifyReceipt response. A 200 here is not proof of real payment.

Account

All endpoints require auth except GET /notifications.

GET/auth/userauth

Response: authenticated user's record plus message, messageId, paymentDown, duplicatePhoneAccounts ([] if none/no phone).

GET/accountauth

Response: {"user": User, "payments": [Payment, ...]} — 5 most recent paid payments.

GET/account/paymentsauth

Query: per_page (default 15)

Response: {"data": [Payment, ...], "meta"} — paid only, newest first, cost_640 hidden on payable, episode payments get series: {"id", "title"}.

POST/account/payments/{payment}/increment-number-of-viewsauth

Response: {"success": true, "message": "Number of views incremented"}

POST/account/update-passwordauth

Body: {"current_password", "password", "password_confirmation"}

Response: "ok" (raw scalar). 422 if current password wrong or not confirmed.

POST/account/phone/requestauth

Body: {"phone"}

Response: "ok". 400 {"message": "Phone number already in use by another account."} on collision.

POST/account/phone/verifyauth

Body: {"otp", "phone"}

Response: "ok" on correct OTP (sets + verifies phone). 422 on wrong OTP.

GET/quick-statsauth, admin

Response: {"day": [{"title", "total"}, ...]}. 403 for non-admin.

GET/notificationspublic

Response: most recently created Notification, or null.

Auth

POST/auth/loginpublic

Body: {"email", "password", "device_name"?} (device_name required for non-web) · throttle:login

web: session established, returns User (200), no token. Wrong creds → 401.

other: revokes existing tokens, returns {"token", "user", "duplicatePhoneAccounts"}. Wrong creds → 422.

Subject to the phone verification gate.

POST/auth/login/complete-phone-verificationpublic

Body: {"ticket", "otp"} · throttle:login

Response: same shape the original gated /auth/login call would have returned. 422 if ticket missing/expired or OTP wrong (ticket stays valid on wrong OTP).

POST/auth/login/resend-otppublic

Body: {"ticket"} · throttle:login

Response: resends OTP for an unexpired ticket. 422 if missing/expired.

POST/auth/registerpublic

Body: {"name", "email", "password", "password_confirmation", "phone", "device_name"?} · throttle:login

Does NOT create an account. Validates everything, stages the submission, sends an OTP, responds 202 {"status": "phone_verification_required", "ticket"}. 422 on any validation failure.

POST/auth/register/verifypublic

Body: {"ticket", "otp"} · throttle:login

Confirms OTP and only then creates the account (phone_number_verified = true).

web204, session established. other200 {"token", "user", "duplicatePhoneAccounts": []}.

422 if ticket missing/expired, OTP wrong, or another registration completed for the same email/phone meanwhile.

POST/auth/logoutauth

Revokes the bearer token used (if any), unlocks any session lock for the caller's device, ends the web session. Response: 204.

POST/auth/send-reset-password-linkpublic

Body: {"email"} · throttle:login

Response: "ok" (raw scalar) if sent. 422 if email doesn't exist.

POST/auth/otp/requestpublic

Body: {"is_email": bool, "email"?, "phone"?} · throttle:login

Response: the email/phone the OTP was sent to (raw scalar string).

POST/auth/otp/verifypublic

Body: {"is_email": bool, "otp", "email"?, "phone"?, "password"?, "device_name"?} · throttle:login

Logs the user in on success — a full OTP-based login, not just verification.

Phone branch: always marks phone verified on success (even with a simultaneous password change) — self-clears the phone gate.

Email branch: subject to the phone verification gate.

web → session, returns User. other{"token", "user", "duplicatePhoneAccounts"}, device_name required. 422 on wrong OTP.

POST/auth/otp/update-passwordpublic

Body: {"is_email": bool, "otp", "email"?, "phone"?, "password", "password_confirmation"} · throttle:login

Response: [User, password] (array) on success. 422 on wrong OTP.

GET/auth/qr/requestpublic

Response: a new, unclaimed QR-session code (raw scalar string).

POST/auth/qr/checkpublic

Body: {"qr_code"}

Response: {"loggedIn": true, "token", "user"} (200) once claimed by another device; {"loggedIn": false, "message": "Invalid Code"} (401) otherwise.

POST/auth/qr/clearpublic

Body: {"qr_code"}Response: 204.

POST/auth/set-user-for-tvauth

Body: {"qr_code"}

Response: {"loggedIn": true} (200) or {"loggedIn": false, "message": "Invalid Code"} (401). Also has a defensive phone-verification-gate backstop (should be unreachable in practice).

POST/auth/remove-duplicatesauth

Soft-deletes every other account sharing the caller's phone number. Response: "ok" (raw scalar).

Phone verification gate

Applies to: POST /auth/login (both branches), POST /auth/otp/verify's email branch, and defensively to POST /auth/set-user-for-tv. Does not apply to Google/Apple social login — that surface does not exist in v3.

When credentials/OTP check out but the account's phone isn't verified, the endpoint returns 403 with one of:

ResponseMeaning / next step
{"status": "phone_required"} No phone on file at all. Resend the exact same request with a phone field added (with country code). Server stages it (not yet saved to the account) and sends an OTP.
{"status": "phone_verification_required", "ticket"} Either the account already had a phone that's unverified, or one was just staged via phone_required — an OTP was sent either way. Call /auth/login/complete-phone-verification with {"ticket", "otp"}. /auth/login/resend-otp resends if needed.

Tickets expire after 10 minutes.

A phone supplied via phone_required is never written to the account until the OTP is verified — it's staged on the ticket only. A typo or wrong number just produces an OTP the user never receives; the ticket expires, nothing was saved, and the account is exactly as phone-less as before. Resend /auth/login again with the correct number for a fresh ticket — no lockout, no retry limit beyond the standard throttle:login rate.

Removed / not in v3

  • Google/Apple social login — deliberately not present in v3 (see the phone verification gate section above). Do not build client integrations expecting /auth/social/google or /auth/social/apple on this surface.
  • Any endpoint under /mobile, /desktop, /tv, or plain /api (no version) not listed above is a legacy platform-specific route, frozen and pending sunset — not part of v3 and not documented here.