Lersia API v3 Reference
Base path: /api/v3
x-device-typeheader is mandatory on every request — one ofweb,mobile,desktop,tv. Missing/unrecognized →422.- Non-
webtypes also need a version header:mobile→x-app-version,desktop→x-desktop-app-version,tv→x-tv-app-version. Below the configured minimum →422. Accept: application/jsonshould be sent on every request.- Session vs. token: auth-sensitive endpoints branch on
x-device-type.webgets 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.
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[].
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.
Response: paginated Series summaries — id, type: "Series", title, slug, release_year, rating, featured, published, aired_at, posters, genres[], episodes_count.
Response: Series detail — summary fields plus synopsis, starring, trailer, disable_single_episode_purchase, purchase_expires_after, cost_*, owner, videos[], paymentInfo?. 404 if unpublished.
Response: {"series": SeriesSummary, "episode": VideoDetail} — episode includes pivot episode_number/season_number.
Response: flat array (no pagination) of every featured+published Movie/Series, merged, sorted by aired_at desc.
Query: type = movies | series | all (default all)
Response: flat array, future aired_at.
Query: type = movies | series | all
Response: flat array, newest 5 per type.
Response: {"paymentGateway", "paymentGatewayStatus", ...}.
Query: term (required)
Response: matching published Movies+Series merged. Not resource-wrapped — raw SQL UNION, different shape from other lists.
Response: {"featured": [...], "newArrivals": [...], "comingSoon": [...]}.
Response: app-wide metadata (genres, categories, etc).
Response: content the authenticated user currently has active access to.
Media
payableType = video | series (case-insensitive)
Response: the Payment object on active access (200); 403 {"message": "no access"} otherwise.
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"}.
Response: {"canWatch": resolution|false, "paid_at", "expires_at", "validity", "allowed": bool}
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.
Query: payment_id OR user_id+video_id
Response: play=true (text/html, 200) or {"cannotplay"} (402).
Query: status, video_id, per_page
Response: {"data": [SessionLock, ...], "meta"}, scoped to the caller.
Response: {"status": "ok"} if owned by caller, 403 otherwise.
Body: {"device_id"}
Response: {"status": "ok"} — unlocks locks matching device_id + caller.
Body: {"paymentId"}
Response: 1 (no-op, raw scalar — not JSON-quoted).
Body: {"currentTime"}
Response: 200, no-op.
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.
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.
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.
Response: authenticated user's record plus message, messageId, paymentDown, duplicatePhoneAccounts ([] if none/no phone).
Response: {"user": User, "payments": [Payment, ...]} — 5 most recent paid payments.
Query: per_page (default 15)
Response: {"data": [Payment, ...], "meta"} — paid only, newest first, cost_640 hidden on payable, episode payments get series: {"id", "title"}.
Response: {"success": true, "message": "Number of views incremented"}
Body: {"current_password", "password", "password_confirmation"}
Response: "ok" (raw scalar). 422 if current password wrong or not confirmed.
Body: {"phone"}
Response: "ok". 400 {"message": "Phone number already in use by another account."} on collision.
Body: {"otp", "phone"}
Response: "ok" on correct OTP (sets + verifies phone). 422 on wrong OTP.
Response: {"day": [{"title", "total"}, ...]}. 403 for non-admin.
Response: most recently created Notification, or null.
Auth
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.
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).
Body: {"ticket"} · throttle:login
Response: resends OTP for an unexpired ticket. 422 if missing/expired.
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.
Body: {"ticket", "otp"} · throttle:login
Confirms OTP and only then creates the account (phone_number_verified = true).
web → 204, session established. other → 200 {"token", "user", "duplicatePhoneAccounts": []}.
422 if ticket missing/expired, OTP wrong, or another registration completed for the same email/phone meanwhile.
Revokes the bearer token used (if any), unlocks any session lock for the caller's device, ends the web session. Response: 204.
Body: {"email"} · throttle:login
Response: "ok" (raw scalar) if sent. 422 if email doesn't exist.
Body: {"is_email": bool, "email"?, "phone"?} · throttle:login
Response: the email/phone the OTP was sent to (raw scalar string).
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.
Body: {"is_email": bool, "otp", "email"?, "phone"?, "password", "password_confirmation"} · throttle:login
Response: [User, password] (array) on success. 422 on wrong OTP.
Response: a new, unclaimed QR-session code (raw scalar string).
Body: {"qr_code"}
Response: {"loggedIn": true, "token", "user"} (200) once claimed by another device; {"loggedIn": false, "message": "Invalid Code"} (401) otherwise.
Body: {"qr_code"} — Response: 204.
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).
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:
| Response | Meaning / 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/googleor/auth/social/appleon 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.