Troubleshooting
symptom, cause, fix.
The problems you are most likely to meet, in the order you meet them. Press / and search for the exact message.
01The app does not start properly
| Symptom | Cause | Fix |
|---|---|---|
| The app shows a setup screen instead of sign-in | app/lib/firebase_options.dart is still the placeholder with empty values. | Run flutterfire configure in app/ (how), or use DEMO_MODE=true / USE_EMULATOR=true. |
Every action fails with permission-denied or unauthenticated in a debug build | App Check: the debug token of this device is not registered. | Copy the debug token from the device log into Firebase console → App Check → Manage debug tokens. Or set ENFORCE_APP_CHECK=false in firebase/functions/.env while you set up, and redeploy. |
Actions fail with not-found or internal | The app calls Functions in a different region than you deployed to. | Build with --dart-define=FUNCTIONS_REGION=<your region>, matching firebase/functions/.env. |
| Google sign-in fails on Android | The SHA-1/SHA-256 fingerprints of your signing key are missing in Firebase. | Add them in Project settings, run flutterfire configure again. |
| Sign in with Apple fails | The capability or the Firebase Apple provider is not set up. | Add the capability in Xcode; enable Apple in Firebase Authentication. |
flutter pub get says the current Flutter SDK version does not satisfy the requirement | 4.0.0 needs Flutter 3.41 or newer (flutter: ">=3.41.0" in pubspec.yaml). | flutter upgrade, then flutter --version. |
pod install fails with a deployment target error | The plugins need iOS 15. | Keep platform :ios, '15.0' in the Podfile and set Minimum Deployments 15.0 in Xcode. |
| iOS build fails with "Include of non-modular header inside framework module 'google_mobile_ads…'" | google_mobile_ads was upgraded to 9.1.x, whose iOS headers import a private Google Mobile Ads SDK header. | Keep google_mobile_ads: 9.0.0 in app/pubspec.yaml, then run flutter pub get and cd ios && pod update google_mobile_ads Google-Mobile-Ads-SDK. |
pod install fails with "target has transitive dependencies that include statically linked binaries" (IronSource) | The LevelPlay plugin must be a static library because IronSource ships a static framework. | Keep the pre_install block of app/ios/Podfile that builds unity_levelplay_mediation as a static library. |
| The app does not install on an iOS 26 Simulator ("needs to be updated", no matching architecture) | The simulator build came out x86_64-only because Flutter read IronSource Ad Quality's arm64e exclusion as arm64. | Keep the post_install block of app/ios/Podfile that removes arm64e, run cd ios && pod install, then build again. |
02Ads and points
| Symptom | Cause | Fix |
|---|---|---|
| Users watch videos but get no points | The network's callback does not reach your function, its signature fails, or it does not match the session. | Follow Points not credited after a video. |
| Points arrive only after a delay | Networks send callbacks from their servers, usually within seconds, sometimes later. | Normal. The balance updates by itself when the callback is applied (the app listens to the user document live). |
| Only Google test ads appear | AdMob is selected and no unit ids are entered, so the app uses Google's test units. | Enter your unit ids in console → AdMob units. |
| No ads at all with Unity Ads or ironSource | That network's required ids are empty; the app shows nothing until they are set. | Fill them in; the console Status page lists what is missing. New ad units can take time before the network serves fill. |
| "You reached today's video limit. Come back tomorrow!" | adDailyCap reached for this UTC day (reason ad_cap), or the rewarded-interstitial daily cap (rewarded_interstitial_cap). | Expected. Change it in Rewards economy or Ad placements if you want. A cap of 0 means none (0 means none). |
| "Please wait a few seconds before the next video." | adCooldownSeconds (reason cooldown). | Expected. |
| Many fraud flags | Callbacks that do not match a pending session of the same user, or arrive after the session expired. | Review them in console → Fraud flags; for your own test views see Fraud flags on your own test views. A LevelPlay callback without the nonce is not flagged but recorded as ignored:no_nonce (note). |
Unity callbacks are refused (403 not configured) | The secret is missing; Unity only sends it after you e-mail their support. | Send the e-mail in Unity Ads S2S callbacks, then enter the secret as Unity S2S secret. |
| No rewarded interstitial is offered after games | The placement is off, AdMob has no rewarded-interstitial unit for that platform, the user is ad-free (VIP or remove-ads), or a cap or the first-session grace applies. | Check console → Ad placements and AdMob units. Only AdMob serves this format. |
| No native ads although LevelPlay is in the native waterfall | The app renders native ads with AdMob only; LevelPlay has no native unit field in the console. | Put admob in the native waterfall. |
| Status lists "skipped" waterfall lines | An unknown name, a repeat, or a network without that format. | Use admob, unity, ironsource, one per line (formats). |
Rewarded ads refused on an emulator (reason emulator_blocked, the app shows "This action isn't allowed.") | Anti-fraud → Refuse rewarded ads on emulators is on. It blocks only rewarded-ad and rewarded-interstitial sessions. | Expected. Test on a real device, or switch it off while developing. |
03Ad callbacks: points not credited after a video
Work backwards from the user. You need the user's uid (console → Users) and Firebase console → Firestore Database. These collections are server-only; the app cannot read or change them.
- Find the sessionOpen the collection
adSessionsand filter onuid= the user's uid. Take the newestcreatedAt. Readstatus,placement,verifiedByandtxId. No session at that time meansrequestAdSessionrefused (the app showed the limit, cooldown or emulator message) or the app could not reach Functions (The app does not start properly). - Status
verifiedThe callback was applied. OpenssvEvents/<verifiedBy>_<txId>and readoutcome:credited(withpointsandplacement),ignored:daily_caporignored:no_user_or_banned. A credited points video also has a row inusers/{uid}/ledgerwhoserefis the session id (the nonce). Placementspinorscratchgrants a spin or a card, not points. - Status
pendingorexpiredNo valid callback matched this session. Read the callback logs, from thefirebase/folder (or add--project YOUR_PROJECT_ID):
The same logs are in Firebase console → Functions → the function → Logs.firebase functions:log --only ssvAdmob,ssvUnity,ssvIronsource -n 200 - Match what you see to the tableThen fix the cause and watch one more video.
The session's network field is the network chosen in the console when the session opened. In waterfall mode the network that really served and called back is in verifiedBy and in ssvEvents.network.
| What you find | Cause | Fix |
|---|---|---|
Log line admob ssv, unity ssv or ironsource ssv with "status":"ignored" and "reason":"no nonce (console test callback?)"; ssvEvents outcome ignored:no_nonce | The callback arrived with a valid signature but without the nonce. Unity: sid is not uid:nonce. LevelPlay: the dynamic user id did not arrive as dynamicUserId. A test callback sent from a network dashboard also looks like this. | Unity: check the server id you send (setup). LevelPlay: check the S2S settings and the note. Nothing to fix for a dashboard test. |
Log line with "status":"flagged" and a reason | The callback did not fit its session. | Fraud flags on your own test views. |
Log line with "status":"duplicate" | This transaction id was already processed. | Nothing; the first callback decided. Its ssvEvents document has the outcome. |
ssvEvents outcome ignored:daily_cap | The user reached Rewarded videos per day (per user) (adDailyCap) before the callback arrived. | Expected. |
ssvEvents outcome ignored:no_user_or_banned | The user is banned or the profile is gone. | Expected. |
Error log ssvUnity: config/private.ssv.unitySecret is empty - set it in the operator console (or the same for ssvIronsource / ironsourcePrivateKey); the endpoint answers 403 not configured | The secret was never entered, the console could not write config/private, or ADMIN_SECRET_KEY changed and the console wrote the secret back as empty. | Enter the secret (how). Check console → Status → "App config written to Firestore". If the key changed: Encryption keys. |
| No log line from the callback function at that time | The network never called this URL, or it called and got 403 bad signature, which writes no log line of its own (only the request's 403 status in Cloud Logging). | Run the endpoint test and the self-test (Test the callbacks). Check the URL in the network dashboard: https, region, project id, function name. AdMob: the URL must be on the unit the app really used; with an empty unit id the app shows Google's test unit, which has no callback of yours. Then Signature verification fails. |
Log callback handler error; the endpoint answered 500 error | An exception in the function. For AdMob it is usually the download of Google's verifier keys (verifier keys HTTP …). | Read the error text. A key download failure is temporary. |
| The app said "Your points are being verified…" and the points came later | The callback took longer than the 30 seconds the app waits. | Normal. The balance updates by itself. |
| You changed a secret a moment ago and callbacks still fail | Cloud Functions cache the configuration for about 30 seconds per instance (CONFIG_CACHE_MS). | Wait a minute and try again. |
04Ad callbacks: fraud flags on your own test views
A callback with a valid signature that does not fit its session credits nothing, answers 200 (so the network stops retrying), writes a fraudFlags row and marks the user flagged. Console → Fraud flags shows When, uid, Reason and Detail. Detail holds the network, transaction id and nonce, plus sessionUid or sessionStatus where they apply.
| Reason | Typical cause while testing | Fix |
|---|---|---|
unknown_session | No adSessions document with this nonce in this Firebase project. The ad unit's callback URL points at another project than the app build (staging and production sharing ad units), or the app ran against the emulators (USE_EMULATOR=true) while the network called the deployed function. | One callback URL per project: separate ad units for staging. Test real callbacks only with a build that talks to the deployed project. |
uid_mismatch | The callback's user id is not the session's. LevelPlay: the user id is set once when the SDK starts, so after switching accounts in a running app the callback carries the previous account's uid. | LevelPlay: close and reopen the app after switching test accounts. |
session_expired | The callback arrived more than 15 minutes after the session opened. | Nothing to configure. Credit the tester by hand if you want (below). |
session_not_pending | The session was already used (sessionStatus verified: a second callback with another transaction id), or the hourly expireAdSessions job had expired it before a late callback (sessionStatus expired). | As above. |
- Clear the test flagsConsole → Fraud flags: type why it is fine and clear it. The user's
flaggedmark goes away when no other open flag remains. Clear them: a flagged user's offerwall conversions are held for review. - Give the points back if neededConsole → Users → the user → Adjust points with a reason (ledger type
admin).
A callback without a nonce is not a fraud flag: it is recorded as ignored:no_nonce (above).
05Ad callbacks: signature verification fails (403)
Start with the self-test in Test the callbacks. It signs a callback with the secret you hold: if the self-test passes but real callbacks fail, the network signs with another secret or the request is changed on the way; if the self-test fails, the console holds another secret than you think.
| Network | What the function checks | Common causes | Fix |
|---|---|---|---|
| AdMob | ECDSA (SHA-256) over the query string before &signature=, with the Google key named by key_id. An unknown key_id triggers a fresh download of Google's keys, at most once a minute. | A call made by hand or by a scanner (normal noise). Something between Google and the function rewrites the query string, such as a redirect or a proxy URL. | Point the AdMob URL straight at …cloudfunctions.net/ssvAdmob. There is no secret to fix. A log line admob ssv verify error means a malformed signature. |
| Unity Ads | hmac = HMAC-MD5(secret, all other parameters sorted, key=value joined by commas) | The secret was pasted with a space or line break, or it belongs to another organisation or game. | Paste it again. If the self-test passes and real callbacks still fail, ask Unity Ads support to confirm the secret for your Game IDs. |
| ironSource LevelPlay | signature = md5(timestamp + eventId + applicationUserId + rewards + private key) | The private key was changed in LevelPlay or in the console, not in both. | Set the same key in both places. |
| Unity, LevelPlay | A secret must exist | 403 not configured: config/private.ssv has no secret for this network. | See above. |
After you change a secret, wait about 30 seconds before testing again (the Functions config cache).
06Offerwalls, surveys and revenue
| Symptom | Cause | Fix |
|---|---|---|
| The offerwall card does not appear in the app | The provider is not enabled, or the console kept it hidden because the id, an https URL with {uid} or the secret is missing. | Console → Status → "Every enabled offerwall has its id, URL and secret" says what is missing. |
Postbacks answer 403 not configured | The provider's secret is empty in config/private. | Enter it in console → Offerwalls and surveys (owner only). |
Postbacks answer 403 bad signature | Wrong secret, or the URL in the provider dashboard differs from the one in these docs. | Paste the exact URL from Postback URLs. For CPX Research, compare the parameters with your dashboard: that scheme is not confirmed by a public page. |
| A conversion was received but no points arrived | It was held (above the hold amount, or the user is flagged), or it was a duplicate, unknown user, banned user or zero points. | Check console → Review queue, and the Functions log line offerwall conversion with its outcome. |
| I approved a hold but the user got nothing | The trigger onReviewDecision credits it, usually within a minute. If it never does, the Functions are not deployed or out of date. | firebase deploy --only functions,firestore . |
| Revenue centre: "Ad revenue: not measured" | No complete AdMob API connection. | Connect reporting. The first figures arrive after the next 12:00 UTC run. |
| AdMob reporting worked for a week, then stopped | The OAuth app is still in "Testing", so its refresh tokens expire after 7 days. | Publish the OAuth consent screen, create a new refresh token, enter it, press Test. |
| Store purchases listed as "price not reported" | Google Play: the Functions service account lacks "View financial data". | Grant it in Play Console → Users and permissions. |
A push campaign ends as failed | The error field of pushCampaigns/{id} in Firestore says "empty title", "team segment without teamId" (a team campaign with no team, e.g. written to Firestore by hand) or "unknown segment". | Schedule it again from console → Push campaigns; for a team, pick the team in the Team field (Push campaigns). |
07Cash wallet
| Symptom | Cause | Fix |
|---|---|---|
| The app shows no wallet; the tab says Rewards | The wallet is off, or off for this platform (iOS and web are off by default). | Console → Wallet & payouts: Cash wallet and payouts and Wallet on Android / iOS / web. Read Why iOS is off first. |
| Offerwall conversions still credit points | The reward mode is Points, or the wallet is off (the console then writes "cash" as "points" and the Status page says so). | Switch the wallet on first, then the reward mode to Cash in the wallet (Cash mode). |
A cash conversion credited nothing (outcome zero) | The provider's postback had no USD payout, or the user's share rounded down to 0. | Check the postback parameters in the provider dashboard (Postback URLs) and the user share. |
| A cash conversion is in the Review queue | It is above Hold cash offerwall credits above (cents), or the user is flagged. | Approve or reject it (holds). |
| Cash stays pending | The hold (wallet.holdDays, default 14) has not passed, or releaseWallet is not running. | Expected during the hold. After it, check Firebase console → Functions → releaseWallet logs (hourly) and that the index on entries (state, availableAt) is Enabled. |
| A user's available balance is negative | A provider reversed an offer after the cash was released or paid. | Expected. New earnings settle it; until then payouts are blocked (Reversals). |
| Status: "Cash wallet: at least one payout method and the legal pages" | The wallet is on without an enabled method, the terms or privacy URL is empty, or the wallet is on for iOS (a reminder to check the App Store rules). Crypto without a valid asset and gift cards with an empty catalog are written as off. | Enable a method with its details and fill in console → Legal. For iOS see Why iOS is off. |
08Payout requests
The app shows a translated message for each refusal reason (app/lib/ui/widgets/common.dart); the reason itself is in the Functions log of requestPayout.
| What the user sees | Reason | Fix |
|---|---|---|
| "This method isn't available in your country." | country: the method or gift card is limited to other countries, compared with the country the user picked in the payout sheet (it starts at the device region). | Check Only in these countries of the method and of each gift card. The user can change the country in the sheet if the device region is wrong. |
| "Please check the payout details." | bad_email, bad_asset, bad_address, missing_bank_field, bad_iban, bad_giftcard, bad_face_value, amount_mismatch | For crypto, check that your address pattern accepts real addresses of that network; for banks, that the field list fits the user's country. |
| "That's below the minimum for this method." | below_min: under the minimum, or the fee is as large as the amount. | Expected; or lower the minimum or the fee. |
| "That's more than your available cash." | insufficient_funds | Pending and locked cash cannot be requested. |
| "You reached a cash-out limit. Try again later." | daily_limit or monthly_limit (UTC day and month) | Expected; change the limits in Wallet & payouts (0 = no limit). |
| "Link a Google or Apple account before your first cash-out." | email_unverified: a guest account | The user links Google or Apple in the app. Or switch off the verified-account rule. |
| "Cash-outs are paused for this account." | frozen | Console → Wallets → Unfreeze, if appropriate. |
| "Your balance is negative because a partner reversed an offer." | negative_balance | Expected until new earnings settle it. |
| "That isn't available right now." | disabled, method_off, platform, account_age, no_profile | Check the wallet and method switches, the platform switch and Account age before the first payout. |
| "You reached a daily limit." | rate_limited: more than 3 requests in an hour | Expected; wait an hour. |
| "cannot mark a pending request paid: approve it first" | Mark paid works only on approved or processing requests. | Approve the request, then mark it paid. |
| "same_person: the person who approved this request cannot also send it or mark it paid" | The two-person rule (wallet.requireTwoPeople) is on and you approved this request. | Another team member sends it or marks it paid; or the owner switches the rule off if you work alone. |
| A request never got a status after Approve, Reject or Mark paid | The onWalletOp trigger did not run: 4.0 Functions not deployed, or an error. | firebase deploy --only functions; then Firebase console → Functions → onWalletOp logs. The page shows the op's error when there is one. |
| A rejected request did not refund the user | Same as above: refunds are applied by the backend. | Check the op's status on the Payouts page and the onWalletOp logs. |
09PayPal Payouts
| Symptom | Cause | Fix |
|---|---|---|
| The secret's Test button or the Status check fails | Wrong client id or secret, or sandbox credentials with Live selected (or the reverse). | Copy both from the same app and tab (Sandbox or Live) in developer.paypal.com → Apps & Credentials and match the environment. |
| "Set up PayPal Payouts first (Settings → PayPal Payouts)." | No client id or secret. | Enter them (owner), or pay by hand and mark paid. |
| "Only the owner sends money through PayPal Payouts." | A manager pressed Send via PayPal. | Expected: only the owner sends through the PayPal Payouts API. |
The request went back to approved with "Last PayPal error" | PayPal refused the send, for example an insufficient PayPal balance, Payouts not enabled on the account, or a currency your account cannot send. | Read the error, fix it in PayPal (add funds, ask PayPal to enable Payouts), then Retry PayPal send (owner). A retry never pays twice. |
Requests stay processing for a long time | The receiver has not claimed the money (UNCLAIMED), PayPal holds it (HELD), or the webhook does not arrive. | Wait: the 30-minute poll settles it; unclaimed money returns after 30 days and the request fails with a refund. Check the webhook below. |
| The webhook answers 401 | The webhook id in the console is not the id of this webhook, or it is a sandbox id in live mode. | Copy the id of the webhook you created for paypalWebhook in the same environment. |
| The webhook answers 503 | PayPal is not configured in config/private. | Enter the credentials and the webhook id (owner). |
| PayPal shows no webhook deliveries | Wrong URL or events not selected. | URL https://REGION-PROJECT.cloudfunctions.net/paypalWebhook, with the item events and PAYMENT.PAYOUTSBATCH.DENIED (setup). |
A paid PayPal request turned failed | PayPal reported RETURNED or REFUNDED after success. | Expected: the amount went back to the user's wallet and the Business page counts it back. |
10Gift cards
| Symptom | Cause | Fix |
|---|---|---|
| Users see no gift cards | The catalog is empty or every line was invalid (listed on the Status page), or no card is available in the user's country. | Fix the catalog lines: id|brand|face values in cents|countries|your cost. |
The user cannot reveal the code (not_paid) | The request is not marked paid yet. | Mark it paid with the code. |
A paid gift card's code does not open: the app says "That isn't available right now." (reason unavailable) or "Something went wrong. Please try again." (the revealGiftCode log shows a decryption error) | config/private.wallet.codeKey was removed (no key) or changed by hand (wrong key). After a removal, the next gift card you mark paid creates a new key, which cannot open the older codes. | Write the old value back into config/private.wallet.codeKey from your backup (Encryption keys). Without it those codes are lost; send the users new cards. |
11Operator console
| Symptom | Cause | Fix |
|---|---|---|
| Status warns "Season, mystery box and event cosmetics exist in the Rewards store" | A season tier, a cosmetic: mystery-box prize or an unsettled event prize names an id that is no avatar, frame or theme in the Rewards store. Users would get an item the app cannot draw. | Copy the id from the Id column of console → Rewards store (Season pass). |
| I lost the setup code | It is printed in the log on each start until an owner exists. | Restart the console and read the log again. |
| Status: "Firestore is reachable" fails | No credentials, wrong project or missing role. | See Firebase credentials. |
| Status: "App config written to Firestore" fails | The sync could not write config/public. | Fix Firestore access first; the console writes on the next save or start. |
| Ledger page or Overview counts show an index error | Firestore indexes are missing or still building. | firebase deploy --only firestore:indexes and wait until they are Enabled. |
Log [admin] secret "…" cannot be decrypted: was the secretKey changed?; callbacks answer 403 not configured | ADMIN_SECRET_KEY changed or was lost, and the console wrote the unreadable secrets to config/private as empty. | Restore the old key and restart. Without it, enter every secret again (Encryption keys). |
Log [admin] ADMIN_SECRET_KEY is not set: using a generated key in … | No key in the environment; the console made one in data/.admin-secret-key. | Move that value into the host's environment as ADMIN_SECRET_KEY and back it up. If data/ is lost, the key goes with it. |
The console does not start: createAdminKit: secretKey must be 32 bytes as 64 hex chars or base64. | ADMIN_SECRET_KEY has the wrong format. | Use 64 hex characters (openssl rand -hex 32) or base64 of 32 bytes. If secrets were saved with an earlier key, set that key back. |
| Settings changes do not reach the app | The app listens to config/public live, so a change normally arrives within seconds. If not, the console could not write it. | Check the console Status page ("App config written to Firestore") and the device's network. A network switch also needs the ad SDK to initialise again: reopen the app. |
| Everything answers 403 and a "Demo console" banner shows | ADMIN_DEMO_PASSWORD is set. | Remove it (and ADMIN_DATA) on your real console. |
12Operator console: Firebase credentials
The console reaches Firebase with the Admin SDK and reads only these variables for it (admin/src/firebase.ts, admin/src/kit.ts): GOOGLE_APPLICATION_CREDENTIALS, FIREBASE_PROJECT_ID (or GCLOUD_PROJECT / GOOGLE_CLOUD_PROJECT), FIRESTORE_EMULATOR_HOST and ADMIN_DATA. npm start and npm run start:dist load admin/.env; starting node or tsx yourself, or the Docker image, does not, so pass the variables from the host (docker run --env-file .env …).
- Read the start-up lineThe log says where the data comes from:
[admin] data: Firestore (service account),[admin] data: Firestore emulator …or[admin] data: built-in SAMPLE data (no Firebase). - Open console → Status"Firestore is reachable" and "App config written to Firestore" show the error text when they fail.
| Symptom | Cause | Fix |
|---|---|---|
Log [admin] config sync failed: The file at /path/key.json does not exist, or it is not a file. | GOOGLE_APPLICATION_CREDENTIALS points to a file that is not there: a relative path started from another folder, or a file that is not inside the container. | Use an absolute path. With Docker, mount the key read-only (for example -v /srv/keys/sa.json:/keys/sa.json:ro) and set the variable to the path inside the container. |
[admin] data: Firestore emulator … on your server | FIRESTORE_EMULATOR_HOST is set, often copied from a development .env. | Remove it from the server's environment and restart. |
[admin] data: built-in SAMPLE data (no Firebase) | ADMIN_DATA=sample. | Remove it (and ADMIN_DEMO_PASSWORD) on your real console. |
| Status "Firestore is reachable" fails with a permission or project error | The key belongs to another Firebase project, the project id is wrong, or on Cloud Run the attached service account lacks a role. | Generate the key in the same project (Firebase console → Project settings → Service accounts). Set FIREBASE_PROJECT_ID if the credentials do not name the project. On Cloud Run give the role listed in Deploy options. |
| Status "Firestore is reachable" fails with a credentials error outside Google Cloud | GOOGLE_APPLICATION_CREDENTIALS is empty. Only Cloud Run and GCE provide credentials by themselves. | Set it to the key file. |
| Firestore works but Send now push fails | On Cloud Run with narrow roles, the service account lacks Firebase Cloud Messaging Admin. | Add the role (Deploy options). |
13Operator console: database (SQLite or Postgres)
The console keeps its own data (team, sessions, settings, encrypted secrets, audit log) in one place: the SQLite file admin/data/admin.sqlite (in Docker /app/data), or Postgres when DATABASE_URL is set. On Postgres the tables are named mk_watchandearn_* and are created at the first start; Postgres 14 or newer is needed (the schema uses CREATE OR REPLACE TRIGGER). App users and points are never in this database: they are in Firestore.
| Symptom | Cause | Fix |
|---|---|---|
The console stops at start with a Postgres error, for example Error: connect ECONNREFUSED … | DATABASE_URL is wrong or the database cannot be reached. | Check host, port, user, password and database name. For a hosted database use its pooled URL with sslmode=require and allow connections from the console's host. |
| The console prints a setup code again and every setting is back to its default | It started with an empty database: data/ is not on a persistent disk (a redeploy wiped it), or DATABASE_URL was changed or removed. | Act fast: at every start the console writes its settings to Firestore, so the app now runs on default settings and the callbacks have no secrets. Restore data/ from your backup (or set the right DATABASE_URL) with the same ADMIN_SECRET_KEY and restart. Then put data/ on a persistent volume. |
| Random sign-outs ("Sign in required") or settings that change back | Several instances, each with its own SQLite file. | Run one instance, or set DATABASE_URL so all instances share Postgres. |
| "Someone saved in the meantime. Reload to see their changes, then apply yours again." | Another person or tab saved settings after you opened them. | Reload and apply your change again. |
Start fails while opening data/admin.sqlite | The folder is not writable for the user that runs the console. | Make admin/data/ writable. The Docker image runs as user node and owns /app/data. |
Move from SQLite to Postgres
Nothing copies the data for you. Do it at a quiet time.
- Export the settingsIn the old console, as the owner, open Export and download the export. It holds the settings, not the secrets, and wallet addresses in clear: keep it safe. Note your team members, and collect every secret from its source (list).
- Start on PostgresStop the old console, set
DATABASE_URL, keep the sameADMIN_SECRET_KEYand start. The tables are created and a setup code is printed. This first start writes default settings to Firestore until you import. - RestoreCreate the owner with the setup code, import the export, enter every secret again and add the team.
- CheckConsole → Status shows no blocker; run the callback self-tests (Test the callbacks).
14Operator console: sign-in and cookies behind a reverse proxy
What the console does (admin/packages/admin-kit/server/kit.ts):
- The session cookie
mk_adminisHttpOnly,SameSite=Strict,Path=/, has noDomain(this host name only) and lasts 12 hours. - It is marked
Secureonly when the request reached the console over https, or when the first value of theX-Forwarded-Protoheader ishttps. There is no "trust proxy" setting: the header is always honoured, so let only your proxy reach port 8790. - The paths are fixed:
/admin(console),/admin/api(its API),/wae-admin/(product pages and their API);/redirects to/admin. Serve the console at the root of its own host name, for exampleadmin.example.com; a path prefix such asexample.com/tools/is not supported. - Every API request that changes something must carry the header
x-mikodes-admin: 1(protection against cross-site requests). The console's own pages send it.
| Symptom | Cause | Fix |
|---|---|---|
| Sign-in succeeds, then the console asks you to sign in again (the API answers 401 "Sign in required") | The browser did not keep or send the cookie: the page is open over http:// while the proxy sends X-Forwarded-Proto: https (a Secure cookie is not stored over http); the proxy drops Set-Cookie or Cookie; several instances on separate SQLite files; or the console's pages and API are reached under different host names. | Open the console over https on one host name. Pass the cookie headers through. One instance or Postgres (above). |
| Over https the cookie has no Secure flag (browser developer tools → Application → Cookies) | The proxy ends TLS but does not send X-Forwarded-Proto: https. | Add the header in the proxy (example below). |
| Saving answers 403 "Missing x-mikodes-admin header" | The proxy or a firewall strips custom request headers. | Allow the x-mikodes-admin header. |
| Product pages (Users, Payouts, …) are blank or 404 | The proxy forwards only /admin. | Forward the whole host name. |
| "Too many failed attempts. Try again later." (429) | Five failed sign-ins for this e-mail within 15 minutes. | Wait; the answer's Retry-After header says how long. |
| The console's Sessions list shows the IP "unknown" | The proxy sends neither X-Forwarded-For nor X-Real-IP. | Add X-Forwarded-For. |
An nginx example (adjust the host name and certificate lines to your server):
server {
listen 443 ssl;
server_name admin.example.com;
# ssl_certificate and ssl_certificate_key lines here
location / {
proxy_pass http://127.0.0.1:8790;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
15Development
| Symptom | Cause | Fix |
|---|---|---|
| Emulators do not start | Java is missing or older than 21. | Install Java 21+; check java -version. |
| The app on the Android emulator cannot reach the Firebase emulators | Wrong host. | Leave EMULATOR_HOST empty (the app uses 10.0.2.2); on a real phone set it to your computer's IP. |
| The emulator says purchases or callbacks are refused | Real store and network calls do not work in the emulator. | Use devCompleteAdSession (automatic in emulator builds) and test purchases on store test builds. |