Launch week: webhooks, 2FA, password reset
Four days after the public beta went live, we shipped five features that turn DroidFleet from "useful mobile dev tool" to "real SaaS that a team can commit to."
The beta had a gap between what we advertised on the pricing page and what actually existed in the app. "Webhooks" was listed under Pro. The dispatcher existed in the codebase. The CRUD surface did not. That's the worst kind of feature — the kind that gets your user all the way to "let me connect this to our Slack" before discovering it's vaporware.
So this week we closed those gaps. Here's what shipped.
Webhooks — actually shippable now
Pro users can now register outgoing webhooks from the account page. Every meaningful event on the server fires to your URL, signed with HMAC-SHA256, with automatic retry and exponential backoff.
The events
- install.started / install.completed / install.failed — an APK landed on a phone (or didn't).
- phone.paired / phone.online / phone.offline — fleet state changes.
- crash.recorded — a new crash hit your crash grouper. First-seen only; dedup happens server-side.
- cohort.created — A/B rollout tagged a new build.
- billing.upgraded / billing.cancelled — for teams that want to self-serve their internal reporting.
Signature verification
On every POST we send an X-DroidFleet-Signature header of the form sha256=<hex>. Your receiver computes the same HMAC over the raw body and rejects anything that doesn't match. Node.js example:
import { createHmac, timingSafeEqual } from "node:crypto";
app.post("/webhooks/droidfleet", (req, res) => {
const sig = req.headers["x-droidfleet-signature"];
const expected = "sha256=" + createHmac("sha256", SECRET)
.update(req.rawBody) // raw body — BEFORE JSON parsing
.digest("hex");
const a = Buffer.from(sig), b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(401).end();
}
// Safe to parse + act on
const { event, payload } = JSON.parse(req.rawBody);
// ...
});
Why raw body? Because a JSON re-encode will almost certainly differ from what we signed (whitespace, key ordering, string escape). Keep the bytes as they came in until after you verify.
Delivery guarantees
At-least-once, with a backoff schedule of 0s → 5s → 30s. If your endpoint is down for more than ~35 seconds, the delivery is dropped — but your queue-full alert will tell you the next time a burst of events comes in and we can't fit them all. Every delivery carries a unique X-DroidFleet-Attempt header (1, 2, 3) so you can dedupe on the deliveryId field if your handler isn't idempotent.
A dashboard-side "Test" button fires a synthetic webhook.test event straight at your endpoint and records the HTTP status, so you can confirm your receiver is alive before waiting for real traffic.
2FA — TOTP with backup codes
Account settings now has a Two-factor authentication card. Scan the QR with any authenticator app — we tested Google Authenticator, 1Password, Authy, Bitwarden, and Aegis — and your next login asks for the 6-digit code.
Two details that matter:
- Backup codes: enrolling generates 10 single-use backup codes. Save them somewhere that isn't your phone. Each one works exactly once in place of the authenticator code, after which it's wiped from the DB. No-phone recovery without losing your account.
- Disable requires password: removing 2FA isn't a one-click action from an authenticated session — you have to re-enter your password. A stolen session token (e.g. phished, or stolen via a browser extension) can't unilaterally turn off your second factor.
Recommended for anyone on the APPPROOF_ADMIN_EMAILS allow-list — admins can change roles and revoke API keys across every account on the instance. The self-hosting guide calls this out explicitly.
Password reset — stateless, one-use, 1-hour TTL
Forgot-password link on the sign-in page. Enter your email, get a message regardless of whether the account exists (no user enumeration), and if it does exist a link lands in your inbox within a few seconds.
Under the hood the reset token is a signed HMAC blob whose payload includes your user id, the issuance timestamp, and a 12-char fingerprint of your current password hash. On reset we recompute the fingerprint from the DB row — if it doesn't match, the token is rejected. This gives us three things for free:
- Single-use: the moment a reset completes, the password hash changes → the fingerprint changes → any other in-flight reset link for the same account becomes invalid. No used_at column to maintain.
- Horizontally scalable: every server instance accepts the same tokens as long as they share the JWT secret. No cache coherence worries.
- No migration needed: we didn't have to add a PasswordResetToken table. Deploying was pushing code, not touching schema.
The full treatment — Hebrew-first UI, all-devices-logged-out-on-reset treatment, rate-limited endpoint — is live at api.droidfleet.dev/ui/account.html.
Rate limits on /auth/*
Before this week: the global rate limit of 60 req/min/IP covered the login endpoint too. bcrypt is intentionally slow, so a botnet hitting a distributed stuffing attack could burn ~4 password guesses per second against a specific account — and pin a CPU core while doing it.
Now: /auth/* shares a 15 req/min/IP bucket, applied before handleAuthRoute even runs. Signup + login + refresh + forgot-password + reset-password all contribute to the same cap. Legitimate humans don't come close (2-3 hits per minute at the worst); anything automated gets 429s inside 15 tries and has to wait.
Self-hosting guide
If running your fleet telemetry through a third-party SaaS isn't something your org is comfortable with, the new SELF-HOSTING.md walks through three deployment modes:
- Docker Compose — one command, Postgres included, 5 minutes.
- VPS + Caddy + daily pg_dump backups — production-grade, public TLS, off-host backups. Target machine: $12/mo.
- Kubernetes — sketch of a values.yaml plus the NGINX-Ingress gotchas (SSE buffering, WebSocket upgrade) we've actually hit.
The compose file is now Postgres-first — same postgres:16-alpine the SaaS runs on, so dumps round-trip cleanly if you ever move between self-hosted and cloud.
What's next
Short-term: Google Play Store submission (keystore + listing assets in flight), a proper Helm chart, and a direct-LAN mode that skips the relay when your phones are on the same network as your server.
Medium-term: screenshot-regression diff improvements, per-cohort dashboards, and the SSO on Team plans (SAML + Google Workspace).
As always, the roadmap is open: github.com/Ilya0527/phone-runtime-inspector. File an issue if something's broken or missing. Every release note lives here.
— the DroidFleet team