← All posts
Release notes · 6 min read · 2026-04-22

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

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:

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:

  1. 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.
  2. Horizontally scalable: every server instance accepts the same tokens as long as they share the JWT secret. No cache coherence worries.
  3. 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:

  1. Docker Compose — one command, Postgres included, 5 minutes.
  2. VPS + Caddy + daily pg_dump backups — production-grade, public TLS, off-host backups. Target machine: $12/mo.
  3. 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