FreqDroid supports two ways to deliver push notifications. Pick whichever fits your workflow.
| ntfy | FCM via push.freqdroid.com | |
|---|---|---|
| Setup | Self-host an ntfy server | Just register in the app |
| Battery | Foreground service or polling | Real push, no service or polling |
| Latency | Instant (Live) or up to interval | Instant |
| Platforms | Android + iOS | Android (iOS coming) |
| Encryption in transit | TLS to your server | TLS + per-device sealed-box ciphertext through Google FCM |
| Trading data leaves your network | Yes (to your ntfy) | Yes (to push.freqdroid.com → Google FCM, encrypted) |
Both transports use the same FreqTrade webhook config — only the URL changes — and the rich exit-notification callback at the bottom of this page works for both.
FreqTrade publishes messages to your ntfy server via webhooks. FreqDroid subscribes to the same topics and displays native OS notifications.
FreqTrade bot → POST → ntfy server → stream → FreqDroid → notification
You can use the public ntfy.sh server for testing, but for production use with trading data a self-hosted instance is strongly recommended.
docker run -p 80:80 -v /var/cache/ntfy:/var/cache/ntfy binwiederhier/ntfy serve
services:
ntfy:
image: binwiederhier/ntfy
command: serve
ports:
- "80:80"
volumes:
- ntfy-cache:/var/cache/ntfy
volumes:
ntfy-cache:
Put this behind a reverse proxy (nginx, Caddy, Traefik) with TLS so the URL is https://ntfy.example.com. HTTPS is required for notifications to work reliably on Android 9+ and iOS.
See the ntfy self-hosting docs for full configuration options.
FreqDroid derives a topic name for each bot automatically:
topic = "freqdroid-" + botName.lowercase()
.replace(' ', '-')
.filter { alphanumeric or '-' }
| Bot name in app | ntfy topic |
|---|---|
MyBot | freqdroid-mybot |
Binance BTC | freqdroid-binance-btc |
bot_1 | freqdroid-bot1 |
The Settings screen shows the computed topic as a hint.
In your FreqTrade config.json, add a webhook section that POSTs to your ntfy topic. Use "format": "raw" so FreqTrade sends plain text.
Important: Include
#{trade_id}in your messages. FreqDroid looks for#<number>in the notification text to enable tap-to-navigate — tapping opens the trade detail screen directly.
"webhook": {
"enabled": true,
"url": "https://ntfy.example.com/freqdroid-mybot",
"format": "raw",
"entry": {
"data": "Entry placed (#{trade_id})\n{direction} {pair} @ {open_rate:.6f} | stake {stake_amount:.2f} {stake_currency}"
},
"entry_fill": {
"data": "Trade opened (#{trade_id})\n{pair} @ {open_rate:.6f} | stake {stake_amount:.2f} {stake_currency}"
},
"entry_cancel": {
"data": "Entry cancelled (#{trade_id})\n{pair} entry order cancelled"
},
"exit": {
"data": "Exit placed (#{trade_id})\n{pair} @ {limit:.6f}"
},
"exit_fill": {
"data": "Trade closed (#{trade_id})\n{pair} | profit {profit_amount:.2f} {stake_currency} ({profit_ratio:.2%})"
},
"exit_cancel": {
"data": "Exit cancelled (#{trade_id})\n{pair} exit order cancelled"
},
"status": {
"data": "Status: {status}"
}
}
Restart FreqTrade after editing config.json. Test with:
curl -d "Test message (#1)" https://ntfy.example.com/freqdroid-mybot
https://ntfy.example.com) — no trailing slash, no topic name.iOS uses BGAppRefreshTask to poll ntfy in the background at the configured interval. iOS controls exact timing — the interval is a minimum, not a guarantee. The app also polls when it comes to the foreground.
FreqTrade posts to a single endpoint at push.freqdroid.com. The relay encrypts each payload with your device’s public key (NaCl sealed box, X25519 + XSalsa20-Poly1305) and forwards it to Google FCM. Your device decrypts and displays the notification — Google sees only ciphertext, and the relay never logs payloads.
FreqTrade → POST → push.freqdroid.com → sealed box → FCM → device
Android only at the moment. iOS support arrives with the App Store release.

The URL is of the form:
https://push.freqdroid.com/v1/push/<deviceToken>
Treat the URL as a shared secret — anyone who knows it can post notifications to your device (they cannot read any of your trade data, only flood your notifications). Tap Unregister any time to invalidate it; tap Register & Save afterwards to get a fresh URL.
Use the same webhook block as ntfy, but with format: json and bot_name included so the in-app notification list can group by bot. Send the FreqTrade fields you care about as JSON:
"webhook": {
"enabled": true,
"url": "https://push.freqdroid.com/v1/push/PASTE_YOUR_DEVICE_TOKEN_HERE",
"format": "json",
"entry": {
"bot_name": "MyBot",
"type": "entry",
"status": "Entry placed (#{trade_id})\n{direction} {pair} @ {open_rate:.6f} | stake {stake_amount:.2f} {stake_currency}"
},
"entry_fill": {
"bot_name": "MyBot",
"type": "entry",
"status": "Trade opened (#{trade_id})\n{pair} @ {open_rate:.6f} | stake {stake_amount:.2f} {stake_currency}"
},
"entry_cancel": {
"bot_name": "MyBot",
"type": "entry_cancel",
"status": "Entry cancelled (#{trade_id})\n{pair} entry order cancelled"
},
"exit": {
"bot_name": "MyBot",
"type": "exit",
"status": "Exit placed (#{trade_id})\n{pair} @ {limit:.6f}"
},
"exit_fill": {
"bot_name": "MyBot",
"type": "exit",
"status": "Trade closed (#{trade_id})\n{pair} | profit {profit_amount:.2f} {stake_currency} ({profit_ratio:.2%})"
},
"exit_cancel": {
"bot_name": "MyBot",
"type": "exit_cancel",
"status": "Exit cancelled (#{trade_id})\n{pair} exit order cancelled"
},
"status": {
"bot_name": "MyBot",
"type": "status",
"status": "Status: {status}"
}
}
bot_namefield — with ntfy the per-bot topic identifies which bot fired. With FCM all of your bots post to the same URL, so includebot_name(orname) in the JSON if you want the bot name shown in the FreqDroid in-app list. The notification system tray title is taken fromtitle/type; the body fromstatus/message.
Trade ID for tap-to-navigate — same as ntfy: include
(#{trade_id})instatusfor tap-to-navigate.
Restart FreqTrade after editing config.json. Verify the path with curl using your copied URL:
curl -X POST "https://push.freqdroid.com/v1/push/<deviceToken>" \
-H "Content-Type: application/json" \
-d '{"bot_name":"Test","type":"entry","status":"Test entry (#1) BTC/USDT"}'
A notification should appear on the device within ~2 seconds. The relay returns {"status":"ok"} once it has handed the message to FCM.
Tap Unregister in the FCM section, then switch the transport segment to ntfy. The FCM URL becomes invalid the moment you unregister; FreqTrade webhooks pointed at it will get {"detail":"unknown device token"}. Update them to the new ntfy URL.
For richer exit messages — with profit-based emoji, partial-exit handling, trade duration, and exit reason — replace the built-in exit_fill webhook with a custom notification sent from your strategy’s order_filled callback. The same Python works for both ntfy and FCM; only the wrapping changes (raw text vs JSON).
Webhook config (no exit_fill):
"webhook": {
"enabled": true,
"url": "https://ntfy.example.com/freqdroid-mybot",
"format": "raw",
"retries": 3,
"retry_delay": 0.2,
"allow_custom_messages": true,
"entry": {
"data": "Entry placed (#{trade_id})\n{direction} {pair} @ {open_rate:.6f} | stake {stake_amount:.2f} {stake_currency}"
},
"entry_fill": {
"data": "Trade opened (#{trade_id})\n{pair} @ {open_rate:.6f} | stake {stake_amount:.2f} {stake_currency}"
},
"entry_cancel": {
"data": "Entry cancelled (#{trade_id})\n{pair} entry order cancelled"
},
"exit": {
"data": "Exit placed (#{trade_id})\n{pair} @ {limit:.6f}"
},
"exit_cancel": {
"data": "Exit cancelled (#{trade_id})\n{pair} exit order cancelled"
},
"status": {
"data": "Status: {status}"
}
}
For FCM, switch "format": "raw" to "format": "json", change the URL to your https://push.freqdroid.com/v1/push/<deviceToken> and replace each "data": "..." with the JSON fields shown in the FCM section (bot_name, type, status).
Strategy callback:
from freqtrade.persistence import Trade, Order
def order_filled(self, pair, trade: Trade, order: Order,
current_time, **kwargs):
if order.side != trade.exit_side:
return
bot_name = self.config.get('bot_name', 'Bot')
direction = "Short" if trade.is_short else "Long"
leverage_text = (f" ({trade.leverage:.3g}x)"
if trade.leverage and trade.leverage != 1.0 else "")
enter_tag = trade.enter_tag or ""
coin = trade.stake_currency or "USDC"
# Emoji based on profit tier
def emoji(r):
if r >= 0.05: return "\U0001f911" # 5%+
elif r >= 0.02: return "\U0001f680" # 2-5%
elif r >= 0: return "✳️" # 0-2%
elif r >= -0.02: return "⚠️" # 0 to -2%
else: return "\U0001f534" # worse than -2%
if trade.is_open:
# Partial exit (DCA out / scale-down)
# Calculate sub-profit for just this exit order (not the whole position)
sub_profit = trade.calculate_profit(order.safe_price, order.safe_filled, trade.open_rate)
ratio = sub_profit.profit_ratio
amt = sub_profit.profit_abs
gain = "profit" if amt >= 0 else "loss"
cumulative = trade.realized_profit or 0
msg = (f"{emoji(ratio)} *{bot_name}:* Partially exited "
f"{trade.pair} (#{trade.id})\n"
f"*Sub Profit:* `{ratio:.2%} ({gain}: {amt:.2f} {coin}"
f" / {amt:.2f} USD)`\n")
if cumulative:
msg += (f"*Cumulative Profit:* `{cumulative:.2f} {coin}"
f" / {cumulative:.2f} USD`\n")
if enter_tag:
msg += f"*Enter Tag:* `{enter_tag}`\n"
msg += (f"*Exit Reason:* `{trade.exit_reason or 'unknown'}`\n"
f"*Direction:* `{direction}{leverage_text}`\n"
f"*Amount:* `{trade.amount}`\n"
f"*Open Rate:* `{trade.open_rate:.4f} {coin}`\n"
f"*Exit Rate:* `{order.safe_price:.4f} {coin}`\n"
f"*Remaining:* `{trade.stake_amount:.2f} {coin}`")
else:
# Final exit — trade fully closed
exit_reason = trade.exit_reason or "unknown"
dur = (trade.close_date or current_time) - trade.open_date
secs = int(dur.total_seconds())
dur_str = f"{secs // 86400}d " * (secs >= 86400)
dur_str += f"{(secs % 86400) // 3600}h {(secs % 3600) // 60}m"
entry_count = trade.nr_of_successful_entries or 1
exit_count = trade.nr_of_successful_exits or 1
is_sub = (entry_count > 1 or exit_count > 1) and trade.realized_profit
if is_sub:
# Calculate sub-profit for just this final exit order
sub_profit = trade.calculate_profit(order.safe_price, order.safe_filled, trade.open_rate)
ratio = sub_profit.profit_ratio
amt = sub_profit.profit_abs
else:
ratio = trade.close_profit or 0
amt = trade.close_profit_abs or 0
gain = "profit" if amt >= 0 else "loss"
prefix = "Sub " if is_sub else ""
msg = (f"{emoji(ratio)} *{bot_name}:* Exited "
f"{trade.pair} (#{trade.id})\n"
f"*{prefix}Profit:* `{ratio:.2%} ({gain}: {amt:.2f} {coin}"
f" / {amt:.2f} USD)`\n")
if is_sub:
final_ratio = trade.close_profit or 0
final_amt = trade.realized_profit or 0
msg += (f"*Final Profit:* `{final_ratio:.2%}"
f" ({final_amt:.2f} {coin} / {final_amt:.2f} USD)`\n")
if enter_tag:
msg += f"*Enter Tag:* `{enter_tag}`\n"
msg += (f"*Exit Reason:* `{exit_reason}`\n"
f"*Direction:* `{direction}{leverage_text}`\n"
f"*Amount:* `{trade.amount}`\n"
f"*Open Rate:* `{trade.open_rate:.4f} {coin}`\n"
f"*Exit Rate:* `{order.safe_price:.4f} {coin}`\n"
f"*Duration:* `{dur_str}`")
self.dp.send_msg(msg)
Include
(#{trade.id})in the message body so FreqDroid can detect the trade ID for tap-to-navigate.
No notifications arriving
POST https://ntfy.example.com/freqdroid-<botname>Notifications stop after a while (Live mode)
The app maintains a long-lived HTTP streaming connection. If the server closes the connection, the app automatically reconnects with exponential back-off (2 s up to 60 s). No action needed.
ntfy requires authentication
Enter your credentials in the Username and Password fields below the server URL. The app sends a Basic auth header when both fields are non-empty. ntfy access tokens are not supported — use a dedicated ntfy user with a password.
curl returns {"status":"ok"} but no notification appears
The relay successfully forwarded to FCM. Most common cause on Android: an OEM battery manager blocked the background service. Try the curl test with the screen ON first; if it works that way, add FreqDroid to your phone’s battery whitelist or “auto-launch” list (Settings → Apps → FreqDroid → Battery → Unrestricted).
{"detail":"unknown device token"}
The URL is stale (you re-registered, generating a new token). Open the app, copy the current URL from Settings → Notification Server.
Status reads “Failed”
Usually a network issue contacting push.freqdroid.com. Tap the button again to retry.
Tapping a notification doesn’t open the trade
The notification text must contain #<trade_id> (e.g. (#123)). Make sure your webhook templates include #{trade_id} — see the webhook examples above.