Disclosure: I build NexWall, the wallpaper API this script uses. The OS-specific "set wallpaper" code works with any image file, so feel free to point it at a different source.
Full source code: kodnextechnologies/nexwall-python (MIT licensed)
Want your desktop background to change every morning without installing yet another background app? This guide builds a single Python script that:
- Asks a wallpaper REST API for one random wallpaper (one request per day)
- Downloads it
- Fits it to your screen using a blurred-background "letterbox", because the source images are portrait
- Sets it as the wallpaper on Windows, macOS, or Linux (GNOME, KDE, or feh)
- Runs daily via Task Scheduler, launchd, or a systemd timer
The API
I'm using the NexWall free wallpaper API: 100 requests/day on the free tier, no credit card, and images up to 2160x3840. A daily changer uses one request per day, so the free tier is far more than enough.
Get a key at https://nexwall.kodnextech.com/developers/register, then:
pip install requests pillow
The call we need:
curl "https://nexwall.kodnextech.com/api/developer/v1/wallpapers?sort=random&per_page=1&type=image" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Accept: application/json"
It returns {"data": [{ "id": ..., "image_url": "...", "resolution": "2160x3840", "tags": "...", ... }], ...}.
A note on aspect ratio
NexWall is portrait-first (9:16, built for phones). On a 16:9 monitor, stretching a portrait image looks terrible and cropping it loses most of the picture. So the script does what phone galleries do: it scales the image to cover the screen, blurs that as a background, and centres the full image on top. It looks surprisingly good, especially with AMOLED and minimal styles. If you'd rather not, set FIT_MODE=cover to simply centre-crop.
The script
Save as nexwall_daily.py:
#!/usr/bin/env python3
"""
Daily wallpaper changer using the NexWall wallpaper REST API.
Works on Windows, macOS, and Linux (GNOME, KDE Plasma, or feh).
Env vars:
NEXWALL_API_KEY (required) your API key
NEXWALL_CATEGORY_ID (optional) numeric category id, see GET /categories
WALLPAPER_SIZE (optional) e.g. "2560x1440", default "1920x1080"
FIT_MODE (optional) "blur" (default) or "cover"
"""
import ctypes
import os
import platform
import subprocess
import sys
from pathlib import Path
import requests
from PIL import Image, ImageFilter
API_BASE = "https://nexwall.kodnextech.com/api/developer/v1"
OUT_DIR = Path.home() / ".nexwall"
KEEP_FILES = 7
def fetch_random_wallpaper(api_key: str, category_id: str | None) -> dict:
params = {"sort": "random", "per_page": 1, "type": "image"}
if category_id:
params["category_id"] = int(category_id)
r = requests.get(
f"{API_BASE}/wallpapers",
headers={"Authorization": f"Bearer {api_key}", "Accept": "application/json"},
params=params,
timeout=15,
)
if r.status_code == 401:
sys.exit("Invalid or missing API key (HTTP 401).")
if r.status_code == 429:
sys.exit(f"Rate limited. Retry after {r.headers.get('Retry-After', '?')} seconds.")
r.raise_for_status()
items = r.json().get("data", [])
if not items:
sys.exit("No wallpapers returned. Check the category id.")
return items[0]
def download(url: str, dest: Path) -> Path:
with requests.get(url, stream=True, timeout=60) as r:
r.raise_for_status()
with open(dest, "wb") as f:
for chunk in r.iter_content(chunk_size=1 << 16):
f.write(chunk)
return dest
def fit_to_screen(src: Path, dest: Path, size: tuple[int, int], mode: str) -> Path:
W, H = size
lanczos = Image.Resampling.LANCZOS
with Image.open(src) as im:
im = im.convert("RGB")
# "cover": scale so the image fills the screen, then centre-crop.
s = max(W / im.width, H / im.height)
cover = im.resize((round(im.width * s), round(im.height * s)), lanczos)
left, top = (cover.width - W) // 2, (cover.height - H) // 2
cover = cover.crop((left, top, left + W, top + H))
if mode == "cover":
result = cover
else:
# "blur": blurred cover as background + full image centred on top.
result = cover.filter(ImageFilter.GaussianBlur(40))
s = min(W / im.width, H / im.height)
fg = im.resize((round(im.width * s), round(im.height * s)), lanczos)
result.paste(fg, ((W - fg.width) // 2, (H - fg.height) // 2))
# JPEG: the most widely supported wallpaper format across desktops.
result.save(dest, "JPEG", quality=92)
return dest
def set_wallpaper(path: Path) -> None:
path = path.resolve()
system = platform.system()
if system == "Windows":
SPI_SETDESKWALLPAPER = 20
SPIF_UPDATEINIFILE_SENDCHANGE = 0x01 | 0x02
ok = ctypes.windll.user32.SystemParametersInfoW(
SPI_SETDESKWALLPAPER, 0, str(path), SPIF_UPDATEINIFILE_SENDCHANGE
)
if not ok:
raise OSError("SystemParametersInfoW failed")
elif system == "Darwin":
script = (
'tell application "System Events" to tell every desktop '
f'to set picture to POSIX file "{path}"'
)
subprocess.run(["osascript", "-e", script], check=True)
else: # Linux / BSD
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower()
uri = path.as_uri()
if any(d in desktop for d in ("gnome", "unity", "cinnamon", "budgie")):
base = ["gsettings", "set", "org.gnome.desktop.background"]
subprocess.run(base + ["picture-uri", uri], check=True)
# GNOME 42+ uses a separate key for dark mode; ignore if missing.
subprocess.run(base + ["picture-uri-dark", uri], check=False)
subprocess.run(base + ["picture-options", "zoom"], check=False)
elif "kde" in desktop:
subprocess.run(["plasma-apply-wallpaperimage", str(path)], check=True)
else:
subprocess.run(["feh", "--bg-fill", str(path)], check=True)
def cleanup(directory: Path, keep: int) -> None:
files = sorted(directory.glob("wallpaper-*.jpg"), key=lambda p: p.stat().st_mtime)
for old in files[:-keep]:
old.unlink(missing_ok=True)
def main() -> None:
api_key = os.environ.get("NEXWALL_API_KEY")
if not api_key:
sys.exit("Set NEXWALL_API_KEY first.")
w, h = (int(x) for x in os.environ.get("WALLPAPER_SIZE", "1920x1080").lower().split("x"))
mode = os.environ.get("FIT_MODE", "blur")
OUT_DIR.mkdir(parents=True, exist_ok=True)
wp = fetch_random_wallpaper(api_key, os.environ.get("NEXWALL_CATEGORY_ID"))
raw = download(wp["image_url"], OUT_DIR / "download.tmp")
# Unique filename per wallpaper: macOS caches by path and may not refresh otherwise.
final = fit_to_screen(raw, OUT_DIR / f"wallpaper-{wp['id']}.jpg", (w, h), mode)
raw.unlink(missing_ok=True)
set_wallpaper(final)
cleanup(OUT_DIR, KEEP_FILES)
print(f"Set wallpaper #{wp['id']} ({wp.get('resolution')}) tags: {wp.get('tags')}")
if __name__ == "__main__":
main()
(Requires Python 3.10+ for the str | None hints. Pillow wheels include WebP support, which matters because the API serves .webp files.)
Try it once by hand:
# macOS / Linux
export NEXWALL_API_KEY=YOUR_API_KEY
python3 nexwall_daily.py
# Windows (PowerShell)
$env:NEXWALL_API_KEY = "YOUR_API_KEY"
python nexwall_daily.py
Picking a category
List the categories your plan can access:
curl "https://nexwall.kodnextech.com/api/developer/v1/categories" \
-H "Authorization: Bearer YOUR_API_KEY" -H "Accept: application/json"
Pick an id (for example, the "AMOLED & Pure Black" category) and set NEXWALL_CATEGORY_ID to it. The free plan covers the non-premium categories.
Running it every day
Windows: Task Scheduler
Store the key as a persistent user environment variable, then create a daily task. Use pythonw.exe so no console window flashes:
setx NEXWALL_API_KEY "YOUR_API_KEY"
setx WALLPAPER_SIZE "2560x1440"
schtasks /Create /SC DAILY /ST 08:00 /TN "NexWall Daily Wallpaper" `
/TR "\"C:\Path\To\pythonw.exe\" \"C:\Path\To\nexwall_daily.py\""
(Open a new terminal after setx. It doesn't affect the current session.)
macOS: launchd
~/Library/LaunchAgents/com.example.nexwall.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.example.nexwall</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/Users/you/scripts/nexwall_daily.py</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>NEXWALL_API_KEY</key><string>YOUR_API_KEY</string>
<key>WALLPAPER_SIZE</key><string>2880x1800</string>
</dict>
<key>StartCalendarInterval</key>
<dict><key>Hour</key><integer>8</integer><key>Minute</key><integer>0</integer></dict>
<key>RunAtLoad</key><true/>
</dict>
</plist>
launchctl load ~/Library/LaunchAgents/com.example.nexwall.plist
The first run will ask you to allow the process to control "System Events". Approve it once. Use the Python interpreter that actually has requests and pillow installed (check with which python3).
Linux: systemd user timer
Use a user timer rather than cron. User services run inside your session and can reach the D-Bus session bus that gsettings needs, whereas plain cron jobs often can't.
~/.config/systemd/user/nexwall.service:
[Unit]
Description=NexWall daily wallpaper
[Service]
Type=oneshot
Environment=NEXWALL_API_KEY=YOUR_API_KEY
Environment=WALLPAPER_SIZE=1920x1080
ExecStart=/usr/bin/python3 %h/scripts/nexwall_daily.py
~/.config/systemd/user/nexwall.timer:
[Unit]
Description=Change wallpaper daily
[Timer]
OnCalendar=*-*-* 08:00:00
Persistent=true
[Install]
WantedBy=timers.target
systemctl --user daemon-reload
systemctl --user enable --now nexwall.timer
Persistent=true means that if your laptop was asleep at 08:00, the change happens the next time it wakes.
Ideas to extend it
-
Search instead of category: add
params["search"] = "neon"(2 to 100 characters, matched against tags). -
Multi-monitor: call
fit_to_screenonce per resolution and use your desktop's per-monitor setting. -
"Popular this week" mode: use
sort=popularwith a smallper_pageand pick one item locally. -
Quota awareness: every response includes
X-RateLimit-Remaining. Log it if you share the key across scripts.
Is the free tier really enough?
For this use case, yes: one request a day against a limit of 100. The paid plans (Pro $4.99/mo with 10,000 requests/day and premium categories; Ultra $10.99/mo with live MP4 wallpapers) are aimed at apps with many users, not personal scripts.
Links: Docs · OpenAPI spec · Free key
Disclosure: I build NexWall. If the script misbehaves on your desktop environment, drop a comment with your distro/DE and I'll help, and update the post.
Top comments (0)