Miru tells you when a new episode of an anime you are watching comes out. It sounds like a small feature, but it combines three things that rarely get along: an external API, a scheduled job and sending notifications through systems like APNs and FCM. Here is how I built it with Supabase and what I had to fix along the way.

The architecture

There is no server of mine to keep running:

  1. pg_cron, inside Supabase’s Postgres database, launches a job every two hours (0 */2 * * *).
  2. The job calls, via pg_net, an Edge Function (Deno) named notify-episodes.
  3. The function reads who has notifications on, asks AniList when the next episode of each ongoing series airs and sends pushes with a shared module for APNs (iOS) and FCM (Android).

What the function does

In order:

  • Tokens and preferences. It reads the push tokens, excludes people who turned episode notifications off and remembers each person’s language, to write the text in Italian or English.
  • Ongoing series. It takes the entries in “watching” status for the remaining users.
  • AniList in batches. It asks for the next episode with a GraphQL query, in groups of 50 identifiers; if one batch fails the others carry on.
  • 24-hour window. It notifies only if the episode airs within the next 24 hours.
  • No duplicates. Each entry has a notified_ep column with the last episode already notified: it only sends if the episode is newer.
  • Update after success. notified_ep is written only when the send returns ok; if the token turns out to be invalid I delete it.

The core of the deduplication is three lines:

if (!airing) continue                                    // no episode scheduled
if (airing.airingAt > horizon) continue                  // too far away (over 24 hours)
if (airing.episode <= (entry.notified_ep ?? 0)) continue // already notified

The text changes with the time left: “just came out”, “out in a few minutes”, “out in N hours”, in the user’s language.

First problem: anyone could call the function

The cron called the function with Supabase’s anon key, which by design is public. So anyone who knew it could invoke notify-episodes whenever they wanted.

I added a shared secret: the function answers 401 to requests that do not carry the secret in a dedicated header, and the cron was rescheduled to send it.

The value lives in the function’s secrets, not in the code: the migration that reschedules the cron, in the repository, holds only a placeholder, and I insert the real value when I run it, so it never ends up in git.

Second problem: trusting what the client sends

notify-friend sends the “someone wants to add you” notification. It received a receiver_id from the client and sent the push to that person without checking anything else: a client could make notifications reach any user whose identifier it knew.

The fix: before sending, the function checks that a pending friend request really exists between sender and receiver, otherwise it answers 403.

The same check applies to recommendations. It is the classic IDOR problem: the server must not trust an identifier just because it comes from the client.

A detail that costs a lot: timeouts

Calls to APNs and FCM have a 10-second timeout with AbortController. Without it, a hung call blocks the whole function while there are still notifications to send.

What I learned

  • A public key identifies the app, it does not authorize anyone: if a function does something sensitive, it needs a real secret.
  • Deduplication must be written after a successful send, not before.
  • Every identifier that comes from the client must be checked against the state the server knows.