DEV Community

kevindev
kevindev

Posted on

Keep Email Review APIs Explainable

When a signup flow flags an address for review, the logic is rarely the hardest part. The mess usually starts one layer later, when the API, the worker, and support tooling all tell slightly different stories about what happened. That gap gets wider once you handle edge cases like temp mail domains, manual overrides, and retries from downstream systems.

I have seen teams spend more time explaining review outcomes than improving the rule set itself. One response says "pending", another says "blocked", and the worker logs show a timeout from ten minutes ago that maybe still matters, maybe not. At that point the system is technically running, but it is not very explainable, and engineers end up doing detective work at 2 AM. Not fun, honestly.

The failure mode is usually not the rule itself

Most review pipelines begin with a sensible design:

  • the REST API accepts signup input
  • a review row is created when the risk score crosses a threshold
  • a worker checks the evidence
  • the final decision is written back for Authentication

That looks clean on a whiteboard. In production, though, teams often bolt on a few "temporary" shortcuts:

  • support agents can manually change a row
  • retries update the same status field without recording why
  • dashboard queries compress several states into one label

Now a case involving a tem email pattern or a suspicious alias can move through three owners without one durable explanation. This is also where frontend teams start needing cleaner contracts so they can cancel stale email checks upstream instead of rendering whatever late response arrives last.

Model the review as state plus evidence

The design that has aged best for me is boring on purpose: one table for the latest state, another for append-only evidence.

create table signup_email_reviews (
  id bigserial primary key,
  user_id bigint not null,
  email_address text not null,
  risk_reason text not null,
  status text not null check (status in ('pending', 'claimed', 'approved', 'blocked')),
  available_at timestamptz not null default now(),
  claimed_by text,
  claimed_at timestamptz,
  decision_version integer not null default 1,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create table signup_email_review_events (
  id bigserial primary key,
  review_id bigint not null references signup_email_reviews(id),
  event_type text not null,
  payload jsonb not null default '{}'::jsonb,
  created_at timestamptz not null default now()
);
Enter fullscreen mode Exit fullscreen mode

This split matters because state answers "what is true now?" while events answer "how did we get here?" The first question powers the product. The second one saves your backend team when a rule changes on Friday and nobody rembers which worker version handled the first wave of decisions.

It also keeps you from overloading one column with vague meanings. I really dislike status values like done or processed. They age badly. blocked and approved mean something. claimed means ownership is active. Those are much easier to reason about when somebody asks why one temp mailid case never reached a final decision.

Keep claiming logic tiny and explicit

Explainability usually improves when the claim step is very small. Claim one row, mark the owner, move on.

with next_review as (
  select id
  from signup_email_reviews
  where status = 'pending'
    and available_at <= now()
  order by created_at
  for update skip locked
  limit 1
)
update signup_email_reviews r
set status = 'claimed',
    claimed_by = $1,
    claimed_at = now(),
    updated_at = now()
from next_review
where r.id = next_review.id
returning r.id, r.user_id, r.email_address, r.risk_reason, r.decision_version;
Enter fullscreen mode Exit fullscreen mode

for update skip locked is helpful here because it gives each worker a narrow ownership window without making the whole queue serialized. The important part is what you do next: evaluate the review, append an event, then finalize the decision. Do not claim the row and mutate user access in the same oversized transaction. That pattern works untill a retry or partial failure leaves you with a half-explained state.

I also like adding explicit event types such as:

  • review_created
  • worker_claimed
  • evidence_loaded
  • decision_written
  • decision_requeued

Those names read well in logs, in SQL, and in incident notes. They also pair nicely with receipt logs for email test flows, because the same idea applies outside production traffic: keep one readable trail per run.

Make the API answer what happened

The API should not force callers to infer review progress from one opaque string. A small response contract can remove a lot of confusion:

{
  "review_id": 48192,
  "status": "claimed",
  "decision_version": 3,
  "updated_at": "2026-09-03T23:22:20Z",
  "next_action": "poll",
  "reason_code": "domain_on_watchlist"
}
Enter fullscreen mode Exit fullscreen mode

That does not expose private scoring internals, but it does tell other services what they can safely do next. If the API returns claimed, the caller waits. If it returns blocked, the account service can apply the final rule. If it returns approved, the signup flow can continue without guessing whether the worker is still thinking.

One more thing that helps: keep policy versioning visible. Rules change. Vendor signals change. Your risk appetite changes after abuse spikes. If the review record does not include decision_version, every historical discussion becomes fuzzy, and fuzzy backend systems are expensive to maintain.

Q&A

Should every risky email go through a queue?

No. Reserve the queue for ambiguous or operationally important cases. Clear accepts and clear rejects should stay synchronous when they can, otherwise you make the happy path slower for no real gain.

Is PostgreSQL enough for this?

Usually, yes. If your team already operates PostgreSQL well, a relational review queue is often simpler than introducing more moving parts too early. Add more infra when a measured bottleneck shows up, not because the architecture diagram feels cooler.

What is the smallest useful audit trail?

At minimum: review id, worker id, risk reason, final decision, policy version, and timestamps for claim and completion. If you cannot answer "who owned this review and why did it stop here?" from stored data, the design still needs work.

Explainable review APIs do not make abuse decisions perfect. They do make them maintainable. For backend systems that sit between signup intent and Authentication policy, that is a pretty big win, even if it looks a bit plain from the outside.

Top comments (0)