Portreeve
how-to · 6 Sept 202611 min read

Handle a fraud review webhook and revoke an account

A fraud review webhook denies an account: verify the signature over the raw body, find the user, revoke idempotently, and answer 200 before the retry.

A review verdict is a promise you have to keep. The signup went through, the person is inside your product, and something behind them is still deciding. When that decision comes back a deny, it arrives as a fraud review webhook, and you have to revoke the account without wrecking the ones that get cleared.

By the end of this you will have an Express route that verifies a signed payload against the raw body, resolves it to a user in your database, revokes idempotently, and returns 200 fast enough that the sender stops retrying. The example event is review.resolved, but nothing here is specific to one provider. Stripe, GitHub, Svix and your own internal webhooks all want the same four things.

Before you start

You need Node 18.17+, an Express app you can add a route to, a webhook signing secret in the environment, and a users table you can write to. The code uses the portreeve SDK for signature verification and the feedback call; if you are wiring a different provider, swap verifyWebhook for theirs and the rest stands.

You also need a decision you have already made and written down: what "revoked" means in your product. Read-only? Logged out? Data retained for 30 days? Do that before you write the handler, because the handler is where you find out you never defined it.

Step 1: verify the signature before you parse anything

Signatures are computed over the exact bytes that were sent. If a JSON parser has already read the stream, re-serialized the object and handed you a different arrangement of whitespace, your HMAC is over the wrong string and every delivery fails verification for reasons that look like a secrets problem.

So the raw-body middleware goes on this one route, and the global JSON parser is registered after it. Express runs middleware in registration order, and this ordering is the single most common reason a webhook endpoint rejects everything.

import express from "express";
import { Portreeve } from "portreeve";

const app = express();

app.post(
  "/webhooks/portreeve",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    let event = null;
    try {
      event = Portreeve.verifyWebhook(
        req.body, // Buffer, untouched
        req.get("portreeve-signature"),
        process.env.PORTREEVE_WEBHOOK_SECRET!,
      );
    } catch {
      event = null;
    }
    if (!event) return res.status(400).send("bad signature");
    // ... handled in step 2
  },
);

app.use(express.json()); // every other route, registered after

The header is Portreeve-Signature: sha256=<hex>, an HMAC-SHA256 of the raw body under your signing secret. Anything that does not verify gets a 400 and goes no further into the handler.

If you are hand-rolling this for another provider, compare in constant time. GitHub's guidance is blunt about it: "Never use a plain == operator", because a byte-wise comparison that returns early leaks how many leading bytes were right. Use crypto.timingSafeEqual, and wrap it, because it throws a RangeError when the two buffers differ in length rather than returning false. Someone who sends a two-byte signature should get a 400, not a 500.

Curl the endpoint with garbage before you go further. You want that 400 in your own terminal, not in a support thread three weeks from now.

Step 2: branch on the resolution, then find the human

A review.resolved event carries event.resolution, which is "approved" or "denied", plus event.event_id, event.external_user_id and event.email. Both resolutions matter. The deny is the one that does work; the approve is the one that keeps your flag rate honest.

if (event.type !== "review.resolved") return res.sendStatus(200);

if (event.resolution === "approved") {
  await clearHold(event.external_user_id);
  return res.sendStatus(200);
}
// denied: steps 3 and 4

Resolve the user by external_user_id first. That is the id you sent at screening time, it is yours, and it cannot drift. Fall back to event.email only when the id misses, and treat a fallback hit as a bug worth logging: it means the id you screened with is not the id you store, and every other part of this pipeline is quietly mismatched too.

async function findUser(event) {
  const byId = await db.user.findUnique({ where: { id: event.external_user_id } });
  if (byId) return byId;
  const byEmail = event.email
    ? await db.user.findFirst({ where: { email: event.email.toLowerCase() } })
    : null;
  if (byEmail) log.warn("webhook resolved by email fallback", { id: event.external_user_id });
  return byEmail;
}

This deny path exists because of how a Portreeve review verdict works. It never blocks the person: the signup or checkout proceeds, the event lands in a queue in the dashboard, and the later deny reaches your server as this signed webhook so you can revoke. That split is what lets the engine flag aggressively without a false positive costing a real customer their afternoon, and it is why block stays reserved for high-confidence signals. Handling verdicts covers where each one sits in the flow.

No user found is not a no-op. Return 200 so the sender stops retrying, then alert. A resolution you cannot map to an account is a broken identifier path, and it will stay broken in silence.

Step 3: make the revoke idempotent

Deliveries retry until they get a 2xx. Your handler will run twice. Write it so the second run is free.

Change the row only if it is not already in the target state, and let the database tell you whether you were the one who changed it.

async function markRevoked(event) {
  const user = await findUser(event);
  if (!user) return alertUnmapped(event); // returns null

  const { count } = await db.user.updateMany({
    where: { id: user.id, status: { not: "revoked" } },
    data: {
      status: "revoked",
      revokedAt: new Date(),
      revokedReason: `review ${event.event_id}`,
    },
  });
  return count === 1 ? user.id : null; // null: a previous delivery already did this
}

async function revokeCleanup(userId, eventId) {
  await db.session.deleteMany({ where: { userId } });
  await db.apiKey.updateMany({ where: { userId }, data: { revoked: true } });
  await refundOpenCharges(userId, { idempotencyKey: `revoke:${eventId}` });
}

markRevoked returns a user id exactly once per account, no matter how many times the delivery lands. Everything expensive hangs off that return value. The idempotency key on the refund is doing the same job one layer down, because your queue worker will also retry, and a refund issued twice is money you do not get back.

Sessions are the part people get wrong. Deleting session rows works if you look sessions up on every request. If you issue stateless JWTs, deleting the row changes nothing: the token stays cryptographically valid until it expires, and your revoke is a lie for however long that is. The Ech0 project shipped this exact gap, an admin delete that removed the token record without adding its id to the blacklist, and wrote it up plainly: "The admin's 'revoke' UI button lies. The token row disappears from the panel but the bearer keeps working." If you are on JWTs, you need a denylist keyed on the token id, or a revokedAt on the user that your auth middleware compares against the token's iat. The second is cheaper and covers every token you ever issued to that account.

If the reviewed event was a checkout, refund before the cardholder disputes. Refunds are not free: Stripe states that "Stripe's processing fees from the original transaction aren't returned". They are still much cheaper than a chargeback, and a proactive refund on an account you have already decided is fraudulent removes the reason for the dispute to exist at all, which is the whole argument for treating a deny as a chargeback you get to cancel in advance.

Step 4: respond fast, then do the slow work

Stripe's rule is the industry default and worth copying: your endpoint "must quickly return a successful status code (2xx) before any complex logic that could cause a timeout". A handler that refunds a charge, sends an email and writes to your analytics warehouse inline will eventually blow a delivery timeout, get retried, and do all of it twice.

The version of that advice I would push back on is "ack first, always". If you 200 before any durable write and your process dies, the deny is gone: the sender saw a 2xx and will never send it again. Do one indexed UPDATE synchronously, the conditional revoke from step 3, then respond, then queue the rest.

app.post("/webhooks/portreeve", express.raw({ type: "application/json" }), async (req, res) => {
  let event = null;
  try {
    event = Portreeve.verifyWebhook(req.body, req.get("portreeve-signature"), process.env.PORTREEVE_WEBHOOK_SECRET!);
  } catch {
    event = null;
  }
  if (!event) return res.status(400).send("bad signature");

  if (event.type === "review.resolved" && event.resolution === "denied") {
    const userId = await markRevoked(event);   // fast, idempotent, durable
    res.sendStatus(200);
    if (userId) queue.push({ job: "revoke_cleanup", userId, eventId: event.event_id });
    return;
  }
  res.sendStatus(200);
});

One round trip to the database is a few milliseconds. That is inside anyone's timeout budget, and it means a crash between the 200 and the queue push leaves the account already revoked with cleanup pending, rather than a fraudster still logged in.

Step 5: fire one and watch it land

Use test mode. It is fully isolated from live, so nothing you do here touches a real account.

Run a signup through your own flow with a test secret key and a device token from the browser snippet, using shapes that trip a flag: a disposable domain, or a second signup from the same device inside a minute. Read the verdict and its reason codes. When one comes back review, open it in the dashboard queue and deny it.

Your endpoint should receive review.resolved with resolution: "denied" and the external_user_id you sent. Check the row: status is revoked, revokedReason names the event id.

Now the test that matters. Log the raw body and the Portreeve-Signature header from that delivery, then POST both at your endpoint again with curl. Nothing should change, no second refund should appear, and you should still get a 200. The first delivery only proves the wire works; the replay proves the handler is safe to retry. Test mode and feedback walks the loop end to end.

Then deny a second one and let your queue worker fail on purpose. The retry should land on an already-revoked account and do nothing.

What to change for production

Put the cleanup work on a real queue with its own retries and a dead-letter path. Your webhook handler should not be where a failed Stripe refund gets swallowed.

Alert on unmapped resolutions, on email-fallback hits, and on signature failures. A sudden run of 400s means a secret rotation you did not finish, not an attack.

Store the raw event and your handler's outcome. When someone emails support asking why they were locked out, you want the event id, the resolution and the timestamp in one row.

Report the approvals. When a review is resolved approved, that is a labeled negative example, and sending it back with portreeve.feedback(event.event_id, "false_positive") is what keeps the flag rate from drifting up with nothing pulling it down. If the only signal your fraud layer ever gets is confirmation, its idea of a normal customer decays, and you end up reviewing the free tier signups that were fine all along.

Where this breaks

A global JSON parser above the route. The failure looks like a bad secret. If express.json() runs first, req.body is an object, the HMAC is computed over something the sender never signed, and every delivery 400s. Register express.raw on the webhook path before the global parser.

A revoke that is not conditional. update({ status: "revoked" }) reads as idempotent, but the side effects downstream of it run on every delivery. Gate on the row's previous state and return early when you changed nothing.

Trusting anything outside the signature. A user id in the query string, a header your load balancer sets, a field you did not verify. The signed body is the only thing you know came from the sender. Everything else is caller-controlled, and an endpoint that revokes accounts is an interesting endpoint to point at a competitor's user list.

Treating a 500 as safe. A handler that throws returns 500, the sender retries, and you feel covered. Retry windows are finite: Stripe gives up after three days. If your process is down that long, the deny is lost and the account stays live. Durable write first, ack second.

If you want the review queue and this webhook without building the identity graph behind them, Portreeve's free tier covers 1,000 screened events a month and takes no card: start here.

← Back to all posts