# portreeve

Node/TypeScript SDK for [Portreeve](https://portreeve.com) — an abuse firewall for indie SaaS.
One call per sensitive event (signup, trial start/convert, checkout attempt) returns an
`allow` / `review` / `block` verdict with documented reason codes.

- Zero runtime dependencies. Node >= 18.17. ESM + CJS.
- **Fail-open by default:** if Portreeve is slow or down, your flow proceeds (`degraded: true`).
- `review` never blocks your end user — resolutions arrive via webhook.

```bash
npm install portreeve
```

## Quickstart — Next.js route handler

```ts
// app/api/signup/route.ts
import { Portreeve } from "portreeve";

const portreeve = new Portreeve(process.env.PORTREEVE_SECRET_KEY!);

export async function POST(req: Request) {
  const { email } = await req.json();

  const result = await portreeve.verdict(
    {
      event_type: "signup",
      email,
      ip: req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "",
      user_agent: req.headers.get("user-agent") ?? undefined,
    },
    // One signup action per email; changed request details are re-screened.
    { dedupeKey: `signup:${email.trim().toLowerCase()}` },
  );

  if (result.verdict === "block") {
    return Response.json({ error: "signup_blocked" }, { status: 403 });
  }
  // "allow" and "review" both proceed — review is resolved out-of-band and
  // your webhook handler revokes if it flips to block.
  // ...create the account...
  return Response.json({ ok: true }, { status: 201 });
}
```

## Quickstart — Express

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

const app = express();
const portreeve = new Portreeve(process.env.PORTREEVE_SECRET_KEY!);

app.post("/signup", express.json(), async (req, res) => {
  const result = await portreeve.verdict({
    event_type: "signup",
    email: req.body.email,
    ip: req.ip ?? "",
    user_agent: req.get("user-agent"),
  });
  if (result.verdict === "block") {
    return res.status(403).json({ error: "signup_blocked" });
  }
  // ...create the account...
  res.status(201).json({ ok: true });
});
```

## Options

```ts
new Portreeve(secretKey, {
  baseUrl: "https://api.portreeve.com", // default
  timeoutMs: 400,                        // overall budget per call, spans the retry
  failMode: "allow",                     // "allow" (default) | "block" | "throw"
  fetch: globalThis.fetch,               // injectable
});
```

`context` (and everything else you pass to `verdict()`) must be JSON-serializable —
a non-serializable body (e.g. circular references) throws `PortreeveError`
immediately, since that is an integration bug, not a network failure.

## Failure semantics

| Situation                      | Behavior                                                        |
| ------------------------------ | --------------------------------------------------------------- |
| Timeout (default 400ms)        | Degraded result with reason `timeout_failopen`                  |
| Network error                  | One retry, then degraded result (`network_failopen`)            |
| HTTP 5xx                       | One retry, then degraded result (`upstream_failopen`)           |
| Unparseable / invalid response | Degraded result (`invalid_response_failopen`)                   |
| **HTTP 4xx**                   | **Throws `PortreeveError`**, including 401 and 429 |

A degraded result looks like:

```ts
{ id: null, verdict: "allow", reasons: [{ code: "timeout_failopen" }], policy_version: null, degraded: true }
```

`verdict` follows your `failMode` ("allow" or "block"); `failMode: "throw"` throws instead.
The four `*_failopen` codes above are synthesized by the SDK, never by the server. Treat all
reason codes as an open set — new codes appear without an SDK update.

Retries reuse a per-call `Idempotency-Key`. By default the SDK generates a UUID
per `verdict()` call. For retries across calls, pass `dedupeKey` derived from the
logical action (signup email, checkout order ID, or login attempt ID). An identical
serialized request replays the result within the API's 24-hour retention window.
A changed request is re-screened under a stable key derived from the action and
body; repeating that changed request replays it too. Serialization order matters.
All attempts, including conflict recovery, share the same `timeoutMs` budget.

Use an explicit `idempotencyKey` only when you want strict replay semantics:
changed bodies throw HTTP 409 rather than being automatically re-screened.
Use a different action key for each new login or purchase; do not use a global
constant or a user ID for every action that user takes.

HTTP 401 means the key is invalid or revoked; it does **not** fail open. HTTP 429
also throws: back off before retrying with the same action key. Choose how your
application handles those errors explicitly. `failMode` applies to transport,
server, and invalid-response failures, not HTTP 4xx or serialization errors.

## Feedback

```ts
await portreeve.feedback("evt_01J...", "confirmed_abuse", "optional note");
// outcomes: "confirmed_abuse" | "false_positive" | "chargeback"
```

Unlike `verdict()`, `feedback()` throws on any failure — it is not on your user's hot path.

## Webhooks

Portreeve signs `review.resolved` webhooks with `Portreeve-Signature: sha256=<hex HMAC-SHA256 of the raw body>`.
Use the raw (unparsed) request body:

See the [complete webhook handler](../../docs/partners/verdicts.md#the-reviewresolved-webhook)
for raw-body verification and enforcement. Persist merchant-owned context under
the non-degraded `result.id`: the account, trial, payment, or session this verdict
screened. On denial, revoke that specific action; a denied login must not disable
the legitimate owner's whole account. Acknowledge only after enforcement succeeds
or work is durably queued. Missing context must return a retryable error.

Deliveries have at most six attempts (initial delivery plus five retries), not
unlimited retries. Monitor exhausted deliveries and reconcile unresolved actions.

## License

MIT
