Evaluate in the background
Grant a benefit you can take back in full at once, evaluate the claim in a background job and act on the result when it arrives.
By default, your server evaluates a claim before it grants the benefit, as in Protect a benefit end to end. That stays the recommended pattern. For a benefit you can take back in full, you can also grant first: the user continues at once, a background job evaluates the claim right away, and the job acts on the result when it arrives.
The evaluation is the same call either way. Your job calls evaluate() in the AbuseZero Node.js SDK, or POST /v1/evaluations in the AbuseZero REST API, and waits for its normal response. Every guarantee of an evaluation holds: one final result within the evaluation's time limit, safe retries with the same idempotency key, and the exact policy version recorded. Only the moment you grant changes.
Before you begin
- A server that evaluates claims and an SDK client in
lib/abusezero.ts. See Protect a benefit end to end. - Browser protection on the page where the person claims the benefit.
- A job queue in your application that runs a job as soon as it's added and can run it again.
- A server key with the scopes
benefits:read,evaluations:create,evaluations:readandoutcomes:write.
Choose a benefit that fits
Decide benefit by benefit. From the moment you grant it until your job has the result, the benefit is active, so this pattern suits benefits you can take back in full.
| Benefit | Pattern |
|---|---|
| Trial days, plan access, a feature | Evaluate in the background. On deny, you take the benefit back in full. |
| AI or API credits, payouts, referral cash, promotional codes | Evaluate before you grant. These can be used up at once, before your job has the result. |
Any benefit with bot_evidence: "required" | Evaluate in the background only if your job evaluates within 2 minutes of the bot check. Otherwise, evaluate before you grant. |
Read the benefit's settings with getBenefit(), GET /v1/benefits/{id}, abusezero benefits get in the AbuseZero CLI, or the Benefits page of the dashboard.
import { abusezero } from "@/lib/abusezero";
type BenefitSettings = {
config: { bot_evidence: string | null; outcome_tracking: string | null };
};
const benefit = await abusezero.getBenefit("ben_6f1d2c3b-4a5e-4f60-8b7c-9d0e1f2a3b4c") as BenefitSettings;
console.log({
botCheckRequired: benefit.config.bot_evidence === "required",
reportsOutcomes: benefit.config.outcome_tracking === "report_outcome",
});reportsOutcomes tells you whether step 5 reports each grant. Both settings are described in Benefit configuration.
Collect the device token
When the person submits the claim, call observe with the claim's ID and send the deviceToken to your server with the claim. For a benefit with a bot check, call prepareBotObservation instead. It takes the same options and returns the same deviceToken.
import { observe } from "abusezero/browser";
export async function submitTrial(claimId: string) {
const { deviceToken } = await observe({ key: "pk_…", claimId });
await fetch("/api/trial", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ claimId, deviceToken }),
});
}The token is short-lived. With browser data available, observe also returns expiresIn, the seconds left before the token expires (see The observation). A job sends the token later than a request handler would, so the next step starts it at once.
Grant the benefit and start the job
In the request that receives the claim, grant the benefit and add a job that evaluates it. Save both together, for example in one database transaction, so every claim you grant is evaluated.
export type TrialJob = {
claimId: string;
idempotencyKey: string;
userId: string;
email: string;
emailVerified: boolean;
ip?: string;
userAgent?: string;
deviceToken: string;
};import { db } from "@/lib/db";
import { enqueue } from "@/lib/jobs";
import { grantTrial } from "@/lib/trials";
import type { TrialJob } from "@/lib/trial-job";
export async function POST(request: Request) {
const { claimId, deviceToken } = await request.json() as { claimId: string; deviceToken: string };
const user = await currentUser(request); // Your own session lookup.
const job: TrialJob = {
claimId,
idempotencyKey: `evaluate:${claimId}`,
userId: user.id,
email: user.email,
emailVerified: user.emailVerified,
ip: clientIp(request), // The visitor's IP from your trusted proxy chain.
userAgent: request.headers.get("user-agent") ?? undefined,
deviceToken,
};
await db.transaction(async tx => {
await grantTrial(tx, user.id);
await enqueue(tx, "evaluate-trial", job); // Runs as soon as a worker is free.
});
return Response.json({ trial: "started" });
}db, enqueue, grantTrial, currentUser and clientIp stand for your own database, job queue, trial and session code.
- Capture the request details now. The visitor's IP address and user agent come from this request. The job runs on your server, where an address read from the connection is your server's. See Send the IP address.
- Store the idempotency key with the job. Every run of the job sends the same key, so AbuseZero returns the original result instead of evaluating the claim twice.
- Start the job at once. Don't delay or batch these jobs. The device token must reach AbuseZero before the
expiresInthe browser SDK returned. A later token is recorded with the browser data statusexpired, and the evaluation runs with everything else it received. A bot-check token must arrive within 2 minutes. On a benefit withbot_evidence: "required", a later one returnsBOT_EVIDENCE_REQUIRED, and the claim isn't evaluated.
Evaluate the claim in the job
In the job, call evaluate() with the stored idempotency key. Use a client whose outage option is "throw": no user is waiting, so your job retries instead of deciding the claim locally.
import { AbuseZero } from "abusezero/server";
export const abusezero = new AbuseZero({
key: process.env.ABUSEZERO_SECRET_KEY!,
outage: "throw",
});import { AbuseZeroError } from "abusezero/server";
import { abusezero } from "@/lib/abusezero";
import { retryLater, reviewClaim } from "@/lib/jobs";
import type { TrialJob } from "@/lib/trial-job";
import { actOnResult } from "./act-on-result";
export async function evaluateTrialJob(job: TrialJob) {
let evaluation;
try {
evaluation = await abusezero.evaluate(
{
benefit: "pro_trial",
claim_id: job.claimId,
subject: { external_id: job.userId, email: job.email, email_verified: job.emailVerified },
context: { ip: job.ip, user_agent: job.userAgent },
identifiers: { device_token: job.deviceToken },
},
job.idempotencyKey,
);
} catch (error) {
if (!(error instanceof AbuseZeroError)) throw error;
if (error.retryable && !error.code.startsWith("BOT_EVIDENCE_")) return retryLater(job);
return reviewClaim(job, error.code, error.requestId);
}
return actOnResult(job, evaluation);
}retryLater and reviewClaim stand for your own queue and review process.
- Retryable errors run the same job again, after your queue's backoff. The job sends the same stored key, so a retry never runs or bills a second evaluation.
TEMPORARILY_UNAVAILABLE,DEADLINE_EXCEEDED,EVALUATION_PENDINGandRATE_LIMITEDare retryable. See Retry retryable errors. - Bot-check errors (
BOT_EVIDENCE_…) need a fresh bot check from the browser, which a job can't collect. They go to your review, with every error that a retry can't fix, such as an invalid request. - Attempts: give the job a limit that suits your queue, and send a claim that reaches it to your review too.
Keep the benefit on allow
Route the result. On allow, the benefit stays. When the benefit uses report_outcome, report granted with outcome(). The job reports right after the evaluation, before its outcome.due_at. With assume_granted, there's nothing to report.
import type { Evaluation } from "abusezero/server";
import { abusezero } from "@/lib/abusezero";
import { retryLater } from "@/lib/jobs";
import type { TrialJob } from "@/lib/trial-job";
import { onDeny } from "./on-deny";
import { onSkipped } from "./on-skipped";
export async function actOnResult(job: TrialJob, evaluation: Evaluation) {
if ("status" in evaluation) return onSkipped(job, evaluation.reason);
if (evaluation.source === "local_outage") return retryLater(job); // Only with outage "allow" or "deny".
if (evaluation.result === "deny") return onDeny(job, evaluation.id);
// allow: the trial stays. This benefit uses report_outcome, so report the grant.
await abusezero.outcome(evaluation.id, { outcome: "granted" }, `outcome:${evaluation.id}:granted`);
}The REST API call is POST /v1/evaluations/{id}/outcome. Reporting the same outcome again changes nothing, so a retried job is safe here too.
Take the benefit back on deny
On deny, end the benefit, and tell the person in your own words. Keep reason.code for your team; Reason codes lists every code. A denied evaluation has no outcome, so when you take the benefit back in full, there's nothing to report.
When the person already used part of the benefit and you can't take all of it back, record a grant after a deny: report granted on the denied evaluation, with a reason of 3 to 500 characters. The claim then counts in your claim history like any granted claim, so later claims are checked against it.
import { abusezero } from "@/lib/abusezero";
import { endTrial } from "@/lib/trials";
import type { TrialJob } from "@/lib/trial-job";
export async function onDeny(job: TrialJob, evaluationId: string) {
const { fullyTakenBack } = await endTrial(job.userId);
if (fullyTakenBack) return;
await abusezero.outcome(
evaluationId,
{ outcome: "granted", reason: "Part of the trial was used before the result arrived." },
`outcome:${evaluationId}:granted`,
);
}{
"object": "outcome_report",
"evaluation": "eval_8a1d4f20-6c3b-4e57-b9d2-0f7e6a5c3b18",
"state": "granted",
"conflicts": []
}endTrial stands for your own code that ends the trial and says whether anything was already used.
conflicts lists later outcomes (out_…) that share identifiers with this claim. The claim didn't count until now, so review them before you act on them. The AbuseZero CLI command is abusezero outcomes report, and the AbuseZero MCP server tool is abusezero_report_outcome. In the dashboard, the same action is Record delivery anyway on the denied evaluation. Every rule and error is in Grant after a deny.
Handle skipped evaluations
A skipped evaluation means the workspace couldn't run it, so no allow or deny was decided, for example because the workspace is out of credits. The benefit is already active, so decide in advance what a skipped claim gets, and handle it as its own branch.
import { reviewClaim } from "@/lib/jobs";
import type { TrialJob } from "@/lib/trial-job";
export async function onSkipped(job: TrialJob, reason: string) {
await alertWorkspaceOwner(reason); // Your own alerting.
return reviewClaim(job, `skipped:${reason}`); // Your fallback, decided in advance.
}Skip reasons are insufficient_credits, billing_overdue, service_inactive and benefit_paused. Treat a reason you don't recognize like any other skip. Your outage setting never applies to a skip. The workspace owner resolves skips in Workspace settings → Billing & Subscriptions. See Handle skipped evaluations.
Verify your integration
Claim the benefit from your page and let the job finish. Then open the evaluation in Evaluations in the dashboard, or read it with GET /v1/evaluations/{id} or abusezero evaluations get in the AbuseZero CLI.
curl https://api.abusezero.com/v1/evaluations/eval_3f6c2a1e-8b4d-4c7a-9e2f-5d1b7a0c4e93 \
-H "Authorization: Bearer $ABUSEZERO_SECRET_KEY"Check four things:
browser_dataiscollected.expiredmeans the job sent the device token too late: start it sooner.inputsshowsip: received, so the job carried the visitor's address from step 3.- For an allowed claim,
outcome.stateisgrantedon a benefit that usesreport_outcome, orassumed_granted. - For a denied claim you recorded as granted,
resultstaysdenyandoutcome.stateisgranted. The dashboard shows Delivered after a deny.
You now have a benefit your users receive without waiting. Every claim is still evaluated by AbuseZero, your job applies each result as soon as it arrives, and a claim you delivered despite a deny counts in your claim history like any other grant.
How is this guide?
Last updated on