Back to Blog

Zero-wait free trials: run your abuse check in the background

Start free trials instantly and check for trial abuse in a background job. When to grant first, how to take a trial back on deny, and when not to.

Every millisecond between "Start free trial" and the product matters. The usual advice is to check for free trial abuse before you grant the trial, and for most benefits that's still right. But for a benefit you can take back in full, there's another option: start the trial instantly and run the abuse check in a background job. Real users never wait. Repeat claims lose the trial a moment later.

This guide explains when grant-first is safe, how it works with AbuseZero, and the benefits where you should never use it.

Evaluate first, or grant first?

Evaluate before you grantGrant now, evaluate in the background
User experienceOne quick server call before the trial startsThe trial starts at once
What a repeat claim getsNothingThe benefit for a short moment, then it's ended
FitsEvery benefitBenefits you can take back in full
DefaultYes, recommendedAn option, benefit by benefit

Evaluate-before-grant stays the default, as covered in add a trial and credit abuse check to your signup flow. Grant-first is for products where even a short wait on the trial path matters and the benefit can be fully ended.

Which benefits can be granted first

The rule is simple: if you can take all of it back, you can grant it first. If it can be used up before the result arrives, check first.

BenefitPattern
Trial days, plan access, a featureGrant now, evaluate in the background. On deny, end it.
AI or API credits, payouts, referral cash, promotional codesEvaluate before you grant. These can be used up at once.

Credits are the clearest case for checking first. They're consumed the moment they land. Grant them first and a script can spend them in the seconds before the result arrives, hundreds of times over, and the bill is yours either way. See how to protect signup credits in an AI app. One team in our early beta woke up to a $6,000 OpenAI bill after someone used up the free credits on hundreds of accounts.

How grant-first works

[deny]alt[allow]Start free trialGrant the trial and add a job, in one transactionTrial startedEvaluate the claimallow or denyTrial staysEnd the trial and tell the userUserYour serverYour job queueAbuseZeroUserYour serverYour job queueAbuseZero
[deny]alt[allow]StartfreetrialGrantthetrialandadd ajob, inonetransactionTrialstartedEvaluate theclaimallow ordenyTrialstaysEndthetrialandtell theuserUserYourserverYourjobqueueAbuseZeroUserYourserverYourjobqueueAbuseZero
  1. Grant and enqueue together. In the request that receives the claim, grant the trial and add the evaluation job in the same database transaction, so every granted claim is evaluated.
  2. Capture request details now. The visitor's IP address, user agent and the device token from the AbuseZero browser SDK come from this request, not from the job.
  3. Start the job at once. The device token is short-lived, so don't delay or batch these jobs.
  4. Evaluate with a stored idempotency key. Every retry of the job gets the original result back, never a second evaluation.
  5. Act on the result. Keep the trial on allow. End it on deny, in your own words.

Instant trials, still protected

The evaluation is the same call either way, with the same depth behind it. Only the timing changes. Start with Builder or see pricing.

The job, in code

jobs/evaluate-trial.ts
import { AbuseZeroError } from "abusezero/server";
import { abusezero } from "@/lib/abusezero";

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);
}

TrialJob, retryLater, reviewClaim and actOnResult stand for your own job code. Use a client whose outage option is "throw": no user is waiting, so the job retries instead of deciding locally. The complete guide is evaluate in the background.

When part of the benefit was already used

Sometimes the person used part of the trial before the result arrived, and you can't take all of it back. Record it honestly: report a grant after the deny, with a short reason. The claim then counts in your claim history like any granted claim, so the next claim from the same person is checked against it.

await abusezero.outcome(
  evaluationId,
  { outcome: "granted", reason: "Part of the trial was used before the result arrived." },
  `outcome:${evaluationId}:granted`,
);

See grants after a deny.

Or act on results from a webhook

If another system already receives AbuseZero events, let the job evaluate and leave allow and deny to the evaluation.completed webhook event. One place then applies results for every benefit. A retried job never creates a second event, and a failed delivery is retried for 12 hours. More in AbuseZero webhooks.

Frequently asked questions

Can I check for trial abuse after the trial starts?

Yes, for benefits you can take back in full, such as trial days or plan access. Grant the trial, evaluate in a background job and end the trial on deny. For credits or codes that can be used instantly, check first.

Does a background check make AbuseZero less accurate?

No. It's the same evaluation with the same evidence, as long as you capture the request details when the claim arrives and start the job right away.

What do I tell a user whose trial was ended?

A short, neutral message with a way to reach you. Never explain what was detected. See multi-accounting detection without blocking real users.

What if AbuseZero is unreachable when the job runs?

The job retries with the same idempotency key until it gets an answer. No user is waiting, so there's no need to decide the claim locally. See handle outages and retries.

Make trials instant

Keep reading