Trial Fraud Protection
Throttle ships a card-fingerprint check that detects the same physical card being reused across "fresh" customer accounts. The check runs automatically inside the auto-create flow and is also exposed as a pre-flight API endpoint for explicit gating.
How it works
-
Every payment vault writes the processor's stable
payment_method.fingerprintonto thecustomer_payment_methodsrow. -
When a checkout grants a trial, Throttle reads the card's fingerprint from the
payment before any order exists and checks whether a customer on this merchant with the
same fingerprint already had a subscription with
trialEndset. The check runs once per checkout, and its answer covers every trial line. -
If a match exists, the checkout is refused with
409 trial_not_available(details.reason: "card_already_used_for_trial"). No order, no subscription and no saved card are created, and nothing is charged. The hosted checkout says "This card has already been used for a free trial." and shows the card form again, since another card may be eligible; your storefront can offer a plan without a trial. Asubscription.trial_blockedevent still fires, withsubscriptionId: null. On the server-authorize rail (Secure Fields) the check runs after the authorisation and before capture; a non-zero authorisation is voided, and the pending order that rail creates first is reused if the buyer retries. - A refused trial never gives a free first period. Before 2026-10-05 a
blocked trial created the subscription
activewith its first period unpaid. If two checkouts with the same card race past the first check, the check after payment catches it: the subscription is createdtrialingwithtrialEndset to now, the renewal job charges the first period on its next run (with tax, invoice, emails and dunning as for any trial conversion), andsubscription.trial_blockedfires with the subscription id. For those few minutessubscription.createdreportsstatus: trialing.
reason: 'no_fingerprint_available' so you can
layer your own checks on top. Failing-closed would be catastrophic during the rollout — many
legitimate cards in your existing customer base have no fingerprint yet.
Scope is per-merchant
A fingerprint that's been on Merchant A's trial does not block
the same card from getting a trial on Merchant B. Different businesses, different fraud
surfaces. Throttle deliberately scopes the check to (workspaceId, fingerprint) so we don't leak fraud
signals across workspaces.
Pre-flight API
POST /api/v1/subscriptions/eligibility-check
Server-to-server endpoint, secured with your API key. Useful when you want to render different UI before subscribing (e.g., hide the "Start free trial" button when the buyer is ineligible).
POST /api/v1/subscriptions/eligibility-check
X-API-Key: sk_live_...
Content-Type: application/json
{
"paymentMethodId": "01HF8...uuid"
} Response:
{
"data": {
"eligible": true | false,
"reason": "card_already_used_for_trial"
| "payment_method_not_found"
| "no_fingerprint_available"
| undefined // when eligible:true with no caveat
}
} Using it from your backend
// MERCHANT BACKEND
import { createSubscriptionsClient } from '@usethrottle/subscriptions/server';
const subscriptions = createSubscriptionsClient({
apiKey: process.env.THROTTLE_SECRET_KEY!,
});
// Pre-flight check before stamping a trialEnd on a new subscription.
// You'd typically call this after the buyer's card has been vaulted
// (you have the customerPaymentMethods row id) and before showing
// the "Start your 14-day trial" confirmation screen.
const result = await subscriptions.checkTrialEligibility({
paymentMethodId: pmRowId,
});
if (!result.eligible) {
// Surfaces a reason: 'card_already_used_for_trial' is the abuse signal.
// You can fall back to a no-trial subscribe flow ("subscribe now, no
// trial") or refuse outright. Up to your product policy.
return showNoTrialOption({ reason: result.reason });
}
return showTrialOption(); The trial_blocked webhook
When checkout refuses a trial (without you calling the eligibility endpoint), Throttle
still emits a webhook so you have a hook point to react. On a refusal no subscription
exists, so subscriptionId is null and the event carries the checkout instead: cartId, checkoutSessionId
and planReference. Only the race backstop above sends it
with a subscription id, in addition to subscription.created.
@usethrottle/webhook-types 6.3.0 types subscriptionId and paymentMethodId as nullable.
// MERCHANT WEBHOOK
// Fires when trial-fraud protection refuses a requested trial. Usually the
// checkout itself was refused (409 trial_not_available): nothing was created,
// so subscriptionId is null and cartId / checkoutSessionId / planReference
// name the checkout. Use this hook to offer a no-trial plan or log the decision.
case 'subscription.trial_blocked':
if (event.data.subscriptionId === null) {
// event.data.cartId, event.data.checkoutSessionId, event.data.planReference
// event.data.customerId — null for a guest buyer with no customer yet
} else {
// Race backstop: the subscription exists and its trial already ended;
// the renewal job charges its first period on its next run.
}
// event.data.paymentMethodId — may be null
// event.data.reason — 'card_already_used_for_trial'
// event.data.requestedTrialDays — what was originally asked for
await notifyTrialDeclined(event.data);
break; What this does NOT protect against
- Different cards from the same buyer. The fingerprint changes when the card changes. A buyer with three different cards can claim three trials.
- Disposable email signups. Block disposable email domains in your signup flow if your business cares; Throttle doesn't.
- IP / device velocity. Throttle doesn't see the buyer's IP. Add rate-limiting or a CAPTCHA at your edge if needed.
- Stolen cards. That's a payment-processor concern (provider auth-decline rules) — not a subscription concern.
Disabling the check
The check runs automatically when customerPaymentMethods schema is wired into the
subscription service (default in production). There's no per-merchant toggle in v1 —
if your business model wants unlimited trials per card, contact support and we'll
discuss the right primitive.
Next
- Lifecycle and States — trial timing semantics + dunning curve.
- Subscription Webhooks — full
event reference (now includes
subscription.trial_blocked).