Subscriptions

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

  1. Every payment vault writes the processor's stable payment_method.fingerprint onto the customer_payment_methods row.
  2. 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 trialEnd set. The check runs once per checkout, and its answer covers every trial line.
  3. 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. A subscription.trial_blocked event still fires, with subscriptionId: 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.
  4. A refused trial never gives a free first period. Before 2026-10-05 a blocked trial created the subscription active with 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 created trialing with trialEnd set to now, the renewal job charges the first period on its next run (with tax, invoice, emails and dunning as for any trial conversion), and subscription.trial_blocked fires with the subscription id. For those few minutes subscription.created reports status: trialing.
Fail-open by design
If the engine can't check (no fingerprint on the row, or the card was vaulted before the v1 schema migration), the trial is allowed. We surface this in the eligibility-check response as 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).

http
POST /api/v1/subscriptions/eligibility-check
X-API-Key: sk_live_...
Content-Type: application/json

{
  "paymentMethodId": "01HF8...uuid"
}

Response:

json
{
  "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

ts
// 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.

ts
// 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