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 subscription is created with trialEnd set (auto-create mode), the engine looks up all payment methods on this merchant with the same fingerprint, then checks whether any of those customers have a prior subscription that had trialEnd set.
  3. If a match exists, the new subscription is created without the trial — it starts in active immediately, the buyer is charged at signup as expected, and a subscription.trial_blocked outbound event fires so you can react.
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_production_...
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 the auto-create flow gates a trial silently (without you calling the eligibility endpoint), Throttle still emits a webhook so you have a hook point to react. This is fired in addition to the normal subscription.created — you get both events.

ts
// MERCHANT WEBHOOK
// Fires when the auto-create flow downgrades a trial-requested subscription
// to a no-trial subscription because the eligibility check failed. The
// subscription IS created — just without trialEnd. Use this hook to send
// a "we couldn't grant you a trial" email or to log the decision.

case 'subscription.trial_blocked':
  // event.data.subscriptionId — the (active, no-trial) sub we created
  // event.data.customerId
  // event.data.paymentMethodId
  // event.data.reason — 'card_already_used_for_trial' (or another)
  // 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