Reference

Payments

Gate the checkout button, then release paid credits as the money settles.

Connect Stripe

Stripe Dashboard → Developers → Webhooks → Add endpoint. Webhook only: Nolgard never holds a Stripe API key and never calls Stripe.

https://nolgard.com/v1/hooks/stripe/<siteKey>

Select these events:

charge.succeeded          charge.failed             charge.refunded
charge.dispute.created    charge.dispute.closed     radar.early_fraud_warning.created
setup_intent.setup_failed checkout.session.completed
customer.created          customer.updated
payment_intent.created    payment_intent.succeeded  payment_intent.payment_failed

Paste the signing secret (whsec_…) into site settings, Payments. Test-mode events are accepted: they freeze and release holds so you can test, but never label devices and never feed the shared abuse network.

Gate the checkout button

In the Upgrade or Add-card handler, mint a token for the action:

const token = await window.Nolgard.execute({ action: 'checkout' });
await fetch('/api/checkout', { method: 'POST', body: JSON.stringify({ token }) });

On the server, verify before creating a Checkout Session, PaymentIntent or SetupIntent.

POST https://nolgard.com/v1/verify
{"token":"…","secret":"sk_…","action":"checkout","account":{"id":"user_42"},"checkout":{"credits":5000}}

{"success":true,"decision":"limit","ref":"ev_…","grant":{…},
 "payment":{"hold":{"share":0.3,"hours":72},"threeDS":true}}

Node

import { stripeMetadata } from '@nolgard/node';

const r = await nolgard.verify({ token, action: 'checkout', account: { id: user.id }, checkout: { credits: 5000 } });
if (r.decision === 'block' || r.decision === 'shadow') return res.status(403).json({ error: 'checkout-blocked', ref: r.ref });
const threeDS = r.payment?.threeDS ? { payment_method_options: { card: { request_three_d_secure: 'any' } } } : {};

const session = await stripe.checkout.sessions.create({
  mode: 'payment', line_items, success_url, cancel_url,
  client_reference_id: r.ref,
  metadata: stripeMetadata(r, user.id),
  payment_intent_data: { metadata: stripeMetadata(r, user.id), ...threeDS },
});

SetupIntent (card on a free trial)

const intent = await stripe.setupIntents.create({
  customer: customerId,
  metadata: stripeMetadata(r, user.id),
  ...(r.payment?.threeDS ? { payment_method_options: { card: { request_three_d_secure: 'any' } } } : {}),
});

For subscriptions use subscription_data.metadata, for card-on-file Checkout use setup_intent_data.metadata. Either metadata.nolgard_ref or client_reference_id is enough to tie the Stripe object back to the event; send both when you can.

Decisions

DecisionDo
allowCreate the payment normally. Release all credits.
limitCreate it with 3-D Secure. Release payment.hold.share of the credits now, the rest after payment.hold.hours.
challengeCreate it with 3-D Secure.
block, shadowDo not create the payment.

payment is present only for action: 'checkout'. A fail-open verify returns {"hold":null,"threeDS":false}: the payment is created as usual.

ReasonFires atCheckoutElsewhere
card_testing≥3 failed payments/hour from one device, or ≥10/hour from one networkblocknone
card_cycling≥3 distinct cards from one device in 24 hlimitnone
payment_fraudearly fraud warning or fraudulent dispute on this device or a linked account, 180 daysblocklimit
refund_serial≥2 refunds or lost disputes by the same human in 180 dayslimitlimit on the next free-trial signup

Per-site thresholds in Payments: failuresDevice (3), failuresNetwork (10), cardsPerDevice (3), refunds (2), holdShare (0.3), holdHours (72). Defaults are uncalibrated; run in observe first.

Holds and spend

When you pass checkout.credits and the decision is limit, a hold caps spend for that account at floor(share × credits) until it expires.

{"allowed":false,"remaining":{"hold":0},"reason":"hold_budget"}
{"allowed":false,"remaining":{},"reason":"payment_hold"}

hold_budget: over the hold cap, the rest of the credits are released when the hold expires. payment_hold: an early fraud warning or a fraudulent dispute froze the account; every reservation is denied until an owner releases the hold in the Payments tab.

Fraud labels and account risk

A chargeback or early fraud warning in live mode labels the checkout's device as fraud, the same as /v1/feedback with fraud, and sends account.flagged for linked accounts that received credits.

GET /v1/accounts/user_42
{"flags":["payment_fraud","refund_serial"],"hold":"frozen","recommendedAction":"revoke_credits",…}

New outbound webhook events:

TypeData
payment.card_testingscope (device or network), failures, windowMinutes (60)
payment.fraudaccountId, kind (efw or dispute), reason, linkedAccounts
payment.holdaccountId, state (held, frozen or released), cap, until, reason

Testing

Point a Stripe test-mode webhook at the same endpoint and pay with test cards.

CardProduces
4242424242424242successful charge
4000000000000002declined charge → card_testing counter
4000000000000259charge disputed as fraudulent → freeze and payment_fraud

Repeat the declined card three times from one browser profile to reach the device threshold. Test-mode events never label devices and never reach the shared network, so a test dispute freezes the account without a lasting fraud label.

Data and retention

Stored per payment event: outcome kind, livemode, amount, currency, decline or dispute reason, card country, an HMAC of Stripe's card fingerprint, and salted actor hashes. Never card numbers, names, emails or addresses.

Payment events and their links are kept 180 days, processed Stripe event ids 30 days (replay protection), released holds 30 days. See Privacy.