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_failedPaste 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
| Decision | Do |
|---|---|
allow | Create the payment normally. Release all credits. |
limit | Create it with 3-D Secure. Release payment.hold.share of the credits now, the rest after payment.hold.hours. |
challenge | Create it with 3-D Secure. |
block, shadow | Do 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.
| Reason | Fires at | Checkout | Elsewhere |
|---|---|---|---|
card_testing | ≥3 failed payments/hour from one device, or ≥10/hour from one network | block | none |
card_cycling | ≥3 distinct cards from one device in 24 h | limit | none |
payment_fraud | early fraud warning or fraudulent dispute on this device or a linked account, 180 days | block | limit |
refund_serial | ≥2 refunds or lost disputes by the same human in 180 days | limit | limit 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:
| Type | Data |
|---|---|
| payment.card_testing | scope (device or network), failures, windowMinutes (60) |
| payment.fraud | accountId, kind (efw or dispute), reason, linkedAccounts |
| payment.hold | accountId, state (held, frozen or released), cap, until, reason |
Testing
Point a Stripe test-mode webhook at the same endpoint and pay with test cards.
| Card | Produces |
|---|---|
| 4242424242424242 | successful charge |
| 4000000000000002 | declined charge → card_testing counter |
| 4000000000000259 | charge 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.