Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 099 / 100

Compliance basics for payment engineers, explained simply

Rules that affect your code.

Expona Admin8 min read

The reality of compliance in payment systems

We're on day 99 of the payment engineering journey, and if you've been building payment flows, you've probably felt the weight of regulations creeping into your architecture decisions. Compliance basics for payment engineers, explained simply, come down to this: there are rules that affect your code, your infrastructure, and how you handle customer data. These aren't abstract legal concepts—they directly influence how you store passwords, validate transactions, handle refunds, and delete information.

Most engineers treat compliance as a box to tick off with the security team. But understanding the "why" behind these rules makes you a better architect. You'll design systems that are naturally compliant rather than bolting compliance on afterward.

PCI DSS scope: What you actually need to do

PCI DSS (Payment Card Industry Data Security Standard) is the heavyweight regulation in payment processing. It's not optional if you touch card data. The critical insight: scope determines your burden. If you never see the card number, you're out of scope for most of it.

Let's break down the levels:

  • Level 1: Over 6 million transactions per year. Full audit required annually.
  • Level 2: 1–6 million transactions per year. Self-assessment questionnaire (SAQ).
  • Level 3: 20,000–1 million ecommerce transactions per year. SAQ.
  • Level 4: Under 20,000 ecommerce transactions per year. SAQ.

The smart move is to tokenize. If you use Stripe's hosted payment form or Stripe Elements, card data never touches your servers. You receive a token instead. That drastically reduces your scope.

Here's what this looks like in practice:

typescript

// Bad: Never do this
app.post('/charge', async (req, res) => {
  const { cardNumber, expiry, cvc } = req.body;
  // Your server now holds card data. High PCI scope.
  const charge = await stripe.charges.create({
    amount: 1000,
    currency: 'usd',
    source: cardNumber, // Wrong
  });
});

// Good: Token-based flow
app.post('/charge', async (req, res) => {
  const { token } = req.body; // Token from Stripe Elements
  const charge = await stripe.charges.create({
    amount: 1000,
    currency: 'usd',
    source: token, // Your server never sees the card
  });
});

When you tokenize, you drop from potentially needing a full security audit to filling out a questionnaire. That's a huge operational difference.

PCI DSS also requires encrypted networks (TLS everywhere, no HTTP), network segmentation, regular security testing, access logs, and incident response procedures. But if card data isn't on your servers, the burden shrinks dramatically.

Strong customer authentication: More than just a password

Strong Customer Authentication (SCA) is the regulatory push to kill password-only authentication for payments. It's driven by PSD2 in Europe and similar regulations elsewhere. The rule: when a customer makes a payment, they need to prove who they are using at least two of these:

  • Knowledge: Something they know (password, PIN)
  • Possession: Something they have (phone, security key)
  • Inherence: Something they are (biometric)

For most SaaS and ecommerce, this means one-time passwords (OTP), biometric verification on the device, or 3D Secure challenges. If you don't implement SCA properly, your transactions can be declined or chargebacks can spike.

Stripe handles this with Payment Intents and automatic 3D Secure routing. Here's how:

typescript

// Create a Payment Intent
const paymentIntent = await stripe.paymentIntents.create({
  amount: 1000,
  currency: 'usd',
  payment_method_types: ['card'],
  customer: customerId,
});

// Return the client secret to your frontend
res.json({ clientSecret: paymentIntent.client_secret });

// On the frontend (using Stripe.js)
const { error, paymentIntent: result } = await stripe.confirmCardPayment(clientSecret, {
  payment_method: {
    card: cardElement,
    billing_details: { name: 'Jenny Rosen' },
  },
});

// If result.status === 'requires_action', the customer must authenticate
if (result.status === 'requires_action') {
  // 3D Secure challenge or OTP flow
  // Stripe handles the UI
}

The key: never assume a payment succeeded until you've checked the PaymentIntent status. `requires_action` means the customer needs to authenticate. Your code should handle this gracefully, not treat it as a failure.

For recurring payments (subscriptions), you can use exemptions like "low value" or "credential on file" to skip SCA in certain cases. But relying on exemptions without SCA fallback is risky—regulators tighten them yearly.

Consumer protection for subscriptions: Cancellation and transparency

If you charge customers repeatedly, you're in the consumer protection zone. Rules vary by region, but common requirements include:

  • Easy cancellation (not buried in menus)
  • Clear billing terms before charging
  • Renewal reminders
  • Easy access to billing history
  • Refund windows for accidental charges

The US FTC's "Negative Option Rule" is particularly strict. It requires explicit affirmative consent before the first charge, clear and conspicuous terms, and a simple cancellation mechanism that mirrors the signup process.

So if a customer signed up via a button click, they should be able to cancel via a button click—not by emailing support.

Here's a reasonable implementation:

typescript

// Subscription creation with explicit consent
app.post('/subscribe', async (req, res) => {
  const { customerId, priceId, agreeToTerms } = req.body;
  
  if (!agreeToTerms) {
    return res.status(400).json({ error: 'Must accept terms' });
  }

  const subscription = await stripe.subscriptions.create({
    customer: customerId,
    items: [{ price: priceId }],
    payment_behavior: 'default_incomplete', // Explicit billing
  });

  // Log consent for audit trail
  await db.audit_log.create({
    customerId,
    action: 'subscription_created',
    terms_accepted: true,
    timestamp: new Date(),
  });

  res.json(subscription);
});

// One-click cancellation
app.post('/unsubscribe/:subscriptionId', async (req, res) => {
  const subscription = await stripe.subscriptions.del(req.params.subscriptionId);
  
  // Immediate confirmation, no delay
  res.json({ success: true, cancelledAt: new Date() });
});

Document everything. Audit logs showing when customers consented, when they were charged, and when they canceled are your evidence in disputes.

Data retention and privacy laws: GDPR, CCPA, and local variants

Privacy laws require you to:

  • Collect only what you need: Don't ask for phone numbers if you don't need them.
  • Store securely: Encrypted at rest, TLS in transit.
  • Delete on request: "Right to be forgotten" under GDPR. 30 days is a common SLA.
  • Keep only as long as needed: Payment records often need to stay for tax/audit (3–7 years depending on region), but personal data beyond that should go.
  • Report breaches quickly: GDPR mandates 72 hours. Some states require 30 days.

For payment data, the tension is real: you need to keep transaction records for tax compliance, but GDPR says delete. The answer: keep transaction records (immutable ledger), but anonymize personal details. The transaction happened on Date X for Amount Y—customer name and email get deleted.

Here's a data retention pattern:

typescript

interface Transaction {
  id: string;
  amount: number;
  currency: string;
  timestamp: Date;
  customerId: string; // Foreign key, can be anonymized later
  status: 'completed' | 'failed';
  createdAt: Date;
}

interface Customer {
  id: string;
  email: string;
  name: string;
  createdAt: Date;
  deletedAt?: Date; // Soft delete for GDPR right-to-be-forgotten
}

// GDPR deletion job (runs daily)
async function deleteExpiredPersonalData() {
  const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
  
  // Find customers with deletion requested
  const customersToDelete = await db.customers.find({ deletedAt: { $lt: thirtyDaysAgo } });
  
  for (const customer of customersToDelete) {
    // Anonymize their data
    await db.customers.updateOne(
      { id: customer.id },
      {
        email: `deleted-${customer.id}@example.com`,
        name: 'Deleted User',
        anonymized: true,
      }
    );
    
    // Keep transactions for tax records, but remove reference
    await db.transactions.updateMany(
      { customerId: customer.id },
      { customerId: null, anonymized: true }
    );
  }
}

Test your deletion flows. Actually request deletion of your own test account and verify it disappears within your stated SLA.

Common mistakes

  • Assuming PCI compliance is someone else's job: Your code architecture determines your scope. If you design for tokens early, you stay compliant naturally. If you handle card data, you inherit the burden.
  • Not handling SCA properly: Checking payment status only once is a common trap. A payment might be `processing`, `requires_action`, or `succeeded`. Your code needs to handle all states.
  • Making cancellation hard: Burying unsubscribe links or making customers email support invites regulatory fines. Stripe itself can flag you to regulators if cancellation rates suggest deceptive practices.
  • Keeping data forever: "We might need it" isn't a legal basis. Set explicit retention windows and automate deletion. Most payment processors provide webhooks when accounts are deleted—react to them.
  • Confusing "compliant with Stripe's terms" with "legally compliant": Stripe's API helps you be compliant, but you still need to understand the underlying regulations. Don't copy examples blindly.

Key takeaways

  • Tokenize card data early. It's the biggest lever for reducing PCI scope and complexity.
  • Use Payment Intents and handle all status transitions, especially `requires_action` for SCA.
  • Make subscription cancellation as easy as signup. It's not just compliance—it's good UX and reduces chargebacks.
  • Implement data deletion jobs. GDPR isn't going away, and other regions are following. Automate it or you'll miss SLAs.
  • Audit everything. Logs showing consent, charges, and cancellations are your defense in disputes and investigations.
  • Read the regulations yourself, not just Stripe's docs. Stripe is a tool; compliance is your responsibility.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles