At some point in nearly every custom software project, someone says the magic words: "We need to accept payments." What follows is usually a confusing evaluation of payment processors, each with their own pricing, APIs, limitations, and jargon.
Having integrated all three major options into custom applications, here is the straightforward comparison. We will cover what each does well, where each falls short, and which one you should probably pick.
The Quick Comparison
| Feature | Stripe | Square | PayPal |
|---|---|---|---|
| Processing fee | 2.9% + 30¢ | 2.9% + 30¢ | 3.49% + 49¢ |
| API quality | Excellent | Good | Poor |
| Subscription billing | Built-in | Limited | Basic |
| Marketplace support | Stripe Connect | Limited | Basic |
| In-person payments | Stripe Terminal | Excellent | Zettle (separate) |
| Developer docs | Industry-leading | Good | Inconsistent |
| Time to integrate | 1-3 days | 2-5 days | 3-7 days |
Stripe: The Developer’s Choice (And Probably Yours)
Stripe is the default recommendation for custom applications, and for good reason. Its API is the gold standard in the payments industry. The documentation is clear, the SDKs cover every major language, and the developer experience is polished to a degree that other payment companies have not matched.
Where Stripe excels:
Subscription billing. If your application charges users on a recurring basis—monthly subscriptions, annual plans, metered usage—Stripe Billing handles this natively. You can create pricing tiers, offer trials, prorate upgrades and downgrades, handle failed payment retries, and send automated invoices. Building this from scratch would take weeks. With Stripe, it takes a day.
Marketplace payments (Stripe Connect). If you are building a marketplace where multiple sellers or service providers get paid—think an Etsy-style shop, a freelancer marketplace, or an appointment booking app where providers get a cut—Stripe Connect handles the money movement. It manages the compliance headaches of holding and distributing funds, which is a problem you absolutely do not want to solve yourself.
Checkout flexibility. Stripe offers three integration approaches: hosted checkout (Stripe handles the entire payment page), embedded elements (you drop payment fields into your own UI), or a fully custom integration using their API directly. This means you can start simple and get more sophisticated as your needs evolve.
Webhooks and event handling. Stripe fires webhooks for every meaningful event: payment succeeded, payment failed, subscription canceled, dispute opened. This lets your application react to payment events in real time without polling or manual checks.
Where Stripe falls short:
Stripe is not great at in-person payments. Stripe Terminal exists and works, but it is clearly an afterthought compared to the online payment experience. If your business is primarily brick-and-mortar with a secondary online presence, Square is the better fit.
Stripe also has a learning curve. If you are not a developer, the Stripe dashboard can feel overwhelming. It is built for technical users, not business owners who just want to see their revenue.
Square: Best for In-Person and Hybrid Businesses
Square built its reputation on that little white card reader. It remains the strongest option for businesses that need point-of-sale (POS) functionality alongside their custom software.
Where Square excels:
In-person payments. Square's hardware ecosystem—card readers, terminals, registers—is mature and well-supported. If your custom app needs to handle both online transactions and in-person payments at a counter or job site, Square provides a unified system for both.
Integrated business tools. Square comes with built-in invoicing, inventory management, and team management. For businesses that need a lightweight all-in-one system, Square covers a lot of ground without requiring separate integrations.
Simple pricing. Square's pricing is transparent and predictable. No monthly fees for the basic tier, flat transaction rates, and no hidden charges.
Where Square falls short:
Square's API, while functional, is less flexible than Stripe's for custom integrations. Subscription billing is limited compared to Stripe Billing. If you are building a SaaS product or a complex marketplace, Square's tools will feel constrained.
Square also has a reputation for sudden account freezes. If their risk algorithm flags your account, your funds can be held without much explanation or recourse. This is less common than it used to be, but it remains a real risk for businesses processing unusual transaction patterns.
PayPal: The Legacy Option
PayPal is the payment processor everyone knows, which is both its greatest strength and its biggest problem. Customers trust the PayPal brand. Developers dread the PayPal integration.
Where PayPal excels:
Consumer trust. Some customers, particularly older demographics, feel more comfortable paying through PayPal than entering their credit card directly. Offering PayPal as a secondary payment option can reduce checkout abandonment for certain audiences.
International coverage. PayPal supports more currencies and countries than either Stripe or Square. If your application serves a global audience in markets where Stripe is not yet available, PayPal fills the gap.
Where PayPal falls short:
Developer experience. PayPal's API has accumulated decades of technical debt. There are multiple API versions, inconsistent documentation, and integration patterns that feel like they were designed by committee. What takes one day with Stripe can take three to five days with PayPal.
Checkout friction. PayPal's payment flow often redirects users away from your application to PayPal's own pages, then back again. This redirect-based flow is disruptive and can confuse users. Stripe and Square both offer embedded payment forms that keep users on your site.
Higher fees. PayPal's standard processing fee of 3.49% + 49 cents is meaningfully higher than Stripe or Square. On $10,000/month in transactions, that difference adds up to over $70/month compared to Stripe.
Dispute handling. PayPal is famously buyer-friendly in disputes. While this is great for consumers, it can be a headache for businesses. Chargebacks and disputes through PayPal tend to favor the buyer more aggressively than Stripe or Square's processes.
Our Recommendation
For the vast majority of custom application projects, the answer is Stripe. Here is the decision tree:
- Building a SaaS product with subscriptions? Stripe. No contest.
- Building a marketplace or multi-party app? Stripe Connect.
- Building a custom app that takes one-time payments? Stripe Checkout.
- Building for a business that also takes in-person payments? Square, with Stripe as a secondary for online-only features if needed.
- Serving an international audience in PayPal-heavy markets? Stripe primary, PayPal as a secondary option at checkout.
The best approach for many businesses is to use Stripe as the primary payment processor and offer PayPal as an optional alternative at checkout. This covers 95%+ of customers while keeping the integration clean and maintainable.
What Integration Actually Involves
If you are wondering what "integrating Stripe" means in practical terms for your custom app, here is the typical scope:
- Create a Stripe account and get your API keys (takes 10 minutes).
- Add Stripe's library to your application (one line of code).
- Build a checkout flow—either redirect to Stripe's hosted page or embed payment fields in your UI.
- Handle webhooks—set up endpoints that listen for payment confirmations, failures, and other events.
- Test thoroughly using Stripe's test mode with fake card numbers.
- Go live by switching from test keys to live keys.
For a standard one-time payment integration, this is typically one to two days of development work. Subscription billing adds another day or two. Marketplace payments with Stripe Connect can take three to five days depending on complexity.
The code you actually write
Abstract descriptions of "webhooks" and "checkout flows" don't help when you're looking at a blank file. Here is what a minimal Stripe integration looks like in practice.
Creating a checkout session (Node.js)
This is the server-side handler that creates a Stripe-hosted checkout page and redirects the user. You call this when a user clicks "Buy."
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
app.post('/create-checkout-session', async (req, res) => {
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [{
price_data: {
currency: 'usd',
product_data: { name: 'Your Product Name' },
unit_amount: 4900, // $49.00 in cents
},
quantity: 1,
}],
mode: 'payment',
success_url: 'https://yourapp.com/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'https://yourapp.com/cancel',
});
res.redirect(303, session.url);
});
Note the unit_amount: Stripe always works in the smallest currency unit. For USD that is cents, so $49.00 is 4900. This trips up most first integrations.
Handling the webhook (the part most tutorials skip)
The checkout redirect tells you the session was created. It does not tell you the payment succeeded. A user could close the tab after redirecting to Stripe, or their card could be declined after they enter it. The webhook is the authoritative signal that money actually moved.
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
// Stripe signature verification failed — reject the request
return res.status(400).send(`Webhook Error: ${err.message}`);
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
// session.customer_email has the buyer's email
// session.amount_total has the confirmed payment amount
// Update your database, send the receipt email, provision access here
fulfillOrder(session);
}
res.json({ received: true });
});
Two critical details here. First, the route uses express.raw() not express.json() — Stripe's signature validation requires the raw request body, and express.json() parses it first, breaking the signature check. Second, the constructEvent call verifies the request actually came from Stripe and wasn't spoofed by someone hitting your webhook endpoint directly.
Testing with the Stripe CLI
Before you deploy, test the entire flow locally using the Stripe CLI. This forwards real Stripe webhook events to your local server without needing a public URL.
# Install Stripe CLI, then:
stripe listen --forward-to localhost:3000/webhook
# In a separate terminal, trigger a test event:
stripe trigger checkout.session.completed
You'll see events streaming in the first terminal. Your handler logs will show whether it processed them correctly. This loop — trigger, inspect, fix — is faster than deploying to a staging environment every time you change the handler logic.
Common errors and what they actually mean
No signatures found matching the expected signature for payload
Your webhook secret is wrong, or you're using express.json() before the webhook route. Check that your STRIPE_WEBHOOK_SECRET matches the secret shown in the Stripe dashboard for that specific webhook endpoint. Also check that the webhook route is defined before any global app.use(express.json()) middleware in your server.
StripeInvalidRequestError: No such customer: 'cus_...'
You're trying to operate on a customer object from test mode while using live keys, or vice versa. Test and live environments in Stripe are completely separate — customers, products, and payment intents don't cross over. Your test keys start with sk_test_, live keys start with sk_live_. Check which environment your dashboard is showing.
amount_too_small error on checkout creation
Stripe enforces minimum charge amounts per currency — for USD it's 50 cents ($0.50). If you're creating a checkout session for an amount below the minimum, Stripe rejects it. This surfaces most often when someone applies a 100% discount coupon and the final amount rounds to zero.
Webhook events arriving out of order
Stripe sends webhooks asynchronously and doesn't guarantee order. If you're building subscription logic, a customer.subscription.updated event can arrive before the checkout.session.completed event for the same transaction. Build your webhook handler to be idempotent — processing the same event twice should be safe — and check your database state before acting on an event rather than assuming a clean sequence.
Subscriptions: the additional moving parts
Adding subscription billing to Stripe requires a few more concepts beyond one-time checkout.
Products and Prices. In Stripe, a Product is what you're selling ("Septim Drills Pro Plan"). A Price is a specific billing configuration for that product ($29/month, or $290/year). You create these in the dashboard or via API, then reference the Price ID when creating subscriptions. Separating them lets you change pricing without rebuilding your integration.
The Customer object. For subscriptions, you need a Stripe Customer to attach the subscription to. For one-time payments you can skip this; for subscriptions you must create or retrieve a Customer object and store their Stripe customer ID alongside your own user record.
Failed payment recovery. Subscriptions fail. Cards expire. Banks decline. Stripe Billing has built-in retry logic — you configure how many days it waits, how many attempts it makes, and what happens at the end (cancel, pause, or leave active). The webhook events invoice.payment_failed and customer.subscription.deleted are where you update access in your application.
Square and PayPal: the integration reality
If you've decided Square is the right fit for your in-person use case, the Web Payments SDK follows a similar pattern: create a payment request server-side, render their hosted UI component, confirm server-side. The main structural difference is that Square's API returns error codes in a slightly different shape and their sandbox environment requires a separate set of sandbox credentials (not just a test-mode key toggle like Stripe).
PayPal's REST API v2 is the current recommended integration path. The flow involves creating an Order, capturing it after the buyer approves, and handling webhook notifications for status changes. The code is more verbose than Stripe's equivalent, and the sandbox is historically flaky — testing requires patience with 5xx errors that don't reproduce in production. Budget an extra day compared to Stripe for a comparable feature set.
A representative scope: a Stripe-backed CRM for an auto-detailing shop
As a concrete reference: the studio scoped Stripe payment processing into a custom CRM for Lingenfelter Auto Spa — an auto-detailing business that wanted to replace five separate SaaS tools with one owned system. The proposed integration included subscription billing for their service memberships, one-time charges for detailing appointments, automated receipts, and failed-payment retry with SMS notification to the front desk.
Estimated integration work: roughly two and a half days. The proposed system would process payments, replace the ~$340/month they spend across disconnected tools, and ship as code they own outright. Full proposal context is at septimlabs.com/lingenfelter.
Payment processing does not have to be complicated. Pick Stripe, keep the integration clean, and focus on building the parts of your application that actually differentiate your business. The payment plumbing should be invisible to your users and painless for your developers.
— The Septim Labs team