WordPress payment form without a plugin

A single donation, a workshop deposit, a one-product launch, a “buy me a coffee” tip jar – the default WordPress route is WooCommerce plus a payment gateway, or one of the forms-plus-payments plugins (Gravity Forms Stripe add-on, WPForms Pro Stripe field, Fluent Forms Pro payment fields). For a sole trader taking a deposit, a charity running a fundraiser, or a one-product seller, that is a 50-MB e-commerce platform (or an annual licence) under a single payment that may happen weekly.

Three no-plugin paths replace that stack for a WordPress payment form, in increasing order of effort. The first is a Stripe Payment Link: an <a> on the WordPress page hands off to a hosted Stripe checkout, with zero JavaScript and no per-payment work for the operator. The second is an embedded checkout button. Stripe’s Buy Button and PayPal’s Standard Checkout buttons run inside a Custom HTML block; Square’s hosted checkout is link-only and is covered alongside. The third is a bare HTML form posting to one of the handlers from the contact-form guide, with the form’s success state linking to a Payment Link. The third path is the right one when custom data capture (name, shipping address, attendee details) needs to happen before the card.

What the no-plugin path buys, and what it costs

No e-commerce plugin to maintain, audit, or migrate. No annual licence. No admin UI tree the operator now owns. No WordPress database table holding order records that need GDPR handling. The WordPress site stays a publication; the payment vendor handles the card.

The cost is symmetric. The Stripe, PayPal, or Square dashboard becomes the system of record for orders, refunds, and reporting. The operator looks up “did Jane pay” in Stripe, not in WP-Admin. Reconciliation with anything else the site does (a CRM, a member directory, a course platform) is the operator’s problem – the dashboard has the payment row, but it does not have the relationship to anything else on the site unless a webhook fills that gap. For a single-product seller or a deposit-only operator, that trade is almost always worth it; for a multi-product catalogue with inventory variants and customer accounts, it is not, and WooCommerce is the right answer for that case.

Path A: a Stripe Payment Link

A Stripe Payment Link is a hosted checkout URL on buy.stripe.com/<id> that the operator creates in the Stripe Dashboard with a fixed-price product. The WordPress page links to it from any element that takes an href – a button block, a navigation menu, an inline link, a Custom HTML block. No JavaScript runs on the WordPress side.

Stripe’s standard processing fee applies – 2.9% + $0.30 per successful card charge in the US, with no additional surcharge for Payment Links themselves: the page lists Payment Links as “Included with Payments.” International cards and currency conversion carry the regional supplements documented on the pricing page.

Four configuration options matter for a one-off payment:

  • Custom fields. Three field types are exposed on the checkout page: text (up to 255 characters), numbers (up to 255 digits), and dropdown (up to 10 options from the Dashboard, 200 via the API). Set them under “Add custom fields” when configuring the link. For a workshop, this is where “attendee name” lands when the buyer and the attendee differ; for a donation, “name to list on the supporter wall.”
  • Inventory cap. The restrictions[completed_sessions][limit] parameter deactivates the link after N successful checkouts; inactive_message is the matching sold-out screen text. Useful for a limited edition or a capped-attendance event paid for via this path rather than the event-registration approach.
  • Post-payment behaviour. By default, the buyer lands on a Stripe-hosted “Payment succeeded” page. Switching after_completion[type] to redirect sends them to a URL the operator controls – a thank-you page on the WordPress site, a download link, a calendar invite page. Configurable from the Dashboard or the API.
  • URL parameters. [email protected] fills the checkout email field; the buyer can still edit. ?client_reference_id=<id> attaches an arbitrary string (up to 200 alphanumeric characters, dashes, or underscores) to the Checkout Session, which makes the row reconcilable against whatever upstream system originated the payment. ?prefilled_promo_code and ?locale are the other documented options.

One footgun: Stripe receipt emails are off by default. Settings -> Emails -> “Successful payments.” A small-organisation Payment Link with the box unchecked silently sends no receipt; the operator notices only when a payer asks for one. Turn it on before sharing the link.

Strong Customer Authentication (the EU regulatory rule that requires a second factor for most consumer payments) is handled by Stripe Checkout end-to-end. When the issuer requires 3-D Secure, Stripe presents the challenge inside the hosted checkout and verifies the result; the operator writes no code for it.

Refunds happen in the Dashboard: Payments -> click the row -> Refund payment, full or partial. The refunded amount returns to the original card via the same network; Stripe’s processing fees on the original charge are not refunded, which is the industry default and worth knowing if margins are tight.

Path B: an embedded checkout button

When the operator wants the checkout to open inside the WordPress page rather than at a hosted URL, the path is a JavaScript embed inside a Custom HTML block. Two vendors offer in-page embeds; Square is link-only.

Stripe Buy Button

The Stripe Buy Button is a hosted web component configured from the Payment Link’s Dashboard view. The Dashboard generates a snippet:

<script async src="https://js.stripe.com/v3/buy-button.js"></script>

<stripe-buy-button
  buy-button-id="buy_btn_xxxxxxxxxxxxxxxxx"
  publishable-key="pk_live_xxxxxxxxxxxxxxxxx">
</stripe-buy-button>

The <script> registers the <stripe-buy-button> custom element; the element renders the button. Clicking it opens Stripe Checkout in an overlay; the rest of the WordPress page stays where it is. The fee structure, custom fields, and post-payment behaviour are inherited from the Payment Link the Buy Button wraps; the embed does not add a surcharge.

The Buy Button is the right path when the operator wants the WordPress site URL on the address bar through the checkout flow, when the page is doing other work that the link-out would interrupt, or when the existing page design has a specific space for the button rather than a generic “Buy now” link.

PayPal Smart Buttons

PayPal’s JavaScript SDK renders payment buttons inline from a script tag and a container div:

<div id="paypal-button-container"></div>

<script src="https://www.paypal.com/sdk/js?client-id=YOUR_CLIENT_ID&components=buttons&currency=GBP"></script>

<script>
paypal.Buttons({
  createOrder: (data, actions) => actions.order.create({
    purchase_units: [{ amount: { value: '25.00' } }]
  }),
  onApprove: (data, actions) => actions.order.capture().then(details => {
    document.getElementById('paypal-button-container').innerHTML =
      'Thanks, ' + details.payer.name.given_name + '. Receipt on its way.';
  })
}).render('#paypal-button-container');
</script>

The buttons render the relevant payment-method tiles for the buyer’s region (PayPal balance, card, Venmo in the US, Pay Later where available). PayPal Checkout’s published US fee is 3.49% + $0.49 per transaction completed through the Smart Buttons, whether the buyer uses a PayPal balance, a saved payment method, or pays as a guest via card. PayPal’s lower 2.99% + $0.49 rate (“standard credit and debit card payments”) covers a different product: the Advanced Credit and Debit Cards direct-processing integration, where the merchant hosts the card-input fields. That rate does not apply to the Smart Buttons embed used here. The fee structure assumes a verified business PayPal account.

A note on the client-id: this is the public client id from the PayPal Developer Dashboard, not a secret. Use the live client id only on production; sandbox client ids exist for testing and route to PayPal’s sandbox environment.

For an operator already standardised on PayPal (because that is what the donor base recognises, or because the charity is enrolled with PayPal Giving Fund), this is the in-page path. For an operator with no PayPal preference, Stripe Buy Button is the simpler embed – one script tag, no SDK callbacks.

Square Pay Link

Square’s Checkout API and the Dashboard’s Payment Links feature produce a hosted URL on square.link/u/<id>. The Checkout API is link-based: a single API call returns a URL that the operator shares or links to. Square’s developer surface for an in-page card form is the Web Payments SDK, which renders the card-input fields directly but expects the merchant to write the rest of the integration; there is no one-snippet hosted-button equivalent to Stripe Buy Button or PayPal Smart Buttons. The button on the WordPress page is therefore a link to the Square-hosted page – mechanically identical to Path A, with Square as the processor.

Square’s online processing fee is 3.3% + $0.30 per transaction on the free plan and 2.9% + $0.30 on the Plus and Premium plans, charged on every online card payment. The Quick Pay flow accepts only “name, price, and seller location” to create a link; an order-based flow (where the application provides an Order object with itemisation, tax, and fulfilment) covers more complex cases.

For a Square-native operator (a retail shop already taking in-person payments through Square Terminal, where the payment is one source-of-truth in Square’s dashboard rather than two), Square is the right path. For everyone else, Stripe Payment Link or Stripe Buy Button covers the same shape with a documentation surface that names every Checkout parameter; Square’s published Checkout API is thinner on consumer-facing detail than its in-person-payment documentation, which is where the company invests.

Compared

All fees verified 2026-06-24.

Option Checkout UI US card fee Embed shape
Stripe Payment Link Hosted page on buy.stripe.com/<id> 2.9% + $0.30 Link from any element
Stripe Buy Button In-page button + Stripe Checkout 2.9% + $0.30 <script> + <stripe-buy-button>
PayPal Smart Buttons In-page button + PayPal flow 3.49% + $0.49 JS SDK + button container
Square Pay Link Hosted page on square.link/u/<id> 3.3% + $0.30 (Free) / 2.9% + $0.30 (Plus, Premium) Link from any element

Path C: a form handler with a Stripe Payment Link

The bare-link and embedded-button paths work when the only thing the operator needs from the buyer is the payment. The case for Path C is when the operator also needs custom data captured before the card runs: a shipping address that affects what is shipped, a workshop attendee’s name when the buyer and attendee differ, dietary requirements for a dinner, a referral code that the receiving system needs to act on. The cleanest no-plugin path for that case is an HTML form whose success state hands off to a Payment Link.

The form posts to one of the handlers covered in Create a WordPress contact form without plugins (Formspree, Basin, Formspark, Forminit). The handler delivers a notification email to the operator and archives the submission on its dashboard. The form’s success state, rendered in the page after the AJAX submission, surfaces a Stripe Payment Link with the email and a reference id prefilled.

<form id="pay" action="https://formspree.io/f/your-endpoint-id" method="POST">
  <label for="pay-name">Name</label>
  <input id="pay-name" type="text" name="name" required>

<label for="pay-email">Email</label>
  <input id="pay-email" type="email" name="email" required>

<label for="pay-address">Shipping address</label>
  <textarea id="pay-address" name="address" rows="3" required></textarea>

<label for="pay-notes">Anything we should know</label>
  <textarea id="pay-notes" name="notes" rows="3"></textarea>

<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px;">

<button type="submit">Continue to payment</button>
</form>

<p id="pay-success" hidden>
  Details saved. Pay £35 to confirm:
  <a id="pay-link" href="">Continue to checkout</a>.
</p>

<script>
const PAYMENT_LINK = 'https://buy.stripe.com/your-link-id';
document.getElementById('pay').addEventListener('submit', async function (e) {
  e.preventDefault();
  const form = e.target;
  const data = new FormData(form);
  const res = await fetch(form.action, {
    method: 'POST',
    body: data,
    headers: { 'Accept': 'application/json' }
  });
  if (res.ok) {
    form.hidden = true;
    const reference = (data.get('email') + '-' + Date.now()).replace(/[^a-zA-Z0-9-]/g, '-');
    const url = new URL(PAYMENT_LINK);
    url.searchParams.set('prefilled_email', data.get('email'));
    url.searchParams.set('client_reference_id', reference);
    document.getElementById('pay-link').href = url.toString();
    document.getElementById('pay-success').hidden = false;
  }
});
</script>

The honeypot field (_gotcha) is the convention the form handlers documented in the contact-form guide expect; the form-handler docs are authoritative for the exact field name in each. prefilled_email carries the buyer’s email into Stripe Checkout; client_reference_id ties the form submission to the payment row so the operator can pair them by hand or by webhook.

Two receipts will land in the buyer’s inbox unless the operator intervenes: the form handler’s auto-reply and Stripe’s payment receipt. The simplest mitigation is to disable the form handler’s auto-reply from its dashboard, leaving Stripe’s receipt as the only message the buyer receives. For an operator who wants a single combined “order confirmation” that pairs the captured form fields with the payment confirmation, the route is a webhook handler on checkout.session.completed that issues one email of its own.

VAT, sales tax, and equivalent are out of scope for the no-plugin path. Stripe’s Tax product handles automatic calculation but is a Stripe-side product, not a WordPress one. For a UK sole trader under the VAT threshold or a US seller under the relevant economic nexus thresholds, no tax field is required; for anyone above those, this is the point at which the no-plugin path stops being adequate.

Which path fits which need

For a multi-product catalogue with stock variants, customer accounts, or shipping calculations that vary by destination, none of these paths fits; WooCommerce is the answer for that case, and the rest of this section assumes the operator has already determined the no-plugin path is the right shape.

For a one-product seller (a book, an ebook, a print) at a fixed price with no shipping variation, Stripe Payment Link is the right default. One link from the Dashboard, one button on the WordPress page, no JavaScript, no form. Add a custom field for “name to inscribe” if relevant.

For a donation-only operator (a charity, a community project, a “buy me a coffee” page) where the donor chooses an amount, a single Stripe Payment Link configured as a customer-set-amount product handles it. Same shape as the fixed-price flow; the input lives on the Stripe-hosted page.

For an operator already standardised on PayPal (because the donor base recognises the brand or because of a regulatory or fundraising-platform integration), PayPal Smart Buttons keeps the checkout in the WordPress page and routes to the existing PayPal business account. Higher per-transaction fee than Stripe; lower friction for an existing PayPal donor base.

For a service deposit, an event ticket, or a personalised product where the operator needs to capture shipping details, attendee information, or any field that affects what is delivered, Path C (the form-plus-Payment-Link) is the path. Stripe Checkout’s custom fields can carry simple cases (a single text field, a dropdown), but a multi-field address or a freeform brief is the form handler’s job.

nanoPost’s default for the typical reader profile (single product, single donation, simple deposit) is Path A: a Stripe Payment Link, linked from a Button block. One link, one button, and the WordPress site stays the publication it was before.

What this does not solve

The no-plugin paths handle a single payment. They do not handle a multi-line cart, a basket the buyer assembles across pages, variable shipping calculations based on weight and destination, multi-rate tax that varies by buyer location, downloadable digital products with per-purchase licence-key issuance, complex subscriptions with proration and dunning, or a customer account where buyers see past orders. That whole category is what WooCommerce and the WordPress e-commerce roundups are for.

A no-plugin path also does not give the operator a WordPress-side record of payments. The order row lives in the Stripe, PayPal, or Square dashboard; the WordPress site has no idea the payment happened unless a webhook handler is built. For a sole trader who looks at the Stripe dashboard anyway, that is not a loss; for a multi-staff operation where different people need different reporting cuts, it might be.

For the email side of the operation (the receipt, the post-purchase email, any reminder broadcasts that follow), WordPress’s wp_mail() still needs to work if any of those flow through the site. The site needs an SMTP setup for that to be reliable, or a host that handles outbound mail without a plugin, regardless of which payment path is in use.

This guide is the payment-form slice of WordPress without the plugin, nanoPost’s coverage of the patterns that work without the usual stack.