Drop-in ACH Payments for Your Next.js Site

A production-ready same-day ACH integration for any Next.js 16 site on Vercel. Ships with one-time checkout, recurring subscriptions, dunning, loyalty tiers, settlement verification, saved payment methods, and distributed rate limiting.

By Blake Toves, Founder at Holistic Payments · Published April 19, 2026

What this is. We partnered with Gulf Management Systems (GMS) to bring you a compliant same-day ACH checkout capability priced at 2%. This plugin is the drop-in Next.js integration on top of that program, so your site can accept ACH debits, run recurring subscriptions, and settle funds without building it yourself.

Apply for the ACH program at GMS →

On WordPress instead of Next.js? We have a WordPress plugin for the same ACH program. Email intake@holisticpayments.io and we'll send it over.
Download

gms-ach-plugin.zip

Source files, Drizzle schema, Vercel cron config, and test fixtures. Preserve the directory structure when you unzip.

46 KB · 36 files
Download the plugin
On this page
  1. What this gives you
  2. Who this is for
  3. Requirements
  4. Before you start
  5. Installation (12 steps)
  6. What to monitor after launch
  7. Troubleshooting
  8. Files in the zip

What the checkout looks like live

Here's the ACH checkout running in production on a Next.js commerce site using this exact plugin. Routing + account fields with inline confirm-account validation, account type dropdown, save-for-later checkbox, and an order summary with an automatic ACH discount applied to incentivize customers away from card.

ACH Bank Payment checkout, routing number, account number, confirm account number, account type selector, save-bank-for-faster-checkout option, order summary with 5% ACH discount applied
Custom SiteNext.js + Tailwind checkout built on this plugin. Total shows $57.74 after a 5% ACH discount ($2.25 off a $45 subtotal + $14.99 shipping). Your UI, your pricing logic, plugin just handles the debit.

If your site is on WordPress instead, the same ACH program drops into WooCommerce checkout via our companion plugin. Different theme, same underlying payments infrastructure:

WooCommerce eCheck checkout, Pay securely using eCheck, routing + account fields, NACHA authorization, Place Order button
WordPressWooCommerce eCheck checkout, NACHA-compliant ACH authorization checkbox, standard WooCommerce UI, Place Order button. Email intake@holisticpayments.io for the WordPress plugin zip.

What this gives you

When installed and wired up, your site supports:

No external subscription service. No per-transaction SaaS fees beyond GMS + Upstash. Runs on your own stack.

Who this is for

Not for you if:

Requirements

RequirementWhy
GMS (Gulf Management Systems) merchant accountThe actual ACH program. Apply at gms-operations.com; 3–7 business day approval. They issue api_id, gms_id, api_key.
Next.js 16+ (App Router)The plugin is all App Router route handlers. Pages Router would need porting.
Vercel Pro ($20/mo)Cron jobs + 900 s function timeouts. Hobby tier cron is too limited.
Neon Postgres (or any Postgres)Subscription records, encrypted credentials, audit trail.
Drizzle ORMSchema is declared in Drizzle. Prisma users can port but you'll rewrite the queries.
Upstash RedisRate limiting + idempotency. Provisioned in 60 seconds via Vercel's Upstash integration. Free tier (500K commands/month) is plenty.
TypeScript strictEverything's typed. Strict mode catches the money-path bugs.
Email providerYou supply a sendEmail({ to, subject, html }) helper. Mailtrap, Resend, Postmark, SendGrid, doesn't matter which.
Existing commerce schemaYou need users, addresses, orders, order_items tables already. The plugin adds subscription tables on top.

Before you start

Gather these:

  1. Your GMS credentials from your merchant account onboarding email: api_id, gms_id, api_key.
  2. A Vercel Pro project linked to your codebase.
  3. Your Neon (or Postgres) connection URL already in your project's .env.local and Vercel env.
  4. A working email helper at src/lib/email.ts exporting sendEmail.
  5. An auth helper at src/lib/auth.ts exporting requireAuth() that returns { id, email, firstName, lastName } or throws.
  6. Somewhere to click, you'll be hitting Vercel CLI + dashboard a few times.

Installation

Step 1, Download and unzip

unzip gms-ach-plugin.zip

You get a gms-ach-plugin/ folder with source files, schema, and docs.

Step 2, Install dependencies

From your project root:

npm install @upstash/redis @upstash/ratelimit

Other deps (drizzle-orm, zod, next, react, @neondatabase/serverless) are assumed already present in a typical Next.js commerce project.

Step 3, Provision Upstash Redis

npx vercel integration accept-terms upstash --yes
npx vercel integration add upstash/upstash-kv

This installs Upstash as a Vercel integration and auto-injects KV_REST_API_URL, KV_REST_API_TOKEN, KV_URL, REDIS_URL into your project's production + preview + development environments. No manual env-var entry needed.

Step 4, Generate and set secrets

Generate two secrets:

openssl rand -hex 32      # → ENCRYPTION_KEY (for routing/account encryption)
openssl rand -base64 32   # → CRON_SECRET (protects /api/cron/* from public access)

Add both to Vercel production:

npx vercel env add ENCRYPTION_KEY production     # paste hex value
npx vercel env add CRON_SECRET production        # paste base64 value

Step 5, Add GMS credentials

npx vercel env add GMS_API_ID production    # paste from GMS onboarding email
npx vercel env add GMS_API_KEY production   # paste UUID from same email
npx vercel env add GMS_ID production        # paste short merchant ID

Step 6, Copy source files

Preserve the directory structure exactly. From inside the unzipped gms-ach-plugin/ folder:

# Copy library code
cp src/lib/gms.ts        /path/to/your/project/src/lib/
cp src/lib/crypto.ts     /path/to/your/project/src/lib/
cp src/lib/idempotency.ts /path/to/your/project/src/lib/
cp src/lib/rate-limit.ts /path/to/your/project/src/lib/

# Copy API routes
cp -r src/app/api/checkout/ach       /path/to/your/project/src/app/api/checkout/
cp -r src/app/api/subscriptions      /path/to/your/project/src/app/api/
cp -r src/app/api/payment-methods    /path/to/your/project/src/app/api/
cp -r src/app/api/cron/subscriptions /path/to/your/project/src/app/api/cron/
cp -r src/app/api/cron/gms-settlement /path/to/your/project/src/app/api/cron/
cp -r src/app/api/cron/subscription-reminders /path/to/your/project/src/app/api/cron/

# Copy test
cp scripts/test-gms-parser.ts /path/to/your/project/scripts/

Step 7, Merge the Drizzle schema

Open migrations/schema-additions.ts from the plugin. You'll find:

  1. 4 new tables, subscriptions, subscription_items, subscription_events, saved_payment_methods. Copy the pgTable declarations into your src/lib/db/schema.ts.
  2. New columns for your existing orders table, documented at the top of the file. Add these to your existing orders pgTable:
    • paymentStatus (varchar 30, default "unpaid")
    • paymentGateway (varchar 20)
    • gmsTransId, gmsRefId, gmsMaskedAccount (varchars)
    • subscriptionId (uuid, FK to subscriptions)
    • notes (text)

Then push to your database:

npx dotenv-cli -e .env.local -- npx drizzle-kit push

Step 8, Merge the cron config

Open your project's vercel.json (create one if it doesn't exist) and add:

{
  "crons": [
    { "path": "/api/cron/subscriptions", "schedule": "0 10 * * *" },
    { "path": "/api/cron/subscription-reminders", "schedule": "0 9 * * *" },
    { "path": "/api/cron/gms-settlement", "schedule": "0 14 * * *" }
  ]
}

Schedules are UTC. Adjust if your ops team wants different windows.

Step 9, Resolve brand-specific imports

The plugin references a few helpers that may or may not exist in your project. Grep for them and fix up:

grep -rn "getProductBySlug\|scoreOrder\|createCommissionIfReferred\|pushOrderToShipStation\|getLineTotal" src/
ImportUsed forWhat to do if you don't have it
getProductBySlug from @/lib/productsChecking if a product has noShipping: true to skip shipping feesEither add a noShipping flag to your product type, or remove the allNoShipping conditional and always charge shipping
scoreOrder from @/lib/fraud-scoreFraud scoring before GMS chargeStub with async () => null if you don't want fraud scoring, or plug in your own scorer
createCommissionIfReferred from @/lib/affiliateAffiliate commission creation on saleRemove the line if you don't have an affiliate program
pushOrderToShipStation from @/lib/fulfillmentAuto-push confirmed orders to ShipStationRemove if you have a different fulfillment pipeline
getLineTotal from @/lib/discountsLine item total (supports volume discounts)Replace with item.price * item.quantity if you don't have volume pricing

Also do a brand-name sweep, there are a handful of email-template strings that say things like "Stillwater BioLabs." Replace with your brand.

Step 10, Test the GMS connection

Before sending any real-money traffic. Run:

npx tsx scripts/test-gms-parser.ts

Expected output: 5/5 passed, 0 failed. This confirms the parser handles all four real GMS response shapes (success, decline, SOAP fault, plain-text error) plus a garbage fallback. If this fails, the credentials/wiring is wrong, fix before proceeding.

Step 11, Deploy and smoke-test

npx vercel --prod

Once deployed:

  1. Create a test user in your DB.
  2. Create a test product priced at $3 (low enough that you won't mind burning the test money).
  3. Sign in as the test user, add the product to cart, check out with real bank routing + account numbers (yours, for the test).
  4. Confirm in your database:
    • A new row in orders with paymentGateway = 'ach', paymentStatus = 'pending', a populated gmsTransId.
    • No rows in subscriptions (unless you set up a recurring test).
  5. Over the next 2–3 business days, watch paymentStatus flip from pendingcompleted as the settlement cron runs.

That confirms one-time checkout works end-to-end.

Step 12, Optional: add the UI

The plugin is backend-only. To add the UI pieces that drive it, see the reference implementation (a production Stillwater-style e-commerce site), specifically these files:

These aren't in the plugin zip because they're tightly coupled to each brand's UI system. Use them as a template.


What to monitor after launch

Week 1

Ongoing


Troubleshooting

Q: Checkout returns "Payment was declined. Please check your bank details and try again." with no additional info.

The plugin's default prod behavior is to hide the raw GMS response from the customer. To see what GMS actually said, check Vercel runtime logs, the plugin logs [GMS] declined / [GMS] api_error / [GMS] soap_fault / [GMS] parse_error with the raw XML. In dev (NODE_ENV !== 'production'), the raw response is also returned in the JSON body under gms_debug.

Q: Subscription cron ran but no charges went through.

Check subscription_events in the DB for error entries. Common causes: (a) CRON_SECRET mismatch → cron returns 401 before processing; (b) all subscriptions had lastChargeEpoch matching today already (idempotency guard fired); (c) no subscriptions had nextChargeDate <= now.

Q: Customer charged but no order created.

Look for charge_db_failure events. This is the one scenario where the plugin can leave a customer in a bad state (GMS accepts, DB fails). The admin alert email (ADMIN_ALERT_EMAIL env var) fires when this happens. Manual reconciliation: look up the gmsTransId in the GMS portal, confirm the debit, manually create the order row, set paymentStatus = 'pending' so the settlement cron takes over.

Q: Upstash rate limit seems to not be firing.

Check KV_REST_API_URL and KV_REST_API_TOKEN are set in production. The plugin fails open when Upstash is unavailable (availability > security for rate limiting), so if the env vars are missing you won't see any errors, the limits just won't apply. Run npx vercel env ls production | grep KV_ to confirm.

Q: How do I rotate ENCRYPTION_KEY?

You can't, not safely, without a migration script. Rotating the key orphans every existing achRoutingEncrypted / achAccountEncrypted / savedPaymentMethods row. If you truly need to rotate, write a script that: (1) reads every encrypted row using the old key, (2) re-encrypts with the new key, (3) swaps the env var, (4) deploys. The plugin doesn't support keyring versioning out of the box, this is the main known limitation.

Q: What's the minimum amount I can charge via ACH?

GMS technically allows $1+. Some banks will bounce debits under $5 for fraud reasons. $3 is a safe floor for "this actually works" tests.

Q: Does this handle ACH returns (R01 / R02 / etc.)?

The settlement cron catches them and flips paymentStatus to failed, status to cancelled, and claws back affiliate commissions. It does not (yet) automatically pause the subscription on a return, that happens after 3 retry attempts via the dunning flow. If you want immediate pause on return, edit gms-settlement/route.ts to add a pause update when a subscription-linked order fails.


Files in the zip

gms-ach-plugin/ ├── README.md ← Full architecture + integration guide ├── INSTALL.md ← Compact install commands ├── PUBLISH.md ← (This file, publishable version) ├── .env.example ← Every env var, annotated ├── package-requirements.json ← npm deps + versions ├── vercel-crons.json ← Cron schedule entries ├── migrations/ │ └── schema-additions.ts ← Drizzle tables + order-column additions ├── src/lib/ │ ├── gms.ts ← SOAP client, 4-shape response parser │ ├── crypto.ts ← AES-256-GCM helpers │ ├── idempotency.ts ← Upstash claim/cache/release │ └── rate-limit.ts ← Upstash sliding-window limiter ├── src/app/api/ │ ├── checkout/ach/route.ts ← One-time checkout │ ├── subscriptions/ │ │ ├── route.ts ← Create + list │ │ └── [id]/route.ts ← Pause/resume/cancel/skip/update │ ├── payment-methods/ │ │ ├── route.ts ← List + update │ │ └── [id]/route.ts ← Delete │ └── cron/ │ ├── subscriptions/route.ts ← Daily 10am UTC charge run │ ├── subscription-reminders/route.ts ← Daily 9am UTC pre-charge notice │ └── gms-settlement/route.ts ← Daily 2pm UTC settlement check └── scripts/ └── test-gms-parser.ts ← 5 fixture tests for the parser

Provenance. Extracted from a production research-peptides e-commerce site handling real customer ACH debits and recurring subscription billing. Code paths in this plugin have been exercised against real GMS responses, including the four distinct response shapes that broke the initial naive parser. Kurt Schneider at GMS specifically flagged the SOAP fault case that this parser now handles, fix shipped, tests pass.

Last updated April 19, 2026. Check this page for newer versions before trusting this snapshot for anything critical.

License

Private distribution. Do not redistribute without permission from the owner.

Ready to install?

Download the zip, work through the 12 steps, and your Next.js site has drop-in ACH + subscriptions.