Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions docs/ach-payments/INTEGRATION_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# ACH Payments Integration Guide

This guide covers integrating ACH (US/Canadian bank account) payments using
the Spreedly Web SDK. ACH lets customers pay directly from a checking or
savings account via the ACH network.

## Overview

The SDK exposes a small, API-only surface for ACH:

1. The merchant collects bank-account details in their own UI (the SDK does
not render any input fields for ACH — there are no hosted fields or
express-checkout iframes for this flow).
2. The merchant calls `setupACHPayment(config)` with the collected values.
3. The merchant calls `submitACHPayment()` — the SDK posts directly to
Spreedly Core and emits `achTokenGenerated` with the resulting payment
method token.
4. The merchant's backend runs a purchase against an ACH-capable gateway
using the token.

```
┌────────────┐ setupACHPayment ┌─────────────────┐
│ Merchant │ ─────────────────▶ │ │
│ Frontend │ submitACHPayment │ Spreedly SDK │
│ │ ─────────────────▶ │ │
└────────────┘ └────────┬────────┘
▲ │ POST /v1/payment_methods
│ achTokenGenerated ▼
│ { token, last4 } ┌──────────────┐
└────────────────────────────│ Spreedly Core│
└──────────────┘
│ POST /v1/gateways/{gw}/purchase
┌────────────────┐
│ Merchant │
│ Backend │
└────────────────┘
```

## Prerequisites

- Spreedly account with an [ACH-capable gateway](https://developer.spreedly.com/docs/ach-payments#ach-gateways) configured
- Spreedly environment key and API credentials
- Auth params (nonce, timestamp, signature, certificate_token) generated by
your backend

---

## Step 1: Load and initialize the SDK

```html
<script src="https://core.spreedly.com/checkout/sdk/{version}/index.js"></script>
```

```javascript
const sdk = new SpreedlyHostedFields({
environment_key: 'your_environment_key',
certificate_token: 'your_certificate_token',
nonce: 'generated_nonce',
timestamp: 'generated_timestamp',
signature: 'generated_signature',
});
```

> Both `SpreedlyHostedFields` and `SpreedlyExpressCheckout` expose the ACH
> methods — pick whichever class your app already loads. Hosted fields and
> express-checkout iframes are NOT mounted just to use ACH; the methods live
> on the shared base class.

## Step 2: Set up event listeners

```javascript
sdk.on('achTokenGenerated', ({ token, last4 }) => {
// POST `token` to your backend to run the gateway purchase
});

sdk.on('achPaymentError', (error) => {
console.error('ACH error:', error.message);
});
```

## Step 3: Collect bank-account details and submit

```javascript
sdk.setupACHPayment({
bankRoutingNumber: '021000021',
bankAccountNumber: '9876543210',
fullName: 'Bob Smith', // OR firstName + lastName
bankAccountType: 'checking', // 'checking' | 'savings'
bankAccountHolderType: 'personal', // 'personal' | 'business'
});

sdk.submitACHPayment();
```

`setupACHPayment` validates only that required fields are present at the
SDK boundary. Routing-number and account-number formatting (US ABA, Canadian
electronic routing) is validated by Spreedly Core; invalid values surface
via the `achPaymentError` event.

## Step 4: Run the purchase from your backend

After `achTokenGenerated` fires, send the token to your backend and run a
purchase against an ACH-capable gateway:

```http
POST https://core.spreedly.com/v1/gateways/{ach_gateway_token}/purchase.json
Authorization: Basic base64(environment_key:access_secret)
Content-Type: application/json

{
"transaction": {
"payment_method_token": "...",
"amount": 1000,
"currency_code": "USD"
}
}
```

> **Important:** Run the purchase from your backend, not from the browser.
> Your access secret must never be exposed client-side.

---

## API reference

### `setupACHPayment(config: ACHPaymentConfig): void`

Stores bank-account details that will be tokenized when `submitACHPayment()`
is called. Throws if any required field is missing.

#### `ACHPaymentConfig`

| Field | Type | Required | Notes |
| ------------------------ | -------------------------- | -------- | --------------------------------------------------------- |
| `bankRoutingNumber` | `string` | yes | 9-digit US ABA or Canadian electronic routing number |
| `bankAccountNumber` | `string` | yes | Bank account number |
| `fullName` | `string` | † | Either `fullName` OR (`firstName` AND `lastName`) |
| `firstName` | `string` | † | See above |
| `lastName` | `string` | † | See above |
| `bankName` | `string` | no | Display name of the bank |
| `bankAccountType` | `'checking' \| 'savings'` | no | |
| `bankAccountHolderType` | `'personal' \| 'business'` | no | |
| `email` | `string` | no | |
| `phoneNumber` | `string` | no | |
| `address1` … `country` | `string` | no | Optional billing address; some gateways require it |
| `metadata` | `Record<string, string>` | no | Forwarded to Spreedly |
| `retained` | `boolean` | no | Set `true` to create a retained payment method |

> Spreedly only supports US (`US`) and Canadian (`CA`) bank accounts.
> Other country codes will be rejected by Spreedly Core.

### `submitACHPayment(): void`

Submits the configured payload to Spreedly. Emits `achTokenGenerated` on
success, `achPaymentError` on failure. Throws synchronously if
`setupACHPayment()` was not called first.

### `clearACHPayment(): void`

Clears the stored ACH configuration. Useful if you want to reset state
between attempts.

### Events

| Event | Payload | Notes |
| -------------------- | -------------------------------- | ---------------------------------- |
| `achTokenGenerated` | `{ token: string, last4?: string }` | Spreedly returns the last 4 digits as `account_number_display_digits`; the SDK exposes them as `last4` |
| `achPaymentError` | `{ message: string, error?: any }` | |

---

## Security notes

- Account and routing numbers never leave the merchant page until they are
posted directly to Spreedly Core. The SDK does not log them.
- The SDK's success log includes only the masked `last4` returned by
Spreedly, never the full account number.
- The merchant page's CSP must allow `connect-src https://core.spreedly.com`
(the standard SDK CSP already does).

---

## Sample app

A working end-to-end demo lives at `web-sdk-sample-app/src/static/ach-payments/`
and exercises the Spreedly Test gateway via `POST /api/v1/ach-purchase`.
44 changes: 44 additions & 0 deletions src/controllers/payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,50 @@ export const createBraintreePurchase = async (req: Request, res: Response): Prom
}
};

// Run a purchase against an ACH (bank_account) payment method token using
// the Spreedly Test gateway. Mirrors createSimplePurchase but is dedicated to
// ACH so it can be wired and documented independently for the demo flow.
export const createAchPurchase = async (req: Request, res: Response): Promise<void> => {
const gateway_key = config.spreedlyGatewayToken;

const { payment_method_token, amount, currency_code = 'USD' } = req.body;

if (!payment_method_token || !amount) {
res.status(400).json({ error: 'payment_method_token and amount are required' });
return;
}

const body = {
transaction: {
payment_method_token,
amount,
currency_code,
},
};

try {
const response = await axios.post(
`${config.spreedlyUrl}/v1/gateways/${gateway_key}/purchase.json`,
body,
{
headers: {
Authorization: getAuthorizationHeader(),
'Content-Type': 'application/json',
},
}
);

const transaction = response.data?.transaction;
res.json({
success: transaction?.succeeded || false,
transaction,
});
} catch (error) {
const apiError = error as AxiosError;
res.status(apiError.response?.status || 500).json(apiError.response?.data);
}
};

// Confirm a Braintree/Stripe-apm transaction with the nonce from PayPal/Venmo
export const confirmTransaction = async (req: Request, res: Response): Promise<void> => {
const transaction_token = req.params.transactionToken || '';
Expand Down
39 changes: 39 additions & 0 deletions src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
createPurchase,
createBraintreePurchase,
confirmTransaction,
createAchPurchase,
} from './controllers/payments';

const router = Router();
Expand Down Expand Up @@ -575,4 +576,42 @@ router.post('/braintree-purchase', createBraintreePurchase);
*/
router.post('/transactions/:transactionToken/confirm', confirmTransaction);

/**
* @swagger
* /api/v1/ach-purchase:
* post:
* description: Create a purchase transaction against an ACH (bank_account) payment method using the Spreedly Test gateway
* tags: [ACH Payments]
* produces:
* - application/json
* parameters:
* - name: body
* description: Purchase details
* in: body
* required: true
* schema:
* type: object
* required:
* - payment_method_token
* - amount
* properties:
* payment_method_token:
* type: string
* description: Token of the bank_account payment method
* amount:
* type: number
* description: Transaction amount in cents
* currency_code:
* type: string
* description: ISO 4217 currency code (default USD)
* responses:
* 200:
* description: ACH purchase created successfully
* 400:
* description: Missing required parameters
* 500:
* description: Error creating purchase
*/
router.post('/ach-purchase', createAchPurchase);

export default router;
Loading
Loading