@mindtdilly / Omnipay-3 / commits / 534d155

docs: ACH funding-method schema + live checkout picker example

Document bank_transfer (ACH) fields and validation on POST /sessions/{id}/funding-method, update the integrate-checkout guide, and add examples/checkout-funding-picker with institutional UI, client-side ABA checks, and optional live Bearer POST (config in localStorage; Preview only skips the network call).

mindtdilly committed Sep 29, 2026 at 01:58 UTC 534d155c11dfbf4038b155dbf8dfebfa222b43a2
8 files changed +1206 -28
api/sessions-funding-method.mdx
+160 -11
@@ -1,10 +1,10 @@
1 ---
2 title: "Funding Method — Bind Payment to Procurement Session"
3 sidebarTitle: "POST /funding-method"
4 -description: "Bind a payment method to an active procurement session. Accepts USDC on-chain, card, or bank transfer. Replaces the deprecated POST /api/payments/route."
4 +description: "Bind a payment method to an active procurement session. Accepts USDC on-chain, card, or ACH bank transfer. Replaces the deprecated POST /api/payments/route."
5 ---
6
7 -Once a procurement session is ready for payment, this endpoint binds a funding method to it. You specify the method type — USDC, card, or bank transfer — and for USDC payments, the target blockchain network. ProcureNet validates the combination against the session's state and the buyer's entitlements, then advances the session toward payment processing. This endpoint replaces the deprecated `POST /api/payments/route` — if your integration still uses that path, follow the migration guidance at the bottom of this page.
7 +Once a procurement session is ready for payment, this endpoint binds a funding method to it. You specify the method type — USDC, card, or bank transfer (ACH) — and supply method-specific fields. For USDC payments, include the target blockchain network. For ACH bank transfers, include routing and account details. ProcureNet validates the combination against the session's state and the buyer's entitlements, then advances the session toward payment processing. This endpoint replaces the deprecated `POST /api/payments/route` — if your integration still uses that path, follow the migration guidance at the bottom of this page.
8
9 ## Authentication
10
@@ -30,8 +30,8 @@ Authorization: Bearer <your_jwt>
30 | Value | Description |
31 |---|---|
32 | `usdc` | On-chain USDC transfer. Requires the `network` field. |
33 - | `card` | Credit or debit card. Network field is ignored. |
34 - | `bank_transfer` | ACH or wire transfer. Network field is ignored. |
33 + | `card` | Credit or debit card. Network and ACH fields are ignored. |
34 + | `bank_transfer` | ACH debit from a US bank account. Requires ACH fields below. Network is ignored. |
35 </ParamField>
36
37 <ParamField body="network" type="string">
@@ -44,6 +44,41 @@ Authorization: Bearer <your_jwt>
44 </Tip>
45 </ParamField>
46
47 +### ACH fields (`method: "bank_transfer"`)
48 +
49 +When `method` is `"bank_transfer"`, include the following fields. They are ignored for `usdc` and `card`.
50 +
51 +<ParamField body="routing_number" type="string" required>
52 + ABA routing transit number for the buyer's US bank. Must be exactly **9 digits**. Leading zeros are significant — send as a string, not a number.
53 +
54 + Example: `"021000021"`
55 +</ParamField>
56 +
57 +<ParamField body="account_number" type="string" required>
58 + The buyer's bank account number. Digits only; typically 4–17 characters. Do not include spaces or hyphens.
59 +
60 + Example: `"123456789012"`
61 +</ParamField>
62 +
63 +<ParamField body="account_type" type="string" required>
64 + The type of bank account to debit. Accepted values:
65 +
66 + | Value | Description |
67 + |---|---|
68 + | `checking` | Checking account |
69 + | `savings` | Savings account |
70 +</ParamField>
71 +
72 +<ParamField body="account_holder_name" type="string" required>
73 + Legal name on the bank account, as it appears at the financial institution. Used for ACH authorization matching. Length 2–100 characters.
74 +
75 + Example: `"Acme Procurement LLC"`
76 +</ParamField>
77 +
78 +<Info>
79 + ACH credentials are transmitted over TLS and stored only as needed to originate the debit. Full account numbers are never returned in API responses — only a masked last-four form is echoed.
80 +</Info>
81 +
82 ## Request Examples
83
84 <CodeGroup>
@@ -89,7 +124,7 @@ const res = await fetch(
124 const result = await res.json();
125 ```
126
92 -```typescript Bank Transfer
127 +```typescript ACH Bank Transfer
128 const sessionId = 'sess_abc123';
129
130 const res = await fetch(
@@ -101,7 +136,11 @@ const res = await fetch(
136 'Content-Type': 'application/json'
137 },
138 body: JSON.stringify({
104 - method: 'bank_transfer'
139 + method: 'bank_transfer',
140 + routing_number: '021000021',
141 + account_number: '123456789012',
142 + account_type: 'checking',
143 + account_holder_name: 'Acme Procurement LLC'
144 })
145 }
146 );
@@ -111,6 +150,22 @@ const result = await res.json();
150
151 </CodeGroup>
152
153 +### cURL — ACH
154 +
155 +```bash
156 +curl -X POST \
157 + https://api.procurenet.io/api/v7/sessions/sess_abc123/funding-method \
158 + -H "Authorization: Bearer $JWT" \
159 + -H "Content-Type: application/json" \
160 + -d '{
161 + "method": "bank_transfer",
162 + "routing_number": "021000021",
163 + "account_number": "123456789012",
164 + "account_type": "checking",
165 + "account_holder_name": "Acme Procurement LLC"
166 + }'
167 +```
168 +
169 ## Response Fields
170
171 <ResponseField name="session_id" type="string" required>
@@ -118,7 +173,7 @@ const result = await res.json();
173 </ResponseField>
174
175 <ResponseField name="method" type="string" required>
121 - Confirms the payment method that was bound to the session.
176 + Confirms the payment method that was bound to the session (`usdc`, `card`, or `bank_transfer`).
177 </ResponseField>
178
179 <ResponseField name="network" type="string">
@@ -126,12 +181,41 @@ const result = await res.json();
181 </ResponseField>
182
183 <ResponseField name="status" type="string" required>
129 - The session status after the funding method was set. Typically `"payment_processing"` once the session advances.
184 + The session status after the funding method was set. Typically `"payment_processing"` once the session advances. For ACH, status may remain `"payment_processing"` until the debit clears (see `ach_status`).
185 </ResponseField>
186
132 -## Response Example
187 +<ResponseField name="account_type" type="string">
188 + Echo of the submitted account type (`checking` or `savings`). Present only when `method` is `"bank_transfer"`.
189 +</ResponseField>
190
134 -```json
191 +<ResponseField name="account_holder_name" type="string">
192 + Echo of the submitted account holder name. Present only when `method` is `"bank_transfer"`.
193 +</ResponseField>
194 +
195 +<ResponseField name="routing_number_last4" type="string">
196 + Last four digits of the ABA routing number. Present only when `method` is `"bank_transfer"`.
197 +</ResponseField>
198 +
199 +<ResponseField name="account_number_last4" type="string">
200 + Last four digits of the bank account number. Present only when `method` is `"bank_transfer"`. Full account numbers are never returned.
201 +</ResponseField>
202 +
203 +<ResponseField name="ach_status" type="string">
204 + ACH-specific processing state. Present only when `method` is `"bank_transfer"`.
205 +
206 + | Value | Description |
207 + |---|---|
208 + | `pending` | Debit originated; awaiting bank confirmation. |
209 + | `submitted` | Sent to the ACH network. |
210 + | `settled` | Funds cleared. |
211 + | `returned` | Debit returned (insufficient funds, invalid account, etc.). |
212 +</ResponseField>
213 +
214 +## Response Examples
215 +
216 +<CodeGroup>
217 +
218 +```json USDC
219 {
220 "session_id": "sess_abc123",
221 "method": "usdc",
@@ -140,6 +224,42 @@ const result = await res.json();
224 }
225 ```
226
227 +```json Card
228 +{
229 + "session_id": "sess_abc123",
230 + "method": "card",
231 + "status": "payment_processing"
232 +}
233 +```
234 +
235 +```json ACH Bank Transfer
236 +{
237 + "session_id": "sess_abc123",
238 + "method": "bank_transfer",
239 + "status": "payment_processing",
240 + "account_type": "checking",
241 + "account_holder_name": "Acme Procurement LLC",
242 + "routing_number_last4": "0021",
243 + "account_number_last4": "9012",
244 + "ach_status": "pending"
245 +}
246 +```
247 +
248 +</CodeGroup>
249 +
250 +## ACH Validation Rules
251 +
252 +| Field | Rule |
253 +|---|---|
254 +| `routing_number` | Exactly 9 digits (`/^\d{9}$/`). Must pass the ABA check-digit algorithm. |
255 +| `account_number` | 4–17 digits (`/^\d{4,17}$/`). |
256 +| `account_type` | One of `checking`, `savings`. |
257 +| `account_holder_name` | 2–100 characters after trim; letters, spaces, hyphens, periods, and apostrophes allowed. |
258 +
259 +<Warning>
260 + Do not send routing or account numbers as JSON numbers. Leading zeros in ABA routing numbers are significant; always use strings.
261 +</Warning>
262 +
263 ## Supported Networks for USDC
264
265 <CardGroup cols={2}>
@@ -187,11 +307,25 @@ Replace your existing single-call payment routing with the two-step v7 session p
307 Take the `session_id` from the orchestrate response and call this endpoint to bind your payment method.
308
309 ```typescript
310 + // USDC
311 await fetch(`/api/v7/sessions/${session.session_id}/funding-method`, {
312 method: 'POST',
313 headers: { 'Authorization': `Bearer ${jwt}` },
314 body: JSON.stringify({ method: 'usdc', network: 'base' })
315 });
316 +
317 + // ACH
318 + await fetch(`/api/v7/sessions/${session.session_id}/funding-method`, {
319 + method: 'POST',
320 + headers: { 'Authorization': `Bearer ${jwt}` },
321 + body: JSON.stringify({
322 + method: 'bank_transfer',
323 + routing_number: '021000021',
324 + account_number: '123456789012',
325 + account_type: 'checking',
326 + account_holder_name: 'Acme Procurement LLC'
327 + })
328 + });
329 ```
330 </Step>
331 </Steps>
@@ -200,9 +334,24 @@ Replace your existing single-call payment routing with the two-step v7 session p
334
335 | HTTP Status | Description |
336 |---|---|
203 -| `400 Bad Request` | Missing required fields, unrecognized `method` value, or `method` is `"usdc"` but `network` is absent or invalid. |
337 +| `400 Bad Request` | Missing required fields; unrecognized `method`; `method` is `"usdc"` but `network` is absent or invalid; or `method` is `"bank_transfer"` with invalid ACH fields (bad routing check digit, wrong length, unknown `account_type`, empty holder name). |
338 | `401 Unauthorized` | Missing, expired, or invalid Bearer JWT. |
339 | `403 Forbidden` | The session state does not permit setting a funding method at this point (for example, escrow acceptance is still required). |
340 | `404 Not Found` | No session exists for the provided `id`. |
341 | `409 Conflict` | A funding method is already set for this session. Transition to a new session if you need to change it. |
342 | `410 Gone` | The session has expired. Start a new session with `POST /api/v7/orchestrate`. |
343 +| `422 Unprocessable Entity` | ACH details failed bank-side pre-validation (for example, routing number not found in the ABA directory). |
344 +
345 +### ACH-specific error body example
346 +
347 +```json
348 +{
349 + "error": {
350 + "code": "invalid_routing_number",
351 + "message": "routing_number must be a valid 9-digit ABA number",
352 + "field": "routing_number"
353 + }
354 +}
355 +```
356 +
357 +Common ACH field error codes: `invalid_routing_number`, `invalid_account_number`, `invalid_account_type`, `invalid_account_holder_name`, `missing_ach_fields`.
docs.json
+1
@@ -34,6 +34,7 @@
34 "group": "Guides",
35 "pages": [
36 "guides/integrate-checkout",
37 + "guides/checkout-funding-picker",
38 "guides/field-evaluation-payments",
39 "guides/webhooks",
40 "guides/escrow-flows",
examples/checkout-funding-picker/README.md new
+60
@@ -0,0 +1,60 @@
1 +# Checkout funding-method picker
2 +
3 +Static demo UI for ProcureNet v7 `POST /api/v7/sessions/{id}/funding-method`.
4 +
5 +Supports:
6 +
7 +- **USDC** — with network select (`base`, `arbitrum`, `polygon`, `ethereum`)
8 +- **Card** — binds `method: "card"`
9 +- **ACH** (`bank_transfer`) — `routing_number`, `account_number`, `account_type`, `account_holder_name`
10 +
11 +On submit, the page validates client-side (including ABA check digit for routing numbers), shows the request JSON, and by default performs a **live** `fetch` POST. Enable **Preview only** to skip the network call.
12 +
13 +## Config (localStorage)
14 +
15 +| Field | localStorage key | Default |
16 +|---|---|---|
17 +| API base URL | `pn.checkoutFunding.apiBase` | `https://api.procurenet.io` |
18 +| Bearer JWT | `pn.checkoutFunding.jwt` | _(empty)_ |
19 +| Session ID | `pn.checkoutFunding.sessionId` | `sess_demo_001` |
20 +| Preview only | `pn.checkoutFunding.previewOnly` | `0` (live POST) |
21 +
22 +Live submit requires a non-empty JWT (`Authorization: Bearer …` + `Content-Type: application/json`). An empty token shows a clear error and does not call the API. No secrets are hardcoded.
23 +
24 +The response panel shows HTTP status and body (JSON pretty-printed when possible; raw text otherwise). Network failures are surfaced in the UI.
25 +
26 +## Open / serve
27 +
28 +From this directory:
29 +
30 +```bash
31 +# Option A — open the file directly
32 +open index.html # macOS
33 +xdg-open index.html # Linux
34 +
35 +# Option B — local static server (recommended; avoids some browser CORS quirks for file://)
36 +python3 -m http.server 8765
37 +# then visit http://localhost:8765/
38 +```
39 +
40 +From the Omnipay repo root:
41 +
42 +```bash
43 +python3 -m http.server 8765 --directory examples/checkout-funding-picker
44 +```
45 +
46 +> **CORS:** the browser will only accept the live response if the API allows your origin. Use Preview only, a local proxy, or an allowed origin when testing against production.
47 +
48 +## Files
49 +
50 +| File | Role |
51 +|---|---|
52 +| `index.html` | Markup, config fields, form structure |
53 +| `styles.css` | Institutional paper / navy styling |
54 +| `app.js` | Config persistence, ACH validation, live POST, response panel |
55 +
56 +## Related docs
57 +
58 +- API: [`api/sessions-funding-method.mdx`](../../api/sessions-funding-method.mdx)
59 +- Guide: [`guides/integrate-checkout.mdx`](../../guides/integrate-checkout.mdx)
60 +- Docs page: [`guides/checkout-funding-picker.mdx`](../../guides/checkout-funding-picker.mdx)
examples/checkout-funding-picker/app.js new
+309
@@ -0,0 +1,309 @@
1 +(function () {
2 + const DEFAULT_API_BASE = 'https://api.procurenet.io';
3 + const DEFAULT_SESSION_ID = 'sess_demo_001';
4 +
5 + const LS = {
6 + apiBase: 'pn.checkoutFunding.apiBase',
7 + jwt: 'pn.checkoutFunding.jwt',
8 + sessionId: 'pn.checkoutFunding.sessionId',
9 + previewOnly: 'pn.checkoutFunding.previewOnly',
10 + };
11 +
12 + const methodInputs = document.querySelectorAll('input[name="method"]');
13 + const panels = document.querySelectorAll('[data-panel]');
14 + const formError = document.getElementById('formError');
15 + const payloadOut = document.getElementById('payloadOut');
16 + const payloadBadge = document.getElementById('payloadBadge');
17 + const endpointPath = document.getElementById('endpointPath');
18 + const sessionIdDisplay = document.getElementById('sessionIdDisplay');
19 + const submitBtn = document.getElementById('submitBtn');
20 + const resetBtn = document.getElementById('resetBtn');
21 + const apiBaseInput = document.getElementById('apiBase');
22 + const bearerJwtInput = document.getElementById('bearerJwt');
23 + const sessionIdInput = document.getElementById('sessionId');
24 + const previewOnlyInput = document.getElementById('previewOnly');
25 + const responseBadge = document.getElementById('responseBadge');
26 + const responseMeta = document.getElementById('responseMeta');
27 + const responseOut = document.getElementById('responseOut');
28 + const requestHint = document.getElementById('requestHint');
29 +
30 + function loadConfig() {
31 + apiBaseInput.value = localStorage.getItem(LS.apiBase) || DEFAULT_API_BASE;
32 + bearerJwtInput.value = localStorage.getItem(LS.jwt) || '';
33 + sessionIdInput.value = localStorage.getItem(LS.sessionId) || DEFAULT_SESSION_ID;
34 + previewOnlyInput.checked = localStorage.getItem(LS.previewOnly) === '1';
35 + syncEndpoint();
36 + syncSubmitLabel();
37 + }
38 +
39 + function persistConfig() {
40 + localStorage.setItem(LS.apiBase, apiBaseInput.value.trim() || DEFAULT_API_BASE);
41 + localStorage.setItem(LS.jwt, bearerJwtInput.value.trim());
42 + localStorage.setItem(LS.sessionId, sessionIdInput.value.trim() || DEFAULT_SESSION_ID);
43 + localStorage.setItem(LS.previewOnly, previewOnlyInput.checked ? '1' : '0');
44 + }
45 +
46 + function getConfig() {
47 + const apiBase = (apiBaseInput.value.trim() || DEFAULT_API_BASE).replace(/\/+$/, '');
48 + const jwt = bearerJwtInput.value.trim();
49 + const sessionId = sessionIdInput.value.trim() || DEFAULT_SESSION_ID;
50 + const previewOnly = previewOnlyInput.checked;
51 + return { apiBase, jwt, sessionId, previewOnly };
52 + }
53 +
54 + function syncEndpoint() {
55 + const { sessionId } = getConfig();
56 + const path = `/api/v7/sessions/${sessionId}/funding-method`;
57 + endpointPath.textContent = path;
58 + sessionIdDisplay.textContent = sessionId;
59 + }
60 +
61 + function syncSubmitLabel() {
62 + submitBtn.textContent = previewOnlyInput.checked
63 + ? 'Preview funding-method request'
64 + : 'Submit funding method';
65 + requestHint.textContent = previewOnlyInput.checked
66 + ? 'Preview only is on — JSON is built locally; no network call.'
67 + : 'Live POST uses Authorization: Bearer and Content-Type: application/json. Enable Preview only to skip the network call.';
68 + }
69 +
70 + function selectedMethod() {
71 + const checked = document.querySelector('input[name="method"]:checked');
72 + return checked ? checked.value : 'usdc';
73 + }
74 +
75 + function showPanel(method) {
76 + panels.forEach((panel) => {
77 + panel.hidden = panel.dataset.panel !== method;
78 + });
79 + }
80 +
81 + function setError(message) {
82 + if (!message) {
83 + formError.hidden = true;
84 + formError.textContent = '';
85 + return;
86 + }
87 + formError.hidden = false;
88 + formError.textContent = message;
89 + }
90 +
91 + function abaCheckDigitValid(routing) {
92 + if (!/^\d{9}$/.test(routing)) return false;
93 + const d = routing.split('').map(Number);
94 + const sum =
95 + 3 * (d[0] + d[3] + d[6]) +
96 + 7 * (d[1] + d[4] + d[7]) +
97 + 1 * (d[2] + d[5] + d[8]);
98 + return sum % 10 === 0;
99 + }
100 +
101 + function buildBody() {
102 + const method = selectedMethod();
103 + const body = { method };
104 +
105 + if (method === 'usdc') {
106 + body.network = document.getElementById('network').value;
107 + return { ok: true, body };
108 + }
109 +
110 + if (method === 'card') {
111 + return { ok: true, body };
112 + }
113 +
114 + const routing_number = document.getElementById('routing_number').value.trim();
115 + const account_number = document.getElementById('account_number').value.trim();
116 + const account_type = document.getElementById('account_type').value;
117 + const account_holder_name = document
118 + .getElementById('account_holder_name')
119 + .value.trim();
120 +
121 + if (!/^\d{9}$/.test(routing_number)) {
122 + return { ok: false, error: 'routing_number must be exactly 9 digits (ABA).' };
123 + }
124 + if (!abaCheckDigitValid(routing_number)) {
125 + return {
126 + ok: false,
127 + error: 'routing_number failed the ABA check-digit validation.',
128 + };
129 + }
130 + if (!/^\d{4,17}$/.test(account_number)) {
131 + return { ok: false, error: 'account_number must be 4–17 digits.' };
132 + }
133 + if (account_type !== 'checking' && account_type !== 'savings') {
134 + return { ok: false, error: 'account_type must be checking or savings.' };
135 + }
136 + if (account_holder_name.length < 2 || account_holder_name.length > 100) {
137 + return {
138 + ok: false,
139 + error: 'account_holder_name must be between 2 and 100 characters.',
140 + };
141 + }
142 +
143 + body.routing_number = routing_number;
144 + body.account_number = account_number;
145 + body.account_type = account_type;
146 + body.account_holder_name = account_holder_name;
147 + return { ok: true, body };
148 + }
149 +
150 + function resetResponse() {
151 + responseBadge.textContent = '—';
152 + responseBadge.className = 'badge';
153 + responseMeta.textContent = 'No response yet.';
154 + responseOut.textContent = '{ /* submit to see status and body */ }';
155 + }
156 +
157 + function renderRequest(body) {
158 + payloadOut.textContent = JSON.stringify(body, null, 2);
159 + }
160 +
161 + function formatResponseBody(text) {
162 + const trimmed = (text || '').trim();
163 + if (!trimmed) return '(empty body)';
164 + try {
165 + return JSON.stringify(JSON.parse(trimmed), null, 2);
166 + } catch {
167 + return trimmed;
168 + }
169 + }
170 +
171 + async function submitLive(body, config) {
172 + const url = `${config.apiBase}/api/v7/sessions/${encodeURIComponent(config.sessionId)}/funding-method`;
173 + payloadBadge.textContent = 'Sending';
174 + payloadBadge.className = 'badge';
175 + responseBadge.textContent = '…';
176 + responseBadge.className = 'badge';
177 + responseMeta.textContent = `POST ${url}`;
178 + responseOut.textContent = '{ /* waiting */ }';
179 + submitBtn.disabled = true;
180 +
181 + try {
182 + const res = await fetch(url, {
183 + method: 'POST',
184 + headers: {
185 + Authorization: `Bearer ${config.jwt}`,
186 + 'Content-Type': 'application/json',
187 + Accept: 'application/json',
188 + },
189 + body: JSON.stringify(body),
190 + });
191 +
192 + const raw = await res.text();
193 + const pretty = formatResponseBody(raw);
194 + responseMeta.textContent = `HTTP ${res.status} ${res.statusText || ''} · ${url}`.trim();
195 + responseOut.textContent = pretty;
196 + responseBadge.textContent = String(res.status);
197 + responseBadge.className = res.ok ? 'badge ok' : 'badge err';
198 + payloadBadge.textContent = res.ok ? 'Sent' : 'Sent (error)';
199 + payloadBadge.className = res.ok ? 'badge ok' : 'badge err';
200 + } catch (err) {
201 + const message = err && err.message ? err.message : String(err);
202 + responseBadge.textContent = 'Network';
203 + responseBadge.className = 'badge err';
204 + responseMeta.textContent = `Network error · ${url}`;
205 + responseOut.textContent = message;
206 + payloadBadge.textContent = 'Failed';
207 + payloadBadge.className = 'badge err';
208 + setError(`Network error: ${message}`);
209 + } finally {
210 + submitBtn.disabled = false;
211 + }
212 + }
213 +
214 + async function onSubmit() {
215 + persistConfig();
216 + syncEndpoint();
217 + const config = getConfig();
218 + const result = buildBody();
219 +
220 + if (!result.ok) {
221 + setError(result.error);
222 + payloadBadge.textContent = 'Invalid';
223 + payloadBadge.className = 'badge err';
224 + payloadOut.textContent = '{\n /* fix validation errors, then submit again */\n}';
225 + resetResponse();
226 + return;
227 + }
228 +
229 + setError('');
230 + renderRequest(result.body);
231 +
232 + if (config.previewOnly) {
233 + payloadBadge.textContent = 'Preview';
234 + payloadBadge.className = 'badge ok';
235 + responseBadge.textContent = 'Skipped';
236 + responseBadge.className = 'badge';
237 + responseMeta.textContent = 'Preview only — no network call.';
238 + responseOut.textContent = '{ /* preview mode */ }';
239 + console.info('[checkout-funding-picker] preview POST', {
240 + url: `${config.apiBase}/api/v7/sessions/${config.sessionId}/funding-method`,
241 + body: result.body,
242 + });
243 + return;
244 + }
245 +
246 + if (!config.jwt) {
247 + setError('Bearer JWT is required for live POST. Paste a token, or enable Preview only.');
248 + payloadBadge.textContent = 'Blocked';
249 + payloadBadge.className = 'badge err';
250 + resetResponse();
251 + responseMeta.textContent = 'Blocked — empty JWT.';
252 + responseOut.textContent = '{ /* set Bearer JWT or enable Preview only */ }';
253 + return;
254 + }
255 +
256 + if (!config.apiBase) {
257 + setError('API base URL is required.');
258 + payloadBadge.textContent = 'Blocked';
259 + payloadBadge.className = 'badge err';
260 + return;
261 + }
262 +
263 + await submitLive(result.body, config);
264 + }
265 +
266 + methodInputs.forEach((input) => {
267 + input.addEventListener('change', () => {
268 + showPanel(selectedMethod());
269 + setError('');
270 + });
271 + });
272 +
273 + [apiBaseInput, bearerJwtInput, sessionIdInput].forEach((el) => {
274 + el.addEventListener('change', () => {
275 + persistConfig();
276 + syncEndpoint();
277 + });
278 + el.addEventListener('input', () => {
279 + if (el === sessionIdInput || el === apiBaseInput) syncEndpoint();
280 + });
281 + });
282 +
283 + previewOnlyInput.addEventListener('change', () => {
284 + persistConfig();
285 + syncSubmitLabel();
286 + });
287 +
288 + submitBtn.addEventListener('click', () => {
289 + onSubmit();
290 + });
291 +
292 + resetBtn.addEventListener('click', () => {
293 + document.querySelector('input[name="method"][value="usdc"]').checked = true;
294 + document.getElementById('network').value = 'base';
295 + document.getElementById('routing_number').value = '';
296 + document.getElementById('account_number').value = '';
297 + document.getElementById('account_type').value = 'checking';
298 + document.getElementById('account_holder_name').value = '';
299 + showPanel('usdc');
300 + setError('');
301 + payloadBadge.textContent = 'Idle';
302 + payloadBadge.className = 'badge';
303 + payloadOut.textContent = '{ /* choose a method and submit */ }';
304 + resetResponse();
305 + });
306 +
307 + loadConfig();
308 + showPanel(selectedMethod());
309 +})();
examples/checkout-funding-picker/index.html new
+155
@@ -0,0 +1,155 @@
1 +<!DOCTYPE html>
2 +<html lang="en">
3 +<head>
4 + <meta charset="UTF-8" />
5 + <meta name="viewport" content="width=device-width, initial-scale=1" />
6 + <title>ProcureNet — Funding Method Picker</title>
7 + <link rel="stylesheet" href="styles.css" />
8 +</head>
9 +<body>
10 + <div class="page">
11 + <header class="header">
12 + <div class="brand">
13 + <span class="brand-mark" aria-hidden="true"></span>
14 + <div>
15 + <p class="eyebrow">ProcureNet v7</p>
16 + <h1>Checkout funding method</h1>
17 + </div>
18 + </div>
19 + <p class="header-meta">Session <code id="sessionIdDisplay">sess_demo_001</code></p>
20 + </header>
21 +
22 + <main class="card">
23 + <section class="section">
24 + <h2>API connection</h2>
25 + <p class="muted">Values persist in <code>localStorage</code>. Leave JWT empty only when using Preview only — live submit requires a Bearer token.</p>
26 + <div class="field-grid">
27 + <label class="field field-span">
28 + <span>API base URL</span>
29 + <input id="apiBase" name="apiBase" type="url" autocomplete="off" placeholder="https://api.procurenet.io" />
30 + </label>
31 + <label class="field field-span">
32 + <span>Bearer JWT <em>(never hardcoded)</em></span>
33 + <input id="bearerJwt" name="bearerJwt" type="password" autocomplete="off" placeholder="Paste access token" />
34 + </label>
35 + <label class="field field-span">
36 + <span>Session ID</span>
37 + <input id="sessionId" name="sessionId" autocomplete="off" placeholder="sess_demo_001" />
38 + </label>
39 + </div>
40 + <label class="toggle">
41 + <input type="checkbox" id="previewOnly" name="previewOnly" />
42 + <span>Preview only — build JSON, do not call the API</span>
43 + </label>
44 + </section>
45 +
46 + <section class="section">
47 + <h2>Payment method</h2>
48 + <p class="muted">Choose how this procurement session will be funded. The payload below mirrors <code>POST /api/v7/sessions/{id}/funding-method</code>.</p>
49 +
50 + <div class="method-grid" role="radiogroup" aria-label="Payment method">
51 + <label class="method-option">
52 + <input type="radio" name="method" value="usdc" checked />
53 + <span class="method-face">
54 + <span class="method-title">USDC</span>
55 + <span class="method-sub">On-chain stablecoin</span>
56 + </span>
57 + </label>
58 + <label class="method-option">
59 + <input type="radio" name="method" value="card" />
60 + <span class="method-face">
61 + <span class="method-title">Card</span>
62 + <span class="method-sub">Credit or debit</span>
63 + </span>
64 + </label>
65 + <label class="method-option">
66 + <input type="radio" name="method" value="bank_transfer" />
67 + <span class="method-face">
68 + <span class="method-title">ACH</span>
69 + <span class="method-sub">US bank transfer</span>
70 + </span>
71 + </label>
72 + </div>
73 + </section>
74 +
75 + <section class="section panel" id="panel-usdc" data-panel="usdc">
76 + <h3>Network</h3>
77 + <label class="field">
78 + <span>Blockchain network</span>
79 + <select id="network" name="network">
80 + <option value="base" selected>Base (recommended)</option>
81 + <option value="arbitrum">Arbitrum</option>
82 + <option value="polygon">Polygon</option>
83 + <option value="ethereum">Ethereum</option>
84 + </select>
85 + </label>
86 + </section>
87 +
88 + <section class="section panel" id="panel-card" data-panel="card" hidden>
89 + <h3>Card</h3>
90 + <p class="muted">Card collection is handled by your PCI-compliant vault in production. This demo only binds <code>method: "card"</code>.</p>
91 + </section>
92 +
93 + <section class="section panel" id="panel-ach" data-panel="bank_transfer" hidden>
94 + <h3>ACH bank account</h3>
95 + <div class="field-grid">
96 + <label class="field">
97 + <span>Routing number <em>(ABA, 9 digits)</em></span>
98 + <input id="routing_number" name="routing_number" inputmode="numeric" autocomplete="off" maxlength="9" placeholder="021000021" />
99 + </label>
100 + <label class="field">
101 + <span>Account number</span>
102 + <input id="account_number" name="account_number" inputmode="numeric" autocomplete="off" maxlength="17" placeholder="123456789012" />
103 + </label>
104 + <label class="field">
105 + <span>Account type</span>
106 + <select id="account_type" name="account_type">
107 + <option value="checking" selected>Checking</option>
108 + <option value="savings">Savings</option>
109 + </select>
110 + </label>
111 + <label class="field field-span">
112 + <span>Account holder name</span>
113 + <input id="account_holder_name" name="account_holder_name" autocomplete="name" maxlength="100" placeholder="Acme Procurement LLC" />
114 + </label>
115 + </div>
116 + <p class="hint">Send routing and account numbers as strings so leading zeros are preserved. Full account numbers are never returned by the API.</p>
117 + </section>
118 +
119 + <p class="form-error" id="formError" hidden role="alert"></p>
120 +
121 + <div class="actions">
122 + <button type="button" class="btn btn-primary" id="submitBtn">Submit funding method</button>
123 + <button type="button" class="btn btn-ghost" id="resetBtn">Reset form</button>
124 + </div>
125 + </main>
126 +
127 + <aside class="card payload-card">
128 + <div class="payload-header">
129 + <h2>Request</h2>
130 + <span class="badge" id="payloadBadge">Idle</span>
131 + </div>
132 + <p class="muted endpoint">
133 + <span class="verb">POST</span>
134 + <code id="endpointPath">/api/v7/sessions/sess_demo_001/funding-method</code>
135 + </p>
136 + <pre id="payloadOut" class="payload" aria-live="polite">{ /* choose a method and submit */ }</pre>
137 + <p class="hint" id="requestHint">Live POST uses Authorization: Bearer and Content-Type: application/json. Enable Preview only to skip the network call.</p>
138 + </aside>
139 +
140 + <aside class="card payload-card" id="responseCard">
141 + <div class="payload-header">
142 + <h2>Response</h2>
143 + <span class="badge" id="responseBadge">—</span>
144 + </div>
145 + <p class="muted" id="responseMeta">No response yet.</p>
146 + <pre id="responseOut" class="payload" aria-live="polite">{ /* submit to see status and body */ }</pre>
147 + </aside>
148 +
149 + <footer class="footer">
150 + <p>Institutional checkout demo · Omnipay / ProcureNet docs</p>
151 + </footer>
152 + </div>
153 + <script src="app.js"></script>
154 +</body>
155 +</html>
examples/checkout-funding-picker/styles.css new
+419
@@ -0,0 +1,419 @@
1 +:root {
2 + --paper: #f7f5f0;
3 + --surface: #ffffff;
4 + --ink: #0f1b2d;
5 + --ink-muted: #5a6575;
6 + --navy: #1a2b4a;
7 + --navy-deep: #0d1a33;
8 + --accent: #1e3a5f;
9 + --hairline: #d9d4c8;
10 + --hairline-strong: #c4bdb0;
11 + --focus: #2c5282;
12 + --error: #8b1e1e;
13 + --error-bg: #f8ecec;
14 + --radius: 6px;
15 + --shadow: 0 1px 2px rgba(15, 27, 45, 0.04), 0 8px 24px rgba(15, 27, 45, 0.06);
16 + --font: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif;
17 + --mono: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
18 + --sans: "IBM Plex Sans", "Segoe UI", system-ui, -apple-system, sans-serif;
19 +}
20 +
21 +*,
22 +*::before,
23 +*::after {
24 + box-sizing: border-box;
25 +}
26 +
27 +html {
28 + color-scheme: light;
29 +}
30 +
31 +body {
32 + margin: 0;
33 + min-height: 100vh;
34 + background:
35 + linear-gradient(180deg, #faf8f4 0%, var(--paper) 40%, #f0ebe3 100%);
36 + color: var(--ink);
37 + font-family: var(--sans);
38 + font-size: 15px;
39 + line-height: 1.5;
40 + -webkit-font-smoothing: antialiased;
41 +}
42 +
43 +.page {
44 + max-width: 720px;
45 + margin: 0 auto;
46 + padding: 2.5rem 1.25rem 3rem;
47 +}
48 +
49 +.header {
50 + display: flex;
51 + justify-content: space-between;
52 + align-items: flex-end;
53 + gap: 1rem;
54 + margin-bottom: 1.5rem;
55 + padding-bottom: 1.25rem;
56 + border-bottom: 1px solid var(--hairline);
57 +}
58 +
59 +.brand {
60 + display: flex;
61 + gap: 0.9rem;
62 + align-items: flex-start;
63 +}
64 +
65 +.brand-mark {
66 + width: 12px;
67 + height: 36px;
68 + margin-top: 0.35rem;
69 + background: var(--navy);
70 + border-radius: 1px;
71 +}
72 +
73 +.eyebrow {
74 + margin: 0 0 0.15rem;
75 + font-size: 0.7rem;
76 + letter-spacing: 0.14em;
77 + text-transform: uppercase;
78 + color: var(--ink-muted);
79 + font-weight: 600;
80 +}
81 +
82 +h1 {
83 + margin: 0;
84 + font-family: var(--font);
85 + font-size: 1.75rem;
86 + font-weight: 600;
87 + letter-spacing: -0.02em;
88 + color: var(--navy-deep);
89 +}
90 +
91 +.header-meta {
92 + margin: 0;
93 + font-size: 0.85rem;
94 + color: var(--ink-muted);
95 +}
96 +
97 +.header-meta code,
98 +code {
99 + font-family: var(--mono);
100 + font-size: 0.82em;
101 + background: rgba(26, 43, 74, 0.06);
102 + padding: 0.1em 0.35em;
103 + border-radius: 3px;
104 +}
105 +
106 +.card {
107 + background: var(--surface);
108 + border: 1px solid var(--hairline);
109 + border-radius: var(--radius);
110 + box-shadow: var(--shadow);
111 + padding: 1.5rem 1.5rem 1.35rem;
112 + margin-bottom: 1rem;
113 +}
114 +
115 +.section + .section {
116 + margin-top: 1.35rem;
117 + padding-top: 1.35rem;
118 + border-top: 1px solid var(--hairline);
119 +}
120 +
121 +h2 {
122 + margin: 0 0 0.35rem;
123 + font-family: var(--font);
124 + font-size: 1.15rem;
125 + font-weight: 600;
126 + color: var(--navy-deep);
127 +}
128 +
129 +h3 {
130 + margin: 0 0 0.75rem;
131 + font-size: 0.78rem;
132 + font-weight: 650;
133 + letter-spacing: 0.08em;
134 + text-transform: uppercase;
135 + color: var(--navy);
136 +}
137 +
138 +.muted {
139 + margin: 0 0 1rem;
140 + color: var(--ink-muted);
141 + font-size: 0.92rem;
142 +}
143 +
144 +.method-grid {
145 + display: grid;
146 + grid-template-columns: repeat(3, 1fr);
147 + gap: 0.65rem;
148 +}
149 +
150 +.method-option {
151 + cursor: pointer;
152 + margin: 0;
153 +}
154 +
155 +.method-option input {
156 + position: absolute;
157 + opacity: 0;
158 + pointer-events: none;
159 +}
160 +
161 +.method-face {
162 + display: flex;
163 + flex-direction: column;
164 + gap: 0.15rem;
165 + padding: 0.9rem 0.85rem;
166 + border: 1px solid var(--hairline-strong);
167 + border-radius: var(--radius);
168 + background: #fcfbf8;
169 + transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
170 +}
171 +
172 +.method-option input:focus-visible + .method-face {
173 + outline: 2px solid var(--focus);
174 + outline-offset: 2px;
175 +}
176 +
177 +.method-option input:checked + .method-face {
178 + border-color: var(--navy);
179 + background: #fff;
180 + box-shadow: inset 0 0 0 1px var(--navy);
181 +}
182 +
183 +.method-title {
184 + font-weight: 650;
185 + color: var(--navy-deep);
186 + font-size: 0.95rem;
187 +}
188 +
189 +.method-sub {
190 + font-size: 0.78rem;
191 + color: var(--ink-muted);
192 +}
193 +
194 +.field-grid {
195 + display: grid;
196 + grid-template-columns: 1fr 1fr;
197 + gap: 0.85rem 0.75rem;
198 +}
199 +
200 +.field-span {
201 + grid-column: 1 / -1;
202 +}
203 +
204 +.field {
205 + display: flex;
206 + flex-direction: column;
207 + gap: 0.35rem;
208 + font-size: 0.82rem;
209 + color: var(--ink-muted);
210 +}
211 +
212 +.field span em {
213 + font-style: normal;
214 + opacity: 0.75;
215 +}
216 +
217 +.field input,
218 +.field select {
219 + appearance: none;
220 + font: inherit;
221 + font-size: 0.95rem;
222 + color: var(--ink);
223 + background: #fff;
224 + border: 1px solid var(--hairline-strong);
225 + border-radius: 4px;
226 + padding: 0.55rem 0.65rem;
227 + transition: border-color 0.15s ease, box-shadow 0.15s ease;
228 +}
229 +
230 +.field select {
231 + background-image: linear-gradient(45deg, transparent 50%, var(--navy) 50%),
232 + linear-gradient(135deg, var(--navy) 50%, transparent 50%);
233 + background-position: calc(100% - 16px) 50%, calc(100% - 11px) 50%;
234 + background-size: 5px 5px, 5px 5px;
235 + background-repeat: no-repeat;
236 + padding-right: 2rem;
237 +}
238 +
239 +.field input:focus,
240 +.field select:focus {
241 + outline: none;
242 + border-color: var(--focus);
243 + box-shadow: 0 0 0 3px rgba(44, 82, 130, 0.15);
244 +}
245 +
246 +.hint {
247 + margin: 0.85rem 0 0;
248 + font-size: 0.8rem;
249 + color: var(--ink-muted);
250 +}
251 +
252 +.form-error {
253 + margin: 1rem 0 0;
254 + padding: 0.65rem 0.75rem;
255 + background: var(--error-bg);
256 + border: 1px solid #e0c4c4;
257 + border-radius: 4px;
258 + color: var(--error);
259 + font-size: 0.88rem;
260 +}
261 +
262 +.actions {
263 + display: flex;
264 + gap: 0.6rem;
265 + margin-top: 1.35rem;
266 + padding-top: 1.15rem;
267 + border-top: 1px solid var(--hairline);
268 +}
269 +
270 +.btn {
271 + font: inherit;
272 + font-size: 0.9rem;
273 + font-weight: 600;
274 + border-radius: 4px;
275 + padding: 0.6rem 1rem;
276 + cursor: pointer;
277 + border: 1px solid transparent;
278 + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
279 +}
280 +
281 +.btn-primary {
282 + background: var(--navy);
283 + color: #f7f5f0;
284 + border-color: var(--navy-deep);
285 +}
286 +
287 +.btn-primary:hover {
288 + background: var(--navy-deep);
289 +}
290 +
291 +.btn-ghost {
292 + background: transparent;
293 + color: var(--ink-muted);
294 + border-color: var(--hairline-strong);
295 +}
296 +
297 +.btn-ghost:hover {
298 + color: var(--ink);
299 + border-color: var(--navy);
300 +}
301 +
302 +.payload-card {
303 + background: #fbfaf7;
304 +}
305 +
306 +.payload-header {
307 + display: flex;
308 + align-items: center;
309 + justify-content: space-between;
310 + gap: 0.75rem;
311 + margin-bottom: 0.5rem;
312 +}
313 +
314 +.badge {
315 + font-size: 0.68rem;
316 + font-weight: 650;
317 + letter-spacing: 0.08em;
318 + text-transform: uppercase;
319 + color: var(--navy);
320 + border: 1px solid var(--hairline-strong);
321 + padding: 0.2rem 0.45rem;
322 + border-radius: 999px;
323 + background: #fff;
324 +}
325 +
326 +.badge.ok {
327 + color: #1a4d2e;
328 + border-color: #b7d0c0;
329 + background: #eef6f0;
330 +}
331 +
332 +.badge.err {
333 + color: var(--error);
334 + border-color: #e0c4c4;
335 + background: var(--error-bg);
336 +}
337 +
338 +.endpoint {
339 + display: flex;
340 + align-items: center;
341 + gap: 0.5rem;
342 + flex-wrap: wrap;
343 +}
344 +
345 +.verb {
346 + font-family: var(--mono);
347 + font-size: 0.72rem;
348 + font-weight: 700;
349 + letter-spacing: 0.04em;
350 + color: #fff;
351 + background: var(--navy);
352 + padding: 0.15rem 0.4rem;
353 + border-radius: 3px;
354 +}
355 +
356 +.payload {
357 + margin: 0.75rem 0 0;
358 + padding: 1rem;
359 + background: var(--navy-deep);
360 + color: #e8e4dc;
361 + border-radius: 4px;
362 + font-family: var(--mono);
363 + font-size: 0.8rem;
364 + line-height: 1.55;
365 + overflow-x: auto;
366 + white-space: pre-wrap;
367 + word-break: break-word;
368 +}
369 +
370 +.footer {
371 + margin-top: 1.5rem;
372 + text-align: center;
373 + color: var(--ink-muted);
374 + font-size: 0.78rem;
375 +}
376 +
377 +.footer p {
378 + margin: 0;
379 +}
380 +
381 +@media (max-width: 560px) {
382 + .header {
383 + flex-direction: column;
384 + align-items: flex-start;
385 + }
386 +
387 + .method-grid,
388 + .field-grid {
389 + grid-template-columns: 1fr;
390 + }
391 +
392 + .actions {
393 + flex-direction: column;
394 + }
395 +
396 + .btn {
397 + width: 100%;
398 + }
399 +}
400 +
401 +.toggle {
402 + display: flex;
403 + align-items: flex-start;
404 + gap: 0.55rem;
405 + margin-top: 1rem;
406 + font-size: 0.88rem;
407 + color: var(--ink);
408 + cursor: pointer;
409 +}
410 +
411 +.toggle input {
412 + margin-top: 0.2rem;
413 + accent-color: var(--navy);
414 +}
415 +
416 +.btn-primary:disabled {
417 + opacity: 0.65;
418 + cursor: wait;
419 +}
guides/checkout-funding-picker.mdx new
+37
@@ -0,0 +1,37 @@
1 +---
2 +title: "Funding Method Picker Example"
3 +sidebarTitle: "Funding Method Picker"
4 +description: "Checkout UI demo for binding USDC, card, or ACH bank_transfer to a ProcureNet v7 session, with optional live POST."
5 +---
6 +
7 +This repository includes a self-contained funding-method picker under `examples/checkout-funding-picker/`. Use it as a reference when building buyer-facing checkout that calls `POST /api/v7/sessions/{id}/funding-method`.
8 +
9 +## What it demonstrates
10 +
11 +| Method | UI | Request body |
12 +|---|---|---|
13 +| `usdc` | Network select (`base`, `arbitrum`, `polygon`, `ethereum`) | `{ method, network }` |
14 +| `card` | Method selection only | `{ method: "card" }` |
15 +| `bank_transfer` | Routing number, account number, account type, account holder name | Full ACH fields |
16 +
17 +The demo validates ACH fields client-side (9-digit ABA routing with check digit, 4–17 digit account number, `checking` \| `savings`, holder name length), previews the JSON body, and can **POST live** to your API base URL with a Bearer JWT.
18 +
19 +## Config
20 +
21 +Enter API base URL (default `https://api.procurenet.io`), Bearer JWT, and Session ID in the UI. Values persist in `localStorage` under `pn.checkoutFunding.*`. Enable **Preview only** to skip the network call. An empty JWT blocks live submit with a clear error — no secrets are hardcoded.
22 +
23 +## Run the demo
24 +
25 +```bash
26 +cd examples/checkout-funding-picker
27 +python3 -m http.server 8765
28 +```
29 +
30 +Open [http://localhost:8765/](http://localhost:8765/).
31 +
32 +Or open `examples/checkout-funding-picker/index.html` directly in a browser.
33 +
34 +## Related
35 +
36 +- [POST /funding-method](/api/sessions-funding-method) — full request/response and ACH validation rules
37 +- [Integrate Checkout](/guides/integrate-checkout) — end-to-end session flow
guides/integrate-checkout.mdx
+65 -17
@@ -124,24 +124,72 @@ const prefillData = await prefillRes.json();
124
125 ### Set the Funding Method
126
127 -Once the buyer has selected a payment method, register it against the session by calling `POST /api/v7/sessions/{id}/funding-method`. This endpoint requires a Bearer JWT.
127 +Once the buyer has selected a payment method, register it against the session by calling `POST /api/v7/sessions/{id}/funding-method`. This endpoint requires a Bearer JWT. Supported methods are `usdc` (with `network`), `card`, and `bank_transfer` (ACH).
128
129 -```typescript
130 -const fundingRes = await fetch(
131 - `https://api.procurenet.io/api/v7/sessions/${session_id}/funding-method`,
132 - {
133 - method: 'POST',
134 - headers: {
135 - 'Content-Type': 'application/json',
136 - 'Authorization': `Bearer ${token}`
137 - },
138 - body: JSON.stringify({
139 - type: 'card',
140 - token: 'pm_tok_visa_4242'
141 - })
142 - }
143 -);
144 -```
129 +See [POST /funding-method](/api/sessions-funding-method) for the full field reference. A static funding-method picker demo lives at `examples/checkout-funding-picker/` in the docs repo.
130 +
131 +<Tabs>
132 + <Tab title="USDC">
133 + ```typescript
134 + const fundingRes = await fetch(
135 + `https://api.procurenet.io/api/v7/sessions/${session_id}/funding-method`,
136 + {
137 + method: 'POST',
138 + headers: {
139 + 'Content-Type': 'application/json',
140 + 'Authorization': `Bearer ${token}`
141 + },
142 + body: JSON.stringify({
143 + method: 'usdc',
144 + network: 'base' // base | arbitrum | polygon | ethereum
145 + })
146 + }
147 + );
148 + ```
149 + </Tab>
150 + <Tab title="Card">
151 + ```typescript
152 + const fundingRes = await fetch(
153 + `https://api.procurenet.io/api/v7/sessions/${session_id}/funding-method`,
154 + {
155 + method: 'POST',
156 + headers: {
157 + 'Content-Type': 'application/json',
158 + 'Authorization': `Bearer ${token}`
159 + },
160 + body: JSON.stringify({
161 + method: 'card'
162 + })
163 + }
164 + );
165 + ```
166 + </Tab>
167 + <Tab title="ACH (bank_transfer)">
168 + ```typescript
169 + const fundingRes = await fetch(
170 + `https://api.procurenet.io/api/v7/sessions/${session_id}/funding-method`,
171 + {
172 + method: 'POST',
173 + headers: {
174 + 'Content-Type': 'application/json',
175 + 'Authorization': `Bearer ${token}`
176 + },
177 + body: JSON.stringify({
178 + method: 'bank_transfer',
179 + routing_number: '021000021', // ABA, 9 digits (string)
180 + account_number: '123456789012',
181 + account_type: 'checking', // checking | savings
182 + account_holder_name: 'Acme Procurement LLC'
183 + })
184 + }
185 + );
186 + ```
187 +
188 + <Note>
189 + Always send `routing_number` and `account_number` as strings so leading zeros are preserved. Full account numbers are never returned in responses — only `routing_number_last4` and `account_number_last4`.
190 + </Note>
191 + </Tab>
192 +</Tabs>
193
194 ### Advance Through Session States
195