Version check (run this first)
Before announcing anything or starting the flow, confirm this skill is current:
- Read this skill's version from the
metadata.versionfield in the frontmatter above. - Fetch the published copy and read its
metadata.version:https://raw.githubusercontent.com/payroc/skills/main/plugins/payroc/transaction/skills/take-an-ach-payment/SKILL.md - Compare the two as semantic versions:
-
This version >= published → continue silently, no message. (A developer running an unreleased newer version is expected and fine.)
-
This version < published → tell the developer:
⚠️ A newer version of this skill (v<published>) has been published — you're running v<current>. Upgrading is recommended for the best results.
Then ask whether they'd like to continue with the current version or stop and upgrade first, and honour their answer.
-
Couldn't fetch (offline, network error, 404) → note briefly that the version couldn't be verified and continue.
-
On first invocation, announce to the developer:
Payroc ACH / Bank Transfer Payments I'll guide you through accepting payments directly from your customers' bank accounts using the Payroc Bank Transfer Payments API.
What this covers:
- ACH payments (US bank accounts — routing number + account number)
- PAD payments (Canadian bank accounts — transit number + institution number)
- Tokenizing bank details for future charges
- Reversals (void before settlement), refunds, and re-presenting returned ACH transactions
What to know upfront:
- ACH payments are asynchronous —
statusis initiallypendingand settles later- PAD representment is currently unavailable in the Payroc UAT environment
- ACH re-presentment has limited testability in UAT — document this in production code
- Account numbers and routing numbers are always masked in API responses (last 4 digits only)
eCheck note: In the Payroc API, eCheck is implemented as ACH — there is no separate eCheck product or additional micro-deposit verification step. A developer expecting a bank-verification handshake before the first charge (common in other payment platforms) should be informed that Payroc ACH proceeds directly to the transaction.
[If an MCP connection-check tool is available, run it here and surface the result before continuing.]
Quick reference
POST https://api.uat.payroc.com/v1/bank-transfer-payments
Authorization: Bearer <token>
Idempotency-Key: <uuid-v4>
Content-Type: application/json
References
All enum values and schemas live in the local references/ files below — this skill emits from them,
not from live lookups and not from memory.
| Source | Local file | Use for |
|---|---|---|
| Identity service auth | references/identity-call.md |
Bearer token endpoint URL, header name, response shape |
| API schema reference | references/api-schema.md |
All enum values, required fields, request/response schemas |
| Error format reference | references/error-response-format.md |
Error envelope (RFC 7807) + Payroc errors[] + canonical error type catalog |
| Narrative guide | references/ach-payment-guide.md |
Payment lifecycle, reversal/refund logic, re-presentment workflow, tokenization |
These are local snapshots, authoritative for this skill. Their source URLs and last-synced dates are
recorded in references/_sources.md — regenerate from there if they look stale.
Core principles
- Inspect before asking — scan the codebase before asking anything; use what you find to skip obvious questions.
- Ask before coding — gather unknowns through intake before writing implementation code.
- Read
references/api-schema.mdbefore emitting any enum value. Fields likepaymentMethod.type,accountType,secCode, andtransactionResult.statusare all defined there. Do not use training-data guesses. A plausible-sounding string that isn't in the documented enum produces a 400 error. Note thatpaymentMethod.typehas inconsistent casing:achandpadare all-lowercase, butsecureTokenandsingleUseTokenare camelCase —"securetoken","secure_token", and"SecureToken"all produce 400s. - Read
references/api-schema.mdbefore reviewing developer-submitted code. When a developer shares existing code for review, readreferences/api-schema.mdbefore issuing any verdict — enum casing, required fields, and schema shapes must be verified against the reference, not from memory. - Idempotency-Key on every POST. Must be a UUID v4. Required — omitting it causes a 400. Generate a fresh UUID for each distinct operation.
- Never hardcode credentials. API keys and terminal IDs must come from environment variables or a secrets manager, never source code.
- Bearer token expiry. Tokens expire after 3,600 seconds (1 hour). For long-running services, implement token refresh logic.
- Validate before advancing — don't move to the next step until the current step's checkpoint passes in UAT.
Intake
First, scan the codebase. Look for:
- Server-side language and framework
- Existing payment or billing code
- Any existing HTTP client setup or credential configuration
- How environment variables are managed
Then confirm with the developer:
- Payment type — US (ACH) or Canadian (PAD)?
- Account collection — collect bank details directly in the request (
ach/pad), use a reusable token from a prior payment (secureToken), or use a single-use token from a Hosted Fields session (singleUseToken)? - SEC code — (ACH only) what's the transaction context:
web(internet),tel(phone),ccd(business), orppd(prearranged/recurring)? - Tokenization — store bank details for future use? (sets
credentialOnFile.tokenize: true) - Post-payment operations — retrieval only, or also refunds / reversals / re-presentment?
Use the answers to scope the implementation. If the use case is ambiguous, ask one targeted question before proceeding.
Prerequisites
These are needed to test the integration in UAT — not to write the code. If the developer already has them, proceed. If not, wire the code to read each from an environment variable and keep building.
- API key — used to generate Bearer tokens from the Payroc identity service. Provisioned by the Payroc Integrations team.
- Processing terminal ID — included in the request body (
processingTerminalId). ACH capability must be enabled on the terminal by Payroc. - UAT environment — test environment. No self-serve signup; terminals are provisioned by the Payroc Integrations team.
If anything is missing — warn, don't block. Propose env var names like PAYROC_API_KEY and PAYROC_TERMINAL_ID. Write code to read from those variables, then tell the developer:
⚠️ I've wired this to read credentials from
<VAR names>. You'll need a Payroc UAT terminal with ACH enabled to test this — contact the Payroc Integrations team if you don't have one yet. I can keep building in the meantime.
Checkpoint
Either credentials are confirmed, or the developer knows what's outstanding, how to obtain it, and which env vars the code reads from.
Step 1 — Authenticate
Read
references/identity-call.mdbefore emitting any auth code. Do not guess the endpoint URL, header name, or response shape — use only what the reference documents.
Endpoints:
- UAT/test:
POST https://identity.uat.payroc.com/authorize - Production:
POST https://identity.payroc.com/authorize
Header: x-api-key: <api-key>
The response contains access_token, expires_in (3600), and token_type ("Bearer"). All subsequent API requests use Authorization: Bearer <access_token>.
Implement a token-generation helper in the developer's language. For production code, include expiry tracking and refresh logic — tokens that expire mid-operation will produce 401s on otherwise valid requests.
Store the API key in an environment variable (e.g. PAYROC_API_KEY). Never inline it.
Checkpoint
Can the helper generate a Bearer token without error? If not, check the x-api-key header and confirm the API key is correct for the UAT environment.
Step 2 — Build the payment request
Read
references/api-schema.mdbefore writing the request body. Enum values forpaymentMethod.type,accountType,secCode, and all other discriminated fields are defined there. Do not emit them from training data.
Endpoint: POST https://api.uat.payroc.com/v1/bank-transfer-payments
Required headers:
Authorization: Bearer <token>
Content-Type: application/json
Idempotency-Key: <UUID v4>
2a — US ACH payment (type: "ach")
order.orderIdmust be unique per payment. The examples below use static strings (ORDER-001, etc.) for readability — replace them with a generated unique value (e.g. a UUID or incrementing reference) in production code. Reusing the sameorderIdacross distinct payments can cause data integrity issues.order.currencymust be a 3-letter uppercase ISO 4217 code ("USD","CAD")."usd","US", or"ca"will fail validation.
{
"processingTerminalId": "YOUR_TERMINAL_ID",
"order": {
"orderId": "ORDER-001",
"amount": 4999,
"currency": "USD",
"description": "Optional description"
},
"paymentMethod": {
"type": "ach",
"nameOnAccount": "Customer Full Name",
"accountNumber": "123456789",
"routingNumber": "053200983",
"accountType": "checking",
"secCode": "web"
}
}
secCodeis mandatory for ACH payments — always include it. Readreferences/api-schema.mdfor the valid values:web,tel,ccd,ppd. Choose based on how the customer authorised the transaction. Do not guess or omit.
accountTypeis optional — include it when the customer's account type is known (reduces decline risk). Valid values:"checking"and"savings"only (readreferences/api-schema.md). Do not include it if unknown rather than guessing.
Amount is in cents (integer in lowest denomination): $49.99 → 4999.
2b — Canadian PAD payment (type: "pad")
{
"processingTerminalId": "YOUR_TERMINAL_ID",
"order": {
"orderId": "ORDER-002",
"amount": 5000,
"currency": "CAD"
},
"paymentMethod": {
"type": "pad",
"nameOnAccount": "Customer Full Name",
"accountNumber": "123456789",
"transitNumber": "12345",
"institutionNumber": "003",
"accountType": "checking"
}
}
PAD uses a 5-digit transitNumber and 3-digit institutionNumber instead of a routing number.
secCode does not apply to PAD. accountType is optional — include when known.
2c — Secure token payment (type: "secureToken")
Use a token obtained from a prior ACH/PAD payment when credentialOnFile.tokenize: true was set:
{
"processingTerminalId": "YOUR_TERMINAL_ID",
"order": {
"orderId": "ORDER-003",
"amount": 2999,
"currency": "USD"
},
"paymentMethod": {
"type": "secureToken",
"token": "296753xxxxxxxxx",
"secCode": "ppd"
}
}
secCodeis still required when the token is ACH-backed. If the original payment was ACH (type: "ach"), includesecCodein the secureToken request. For PAD-backed tokens,secCodedoes not apply.
Token extraction path after tokenization: When
credentialOnFile.tokenize: trueis set, the reusable token is returned in the response atbankAccount.secureToken.token— not inpaymentMethodorcredentialOnFile. Store that value for futuresecureTokenrequests.
2d — Single-use token payment (type: "singleUseToken")
Use a single-use token obtained from a Hosted Fields session or tokenization endpoint. A singleUseToken can only be used once; it is consumed after the first successful charge.
{
"processingTerminalId": "YOUR_TERMINAL_ID",
"order": {
"orderId": "ORDER-004",
"amount": 2999,
"currency": "USD"
},
"paymentMethod": {
"type": "singleUseToken",
"token": "<single-use-token-from-hosted-fields>",
"secCode": "web"
}
}
typemust be"singleUseToken"(camelCase) — not"single_use_token"or"singleusetoken". Readreferences/api-schema.mdfor the exact casing.secCodeis required when the underlying bank account is ACH-backed. Omit for PAD-backed single-use tokens. Do not reuse asingleUseTokenvalue — it is invalidated after first use. Attempting to reuse it will produce a 400.
Optional: tokenize bank details
Add to any payment request to store bank details for future charges:
"credentialOnFile": {
"tokenize": true
}
After the payment succeeds, the reusable token is returned in the response at bankAccount.secureToken.token — not at paymentMethod.token or credentialOnFile.token. Persist this value to use in subsequent secureToken requests.
Optional: customer notifications
"customer": {
"notificationLanguage": "en",
"contactMethods": [
{ "type": "email", "value": "customer@example.com" }
]
}
Checkpoint
Does the request body include all required fields? Cross-check against references/api-schema.md before sending. Do not advance until the request body validates.
Step 3 — Send the request and handle the response
Generate a fresh UUID v4 for Idempotency-Key. On retry of the same failed request, reuse the same key. On a genuinely new submission, generate a new UUID.
curl -X POST https://api.uat.payroc.com/v1/bank-transfer-payments \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Idempotency-Key: $(uuidgen | tr '[:upper:]' '[:lower:]')" \
-H "Content-Type: application/json" \
-d @payment-payload.json
Success response (HTTP 201)
{
"paymentId": "PAY-XXXX",
"processingTerminalId": "1234001",
"order": {
"orderId": "ORDER-001",
"amount": 4999,
"currency": "USD"
},
"bankAccount": {
"type": "ach",
"nameOnAccount": "Customer Full Name",
"accountNumber": "*****5929",
"routingNumber": "*****4162"
},
"transactionResult": {
"type": "payment",
"status": "pending",
"responseCode": "E",
"responseMessage": "Pending",
"authorizedAmount": 4999,
"currency": "USD"
}
}
Persist paymentId immediately — it is required for all follow-on operations (retrieval, refund, reversal, representment).
IDs are opaque. The PAY-XXXX form above is illustrative. Treat every ID as an opaque string — do not validate against a pattern.
ACH is asynchronous. The initial transactionResult.status is typically pending (response code E). Funds are not confirmed until the status transitions to a terminal state. Poll via GET /v1/bank-transfer-payments/{paymentId} or use webhooks to detect status changes.
Status values — all 7 (read references/api-schema.md before writing any status-based branching):
Transient states (not a final outcome — keep polling or wait for a webhook):
ready— transaction queued, not yet submitted to processorpending— processing in progress; the normal initial state after creation
Terminal states (final outcome — act on these):
complete— funds successfully transferreddeclined— processor rejected the transactionreturned— bank returned the payment after settlement (e.g., NSF, closed account) — can trigger re-presentmentadmin— administrative hold; contact Payroc supportreversal— transaction reversed (voided before settlement)
Never treat pending as a failure — it is the normal initial state after creation.
Read
references/api-schema.mdfor the fulltransactionResult.statusenum before writing any status-based branching logic. Do not assume status values from training data.
Checkpoint
HTTP 201 received with a paymentId in the response? If not, work through the error taxonomy.
Step 4 — Post-payment operations
(Implement only the operations the developer selected during intake)
Retrieve a payment
GET https://api.uat.payroc.com/v1/bank-transfer-payments/{paymentId}
Headers: Authorization: Bearer <token>
Returns the full payment object including current transactionResult.status. Use this to poll for settlement or to find the return paymentId needed for re-presentment.
List payments
GET https://api.uat.payroc.com/v1/bank-transfer-payments?processingTerminalId=<id>
processingTerminalId is required. Optional filters include orderId, nameOnAccount, last4, type (ach/pad), status, dateFrom, dateTo, settlementState (unsettled/settled), and settlementDate.
Read
references/api-schema.mdfor the exacttypeandstatusenum values before writing filter logic.
Pagination: cursor-based via limit, after, before query parameters.
Reverse (void) or refund a payment — choose based on settlement state
Before writing reversal or refund code, ask the developer (or instruct them to check): has the payment already settled?
- Retrieve the payment via
GET /v1/bank-transfer-payments/{paymentId}and inspecttransactionResult.statusandsettlementStateto determine the correct operation. - If the payment is still in an open batch (not yet settled): use reversal — voids the payment before funds move.
- If the payment has already settled: use refund — returns funds to the customer's bank account.
Never prescribe reversal or refund without first determining settlement state. The wrong operation will fail.
Reverse (void)
POST https://api.uat.payroc.com/v1/bank-transfer-payments/{paymentId}/reverse
Headers: Authorization: Bearer <token>, Idempotency-Key: <UUID v4>
No request body required. Removes the payment from the open batch — no funds are taken. Only works if the payment is still in an open batch (before settlement).
Note: If you issue a referenced refund on a payment still in an open batch, the gateway automatically reverses it — you don't need to call reverse explicitly in that case.
Refund a payment
POST https://api.uat.payroc.com/v1/bank-transfer-payments/{paymentId}/refund
Headers: Authorization: Bearer <token>, Idempotency-Key: <UUID v4>
Requires a body with amount and description — both are mandatory, and an empty body returns 400. Send the original amount for a full refund, or a lower value for a partial one.
You can't run a referenced refund against an ACH payment that is in a closed batch. Our gateway returns a 400 error, Bank transfer with status COMPLETE can not be refunded, as soon as the batch closes, and while the batch is still open it reverses the payment rather than refunding it. To return funds for an ACH payment after its batch closes, run an unreferenced refund (POST /v1/bank-transfer-refunds) instead. This doesn't apply to pre-authorized debit (PAD) payments.
Refunds are a separate flow with their own decision logic — use the refund-an-ach-payment skill rather than building from this section.
Re-present a returned ACH payment
Use this to retry an ACH payment that was returned by the bank (e.g., NSF, closed account), after resolving the issue with the customer.
Critical: Use the paymentId from the returns[] array in the original payment response — not
the original paymentId. Retrieve the original payment first to get the return record.
Specifically: retrieve via GET /v1/bank-transfer-payments/{originalPaymentId}, then use returns[0].paymentId as the path parameter — each item in returns[] has its own paymentId field. Read references/api-schema.md for the full returns[] item shape.
POST https://api.uat.payroc.com/v1/bank-transfer-payments/{returnPaymentId}/represent
Path segment is
/represent(verb) — not/representment,/re-present, or/re-presentment. The narrative throughout this skill uses "re-presentment" as a noun, but the endpoint path uses the verb formrepresent. Typing/representmentproduces a 404.
Headers: Authorization: Bearer <token>, Idempotency-Key: <UUID v4>
Request body is optional. Omit to reuse the original bank details, or supply updated bank account details if the customer changed accounts:
{
"paymentMethod": {
"type": "ach",
"nameOnAccount": "Customer Full Name",
"accountNumber": "987654321",
"routingNumber": "053200983",
"accountType": "checking",
"secCode": "web"
}
}
Payments can be re-presented a maximum of two times.
UAT note: PAD representment is currently broken in the Payroc UAT environment. ACH representment availability in UAT may be limited. Document this in comments if writing production code that relies on it.
Error taxonomy
| Symptom | Likely cause | Fix |
|---|---|---|
| 401 on any request | Token missing, expired, or API key wrong | Re-generate token; verify x-api-key header value is the correct UAT API key |
400 — validation error on type, accountType, or secCode |
Enum value not from the reference | Read references/api-schema.md and use the documented value exactly — note secureToken and singleUseToken are camelCase |
400 — validation error on currency |
Lowercase, 2-letter, or unknown currency code | Use a 3-letter uppercase ISO 4217 code: "USD", "CAD" |
400 — idempotencyKeyMissing |
Idempotency-Key header absent |
Add Idempotency-Key: <UUID v4> to every POST request |
| 400 — missing required field | Required field absent from request body | Cross-check required fields in references/api-schema.md for the payment method type |
400 — secCode missing for ACH |
Forgot secCode on an ach-type payment |
Add secCode — it is mandatory for ACH transactions |
409 — duplicate Idempotency-Key |
Same key reused across different operations | Generate a fresh UUID for every distinct operation |
Payment status: returned |
Bank returned the payment (NSF, closed account) | Investigate return reason; contact customer; use re-presentment if appropriate |
Re-present fails or wrong paymentId |
Using original paymentId instead of return's paymentId |
Retrieve the original payment; use returns[0].paymentId from the returns[] array (each return item has its own paymentId field) |
| Refund returns an automatic reversal | Payment was in an open batch | The gateway voided it automatically; this is correct behaviour |
| 500 — server error | Transient gateway error | Retry with exponential backoff; if persistent, contact Payroc support |
Error response shape — errors follow the RFC 7807 problem-details format as the envelope (type,
title, status, detail, instance); Payroc extends it with an errors array. Each errors[]
item carries parameter (the JSON path of the failing field), detail (short reason), and message
(human-readable). Use parameter to map each error back to your request body.
Read-then-emit discipline
The following fields must be read from references/api-schema.md before emitting — never from
training data:
| Field | Why it matters |
|---|---|
paymentMethod.type |
ach/pad/secureToken/singleUseToken — wrong value produces 400 |
paymentMethod.accountType |
checking/savings only — snake_case or other forms rejected |
paymentMethod.secCode |
web/tel/ccd/ppd — mandatory for ACH; wrong value produces 400 |
transactionResult.status |
7 values — do not branch on guessed status strings |
List filter type |
ach/pad — enum for query parameter |
List filter status |
same 7 values as transactionResult.status |
List filter settlementState |
unsettled/settled — 2 values only |
Validation checklist
- API key sourced from environment variable — never hardcoded
- Bearer token generated from identity service — never hardcoded
-
Idempotency-Keyheader present and set to a UUID v4 on every POST -
paymentMethod.type,accountType,secCodevalues read fromreferences/api-schema.md— not from training data -
processingTerminalIdis provided in the request body -
order.orderIdis unique per payment — not a static string copied from an example -
order.currencyis a 3-letter uppercase ISO 4217 code (e.g."USD","CAD") -
order.amountis in the currency's lowest denomination (cents, not dollars) -
secCodeis included for all ACH (type: "ach") payments - PAD payments use
transitNumber+institutionNumber(notroutingNumber) -
paymentIdcaptured from creation response and stored for subsequent operations - For re-presentment: the
paymentIdused isreturns[0].paymentIdfromreturns[], not the original payment'spaymentId - UAT endpoints used during testing (
api.uat.payroc.com)
Completion
Once all checklist items pass:
Integration complete. Here's what you've built:
- Authentication — Bearer token generation from the Payroc identity service; credentials in env vars.
- ACH/PAD payment — [summarise: payment type, SEC code, tokenization if enabled]
- Post-payment operations — [list what was built: retrieve, list, reverse, refund, re-present]
- Validated in UAT — end-to-end flow confirmed (note any UAT limitations encountered).
Before going live: swap
api.uat.payroc.comforapi.payroc.comandidentity.uat.payroc.comforidentity.payroc.com. Point credentials to the production terminal and API key.
Offer next steps:
- Refund an ACH payment — return funds to the customer's bank account after settlement
- View ACH deposits — check batch settlement details via the reporting API
- Save a payment method — tokenize card details for multi-payment-method recurring billing
- Webhook notifications — receive server-side payment events rather than polling