ACH debit mandates
Pull bank-to-bank payments the compliant way: capture a signed Reg E authorization once, then debit only within the amount band and cadence the customer agreed to — with NACHA return handling that knows exactly when to retry, pause, or walk away.
What a mandate is
Under Regulation E (§1005.10), a recurring electronic debit from a consumer bank account requires a written, revocable authorization — the mandate. It is the authorization of record NACHA expects you to produce if the customer's bank ever asks "prove this debit was authorized." A PAID mandate captures:
- The verbatim disclosure text the customer agreed to — originator name, account (last 4), amount band, cadence, and the revocation procedure. It is stored byte-for-byte on the mandate and re-rendered identically at audit.
- An amount band (
authorized_min_cents/authorized_max_cents). Any debit outside the band is refused. - A cadence (
authorized_frequency):monthly,weekly,quarterly,annual, orsporadic. A debit before the minimum interval has elapsed since the last one is refused. - An Ed25519 signature over the disclosure, the customer identity, the capture
IP, the capture timestamp, and a server-issued nonce. The signature is re-verified every time
the mandate is read or charged — a tampered row is refused, never trusted. (Today the
refusal surfaces as a generic
500; the dedicatedmandate_signature_invaliderror code is not yet mapped in the handler.) - A tamper-evident audit event for every state transition (captured, activated, revoked, return-code annotated), appended to the hash-chained ledger.
What is live vs. roadmap
Honest status, consistent with the comparison matrix:
| Capability | Status |
|---|---|
| Mandate capture, list, get, revoke (API + hosted portal) | Built and registered; flag-enabled per deployment — endpoints return 503 where the ACH-debit surface is switched off. |
| NACHA return-code state machine (R01–R85) + retry scheduler | Built; same per-deployment flag as the mandate surface. |
| Platform ACH transfer rail (Dwolla-backed) | Live when Dwolla credentials are configured. In production the rail refuses to simulate success without them — it fails loudly rather than fabricating a transfer. |
| Mandate-gated debit initiation (Plaid Transfer) | Built and tested, but not yet constructed in the API binary — the checkout dispatcher's ACH debiter is deliberately nil until wired, so no debits run in any deployment today. |
Plaid Transfer return webhooks (POST /v1/webhooks/plaid) | Receiver built and tested but not yet wired into the API binary — the registered route answers 503, so returns cannot reach PAID yet. |
| ACH auto-debit inside recurring subscription billing | Roadmap — Phase 1B. The attach route below is registered but returns 503 in every deployment (the subscription linker is not wired), and the billing engine does not yet drive ACH debits on a schedule. |
| ACH as a hosted-checkout payment method | Not wired — hosted checkout dispatches card payments today. |
Capturing authorization
The production path is the hosted flow: link the customer to /billing-portal/mandates/new?customer_id=cust_… — you construct that URL
yourself; the checkout-session subsystem does not issue it. It walks four steps — verify the
bank via Plaid Link, review the exact Reg E disclosure that will be signed, set the amount band
and frequency, then sign and submit. Customers manage and revoke their mandates at /billing-portal/mandates.
The REST call underneath is customer-facing: it authenticates with a customer
checkout token, not your merchant secret key — a merchant API key is refused with 403. (Today checkout tokens for this route are only minted in dev/e2e
deployments, which is why the hosted portal is the production path.) Idempotency-Key is required; a duplicate key is rejected outright, so you can
never double-capture.
curl https://api.trustfabric.ai/v1/customers/cust_9f2d81/ach_mandates \
-H "Authorization: Bearer $CUSTOMER_CHECKOUT_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: mandate-cust_9f2d81-2026-07" \
-d '{
"plaid_item_id": "item_4kXw2mPq",
"plaid_account_id": "acct_8QzJn3Lr",
"authorized_min_cents": 0,
"authorized_max_cents": 50000,
"authorized_frequency": "monthly",
"account_mask": "4321",
"customer_name": "Ada Lovelace",
"customer_email": "[email protected]"
}'The mandate lands in status pending. Activation
(pending → active) is performed by an internal service call; the automated
bank-verification pipeline that drives it is roadmap — captured mandates remain pending until it ships. There is no public REST endpoint to force-activate a
mandate. Lifecycle: pending → active → revoked (or expired).
Endpoints
| Endpoint | What it does |
|---|---|
POST /v1/customers/:customer_id/ach_mandates | Capture a mandate (customer checkout token only). Requires Idempotency-Key; duplicate keys are rejected, not replayed. |
GET /v1/customers/:customer_id/ach_mandates | List a customer's mandates (paginated, default 50). |
GET /v1/ach_mandates/:id | Fetch one mandate. Its signature is re-verified on every read. |
POST /v1/ach_mandates/:id/revoke | Revoke a mandate. The customer's reason is preserved verbatim. |
POST /v1/subscriptions/:sub_id/payment_method | Attach a mandate to a subscription (merchant only). Registered but returns 503 in every deployment today — the subscription linker is not wired. Phase 1B roadmap. |
curl https://api.trustfabric.ai/v1/ach_mandates/achm_7c31f0/revoke \
-H "Authorization: Bearer $PAID_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"reason": "Customer requested cancellation in writing"}'Per the disclosure the customer signs, revocation takes effect with at least 3 business days' notice before the next scheduled debit, and if a debit's amount varies beyond the authorized band the customer must be notified at least 10 days before it is scheduled.
Return-code handling
Every ACH debit can come back from the customer's bank (the RDFI) with a NACHA return reason code. PAID maps every code in the R01–R85 range to a deterministic action — the state machine never guesses, and unknown or reserved codes route to investigation instead of silently retrying. The codes you will actually see:
| Code | Meaning | What PAID does |
|---|---|---|
R01 | Insufficient funds | Retry — re-present after a 5-day wait, at most 2 retries. |
R09 | Uncollected funds | Retry — same policy as R01. |
R02 / R03 | Account closed / no account | Permanent fail. The customer must update their payment method. |
R05 | Corporate SEC code used against a consumer account | Treated as a dispute — mandate revoked, evidence preserved. |
R07 | Authorization revoked by customer | Mandate revoked immediately; the customer's affidavit stands. |
R08 | Payment stopped (this debit only) | Investigate. The mandate is quarantined — no further debits until an operator clears the stop-payment flag. Not auto-revoked, because the customer may have stopped one debit, not the authorization. |
R10 | Unauthorized — originator not known / not authorized | Dispute — mandate auto-revoked, flagged for compliance review. |
R11 | Entry not in accordance with the authorization's terms | The debit is failed (an ach.debit.failed event once the pipeline is wired); automatic mandate pausing is not yet implemented — pausing after an R11 is an operator action today. |
R16 | Account frozen / OFAC instruction | Permanent fail. |
One rule worth knowing: since 2021-04-01, NACHA split the old catch-all R10 into R10 = strictly unauthorized (mandate revoked) and R11 = error in an otherwise-authorized debit (fixable — pause, correct, reconfirm). Documentation that treats R10 as a generic "customer disputed" code is obsolete — the two codes demand opposite responses. PAID's state machine distinguishes them, though the R11 pause itself is an operator action today.
Return and dispute timelines
- 2 banking days — the standard return window. Funds/format failures (R01, R02, R03, R09, and most technical codes) arrive within two banking days of settlement.
- 60 calendar days — the consumer-protection window. Authorization-based returns (R05, R07, R10, R11) can arrive up to 60 days out. This is ACH's equivalent of the card dispute window: the consumer contests the debit through their own bank, and it lands on you as a return code.
- 60-day retry ceiling — PAID refuses to schedule any re-presentment landing more than 60 days after the original debit attempt, even where NACHA technically permits longer NSF re-presentment. A retry past the ceiling escalates to permanent fail.
Webhook events
The debit pipeline defines five signed event types — see Webhooks for signature verification. The pipeline is built and
tested but not yet constructed in the API binary (see the status table above), so none of these events are emitted in any deployment today; the table below
describes the behaviour that ships once it is wired. Returns will reach PAID via Plaid
Transfer webhooks (verified against the Plaid-Verification header on POST /v1/webhooks/plaid) — that receiver is likewise built but unwired, so the
route currently answers 503; you never handle those directly.
| Event type | When it fires |
|---|---|
ach.debit.initiated | A debit was submitted against a mandate. |
ach.debit.failed | A debit failed terminally (hard return, or retries exhausted). |
ach.debit.retrying | A soft return (R01/R09) was scheduled for re-presentment. |
ach.mandate.revoked | A return code revoked the mandate (R07, R10) or it was revoked explicitly. |
ach.mandate.stop_payment_observed | An R08 stop payment quarantined the mandate. |