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, or sporadic. 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 dedicated mandate_signature_invalid error 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:

CapabilityStatus
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 schedulerBuilt; 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 billingRoadmap — 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 methodNot 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.

capture-mandate.sh
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

EndpointWhat it does
POST /v1/customers/:customer_id/ach_mandatesCapture a mandate (customer checkout token only). Requires Idempotency-Key; duplicate keys are rejected, not replayed.
GET /v1/customers/:customer_id/ach_mandatesList a customer's mandates (paginated, default 50).
GET /v1/ach_mandates/:idFetch one mandate. Its signature is re-verified on every read.
POST /v1/ach_mandates/:id/revokeRevoke a mandate. The customer's reason is preserved verbatim.
POST /v1/subscriptions/:sub_id/payment_methodAttach a mandate to a subscription (merchant only). Registered but returns 503 in every deployment today — the subscription linker is not wired. Phase 1B roadmap.
revoke-mandate.sh
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:

CodeMeaningWhat PAID does
R01Insufficient fundsRetry — re-present after a 5-day wait, at most 2 retries.
R09Uncollected fundsRetry — same policy as R01.
R02 / R03Account closed / no accountPermanent fail. The customer must update their payment method.
R05Corporate SEC code used against a consumer accountTreated as a dispute — mandate revoked, evidence preserved.
R07Authorization revoked by customerMandate revoked immediately; the customer's affidavit stands.
R08Payment 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.
R10Unauthorized — originator not known / not authorizedDispute — mandate auto-revoked, flagged for compliance review.
R11Entry not in accordance with the authorization's termsThe 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.
R16Account frozen / OFAC instructionPermanent 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 typeWhen it fires
ach.debit.initiatedA debit was submitted against a mandate.
ach.debit.failedA debit failed terminally (hard return, or retries exhausted).
ach.debit.retryingA soft return (R01/R09) was scheduled for re-presentment.
ach.mandate.revokedA return code revoked the mandate (R07, R10) or it was revoked explicitly.
ach.mandate.stop_payment_observedAn R08 stop payment quarantined the mandate.
An R10 return means the customer told their bank the debit was unauthorized. PAID revokes the mandate automatically and preserves the evidence trail. Do not capture a fresh mandate to route around a revocation — a new authorization requires the customer to affirmatively re-sign the disclosure, and unauthorized-return patterns are exactly what NACHA audits originators for.