> ## Documentation Index
> Fetch the complete documentation index at: https://docs.spritz.finance/llms.txt
> Use this file to discover all available pages before exploring further.

# Linked bank on-ramp

> Onboard users to crypto by linking their bank account and pulling funds via ACH debit.

A linked bank on-ramp lets a user onboard to crypto straight from their bank account.
They link the account once, authorize an ACH debit, and receive USDC at a wallet. No
wallet signature is required; the authorization comes from the verified bank
[funding source](/guides/definitions#funding-source).

This is the canonical implementation guide. An integration is complete when its
backend implements the flow and reconciliation below, and its frontend implements the
[ACH debit user experience](/guides/ach-debit-user-experience).

## Before you build

* Create or connect the user as described in [Onboarding](/guides/onboarding).
* Confirm `GET /v1/integrator/` returns `achDebitEnabled: true`. If it is false, contact
  Spritz before building or testing ACH debit.
* Wait until `GET /v1/users/me` shows a verified US user and the
  `fiat_to_crypto` / `ach_debit` capability as `active`. ACH debit has no separate
  terms requirement for an enabled integrator.
* Keep the integrator key, integrator secret, and user API key on your backend. Never
  expose them in a browser or mobile app.
* Let the client run Plaid Link and render authorization/UI. Send Plaid's result to your
  backend, which calls Spritz with HMAC and the user's authorization.
* Subscribe once per integrator to `onramp.*`, `achDebitReturn.*`, and `achDebit.*`
  webhooks. Store Spritz resource IDs so every event can be reconciled by API read.

High priority is enabled only for reviewed integrations. Ask Spritz to enable it on
your sandbox and production credentials. Use it only when `limitsByPriority.high.available`
is `true`; normal priority is the complete default implementation.

The flow is a short server-side sequence with one client-side bank-linking step:

<Steps>
  <Step title="Create a link token">
    From your backend, create a Plaid link token for the user.

    ```bash theme={null}
    # Add body-specific HMAC headers and the user's Authorization as documented in Authentication.
    curl -X POST https://platform.spritz.finance/v1/bank-accounts/link-token \
      -H "Content-Type: application/json" \
      -d '{}'
    ```

    ```json theme={null}
    {
      "linkToken": "link-sandbox-...",
      "hostedLinkUrl": "https://...",
      "expiration": "2026-06-30T12:40:00.000Z",
      "requestId": "..."
    }
    ```

    Spritz requests Hosted Link for every link token. Choose exactly one completion
    mode from the returned values:

    * **Embedded/native Plaid Link:** initialize Plaid Link with `linkToken`. Your app
      receives the public token and selected account IDs, then your backend calls
      `link-complete` in the next step.
    * **Plaid Hosted Link:** when `hostedLinkUrl` is non-null, open it. Spritz receives
      Plaid's completion asynchronously and exchanges the public token for you. Do
      **not** call `link-complete` for this mode. If it is null, use embedded/native
      Link with `linkToken`.
  </Step>

  <Step title="Finish the selected linking mode">
    <Tabs>
      <Tab title="Embedded/native Link">
        Plaid returns a public token and the selected account IDs to your client. Send
        them to your backend, then complete the link once:

        ```bash theme={null}
        # Add body-specific HMAC headers and the user's Authorization as documented in Authentication.
        curl -X POST https://platform.spritz.finance/v1/bank-accounts/link-complete \
          -H "Content-Type: application/json" \
          -d '{
            "publicToken": "public-sandbox-...",
            "accountIds": ["..."],
            "institutionId": "ins_...",
            "institutionName": "..."
          }'
        ```
      </Tab>

      <Tab title="Hosted Link">
        Before opening the URL, save the user's current funding-source IDs and allow
        only one active Hosted Link session for that user. After the user returns, poll
        `GET /v1/funding-sources/` every 2 seconds for up to 60 seconds; an
        `account.created` event should trigger the same read immediately.

        Linking is complete when a previously unseen source appears as `active`,
        `review_required`, or `ineligible`; each state has a UI action below. Stop
        polling when it appears. If 60 seconds expires, say “We’re still linking your
        bank account” and offer **Check again** or a fresh Link session. This timeout is
        a UI recovery bound, not proof that linking failed; handle a later source or
        event idempotently. Never ask the client for a public token and never call
        `link-complete` in this branch.
      </Tab>
    </Tabs>
  </Step>

  <Step title="Find the funding source and check limits">
    Linking creates a funding source. Wait for one with `status: "active"`, then read its limits.

    ```bash theme={null}
    # Add HMAC headers and the user's Authorization as documented in Authentication.
    curl "https://platform.spritz.finance/v1/funding-sources/"
    ```

    A funding source moves through `pending`, `active`, `review_required`, `ineligible`,
    `disabled`, or `deleted`. Only `active` sources can be debited. Check what the user
    can deposit:

    ```bash theme={null}
    # Add HMAC headers and the user's Authorization as documented in Authentication.
    curl "https://platform.spritz.finance/v1/funding-sources/{fundingSourceId}/deposit-limits"
    ```

    ```json theme={null}
    {
      "limitsByPriority": {
        "normal": {
          "available": true,
          "minAmountUsd": "10.00",
          "maxAmountUsd": "742.57",
          "reason": null,
          "suggestedAction": null,
          "clearsAt": null,
          "clearsAtIsEstimate": false
        },
        "high": { "available": false, "maxAmountUsd": "0.00", "maxEarlyReleaseAmountUsd": "0.00", "reason": "not_available", "suggestedAction": null, "clearsAt": null, "clearsAtIsEstimate": false }
      }
    }
    ```

    Present two delivery choices, not three limits:

    * **Standard:** send `priority: "normal"` and validate the amount with
      `normal.minAmountUsd` and `normal.maxAmountUsd`.
    * **Fastest available:** offer this only when `high.available` is true, send
      `priority: "high"`, and validate the amount with `high.maxAmountUsd`.

    Do not show both maximums at once or label `high.maxAmountUsd` as an “instant
    limit.” It is the fee-adjusted maximum deposit for the Fastest available choice.
    `high.maxEarlyReleaseAmountUsd` is only the largest portion that may release
    before settlement; the user does not allocate that portion. The prepare response
    supplies the exact early/later split to display before authorization.

    With `quoteType: "exact_output"`, each `maxAmountUsd` is the USDC principal the
    user can receive, excluding fees. Spritz has already reduced it enough for the
    principal plus fee to fit the applicable bank-debit limit. For example, with a
    `$750.00` total-debit ceiling and no plan adjustment or subsidy:

    * Standard: `$742.57` principal + `$7.43` fee = `$750.00` bank debit.
    * Fastest available with `$100.00` eligible for early release: `$100.00` at 2%
      plus `$641.58` at 1% produces an `$8.42` fee, so `$741.58` principal + `$8.42`
      \= `$750.00` bank debit.

    `maxAmountUsd` is a principal ceiling for both quote types. With
    `quoteType: "exact_input"`, request `amountUsd` is instead the maximum total bank
    debit, including fees, so do not compare it directly with `maxAmountUsd`. Send the
    user's total-debit budget to prepare; Spritz chooses the largest principal that
    fits it and returns the authoritative `principalAmountUsd`, `userFeeUsd`, and
    `totalDebitAmountUsd` in `summary`. Use `exact_output` when your amount picker needs
    to validate the requested principal directly against `maxAmountUsd`.

    When `available` is false, branch on `reason`. `suggestedAction` is `auto_ramp`,
    `wait_for_settlement`, or `null`. `clearsAt` is the earliest time the current reason
    can stop applying; `clearsAtIsEstimate: true` means it is only a forecast. Re-fetch
    before prepare and again after a blocked attempt. Never hardcode limits or infer the
    policy that produced them.
  </Step>

  <Step title="Prepare the deposit">
    Prepare a quote. This returns the ACH authorization text to show the user, along with a `preparationId` you'll use to commit.

    ```bash theme={null}
    # Add body-specific HMAC headers and the user's Authorization as documented in Authentication.
    curl -X POST https://platform.spritz.finance/v1/deposits/direct/prepare \
      -H "Content-Type: application/json" \
      -d '{
        "sourceId": "{fundingSourceId}",
        "address": "YourUsersSolanaWallet",
        "network": "solana",
        "asset": "USDC",
        "quoteType": "exact_output",
        "amountUsd": "500.00",
        "priority": "normal"
      }'
    ```

    ```json theme={null}
    {
      "preparationId": "...",
      "kind": "deposit_authorization",
      "expiresAt": "2026-06-30T12:40:00.000Z",
      "message": "ACH authorization text to display to the user",
      "summary": {
        "requestedPriority": "normal",
        "priority": "normal",
        "releaseDecisionMode": "after_settlement",
        "principalAmountUsd": "500.00",
        "instantPortionUsd": "0.00",
        "settlementPortionUsd": "500.00",
        "regularPublishedFeeUsd": "5.00",
        "instantPublishedFeeUsd": "0.00",
        "publishedFeeUsd": "5.00",
        "totalDebitAmountUsd": "505.00"
      }
    }
    ```

    `quoteType` is `exact_input` (you specify the USD debited) or `exact_output` (you specify the crypto delivered). `network` supports `solana`, `ethereum`, `polygon`, `base`, `avalanche`, and `arbitrum`; `asset` is `USDC`.

    Map **Standard** to `priority: "normal"` and **Fastest available** to
    `priority: "high"`. For Fastest available, Spritz decides the split:
    `instantPortionUsd` may release before settlement and `settlementPortionUsd` waits
    for settlement. Show those two amounts and timings from this response. Do not ask
    the user to choose the portions themselves.
  </Step>

  <Step title="Show the authorization and create the deposit">
    Display the quote `summary` and the ACH authorization `message` to the user. Once they authorize, commit the deposit with the `preparationId`.

    ```bash theme={null}
    # Add body-specific HMAC headers and the user's Authorization as documented in Authentication.
    curl -X POST https://platform.spritz.finance/v1/deposits/direct \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: your-unique-deposit-intent-key" \
      -d '{ "preparationId": "..." }'
    ```

    Spritz rechecks the source, amount, and effective release availability before any
    money moves. A blocking check creates no deposit. A create attempt consumes its
    `preparationId`; prepare a new authorization for a new attempt. Reuse the same
    `Idempotency-Key` and body only to recover from a timeout or lost response.

    A high-priority request can instead be reduced or downgraded without failing. The
    create response then preserves `requestedPriority: "high"` while `priority`,
    release portions, timing, and fees describe what will actually happen. Render the
    create response again before showing confirmation; do not rely on the earlier
    preparation summary.
  </Step>

  <Step title="Track the deposit">
    The deposit response carries the full lifecycle. Track it by reading the deposit or by reacting to [webhooks](/guides/webhooks).

    ```json theme={null}
    {
      "id": "...",
      "status": "authorized",
      "requestedPriority": "normal",
      "priority": "normal",
      "releaseDecisionMode": "after_settlement",
      "principalAmountUsd": "500.00",
      "instantPortionUsd": "0.00",
      "settlementPortionUsd": "500.00",
      "expectedAssetAmount": "500.00",
      "network": "solana",
      "asset": "USDC",
      "address": "YourUsersSolanaWallet",
      "debitStatus": "authorized",
      "releaseStatus": "not_started"
    }
    ```

    `status` is `authorized`, `processing`, `partially_released`, `completed`, `failed`,
    `refunded`, or `returned`. The ACH pull is tracked independently in `debitStatus`
    (`authorized`, `submitting`, `submitted`, `settled`, `returned`, `failed`). Crypto is
    tracked in `releaseStatus` (`not_started`, `queued`, `partial`, `completed`,
    `failed`).

    `releasedAmountUsd` includes submitted releases. Tell the user crypto was delivered
    only as `confirmedReleasedAmountUsd` increases. Use `payoutTxHash` as blockchain
    proof when present. For `early_partial`, the instant portion can confirm while the
    settlement portion remains pending.

    A settled ACH debit can later become `returned`, even when the aggregate deposit previously showed `completed`, `failed`, or `refunded` because of its crypto-release path. Keep processing deposit and `achDebitReturn.*` webhook updates. Use `debitStatus` and `releaseStatus` to explain intermediate states.

    Reconcile after an outage by paging through `GET /v1/deposits/`. The list is
    user-scoped, so an integrator-wide recovery must iterate your own user roster and
    authorize each user's read. Webhooks are notifications, not the only record of
    deposits.
  </Step>
</Steps>

## When linking is blocked

`link-complete` can return a `409` after Plaid succeeds. Branch on `code`; retrying the
same account cannot clear these conflicts.

| `code`                                | Meaning                                                                                                           | What your UI should do                                                                                                                                          |
| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PLAID_ACCOUNT_ALREADY_LINKED`        | The user already has this usable bank account. No duplicate source was created.                                   | Close Link, re-list funding sources, and select the existing source.                                                                                            |
| `PLAID_ACCOUNT_BLOCKED`               | This account was permanently disabled by a return, risk decision, or operator action. Relinking cannot bypass it. | Offer another bank and your standard support path.                                                                                                              |
| `PLAID_ACCOUNT_REROUTED`              | A time-boxed bank-risk decision still applies. Relinking does not shorten it.                                     | Re-list funding sources and use the existing source's structured `availableAt`. Offer another bank when no source is visible. Never parse a date from `detail`. |
| `PLAID_ACCOUNT_LINK_INCOMPLETE`       | An older link exists without a usable funding source.                                                             | Stop retrying and send the user to support.                                                                                                                     |
| `PLAID_ACCOUNT_LIMIT_EXCEEDED`        | The user already has the maximum number of linked accounts.                                                       | Close Link and let the user choose an existing account.                                                                                                         |
| `PLAID_DUPLICATE_IDENTITY`            | This verified person already belongs to another Spritz user.                                                      | Stop linking. Tell the user to use their existing account or contact support. Do not retry.                                                                     |
| `PLAID_DUPLICATE_BANK_ACCOUNT`        | This bank account already belongs to another Spritz user.                                                         | Stop linking. Tell the user to use the account where the bank is already linked or contact support. Do not retry.                                               |
| `PLAID_ACCOUNT_HOLDER_UNAVAILABLE`    | The linked account has no usable holder identity to compare.                                                      | Stop retrying this account. Offer another bank or support.                                                                                                      |
| `PLAID_VERIFIED_IDENTITY_UNAVAILABLE` | The verified user record lacks the identity data required for ownership matching.                                 | Stop retrying Link and send the user to support.                                                                                                                |

A `503` with `PLAID_LINK_FAILED`, `PLAID_SANDBOX_LINK_UNAVAILABLE`, or
`PLAID_IDENTITY_UNAVAILABLE` is different: the provider flow did not complete. Keep the
user on linking and allow a bounded retry or a different bank.

If a user deliberately unlinks a healthy account, its funding source becomes `deleted`.
Relinking the same account restores the original bank-account and funding-source IDs to
`active`; update the existing local payment method instead of inserting a duplicate.
Relinking does not clear a return or risk block: those attempts follow the blocked rows
above.

## When a deposit is blocked

Spritz runs every deposit through eligibility, limit, and risk checks. A block returns an
[error](/guides/errors) **before any money moves** — no ACH pull is attempted. Most
deposit failures collapse onto a few problem `type`/`status` values, so branch on the
`code` extension when it is present.

A code-less `409` can occur when another create request for the same user is still in
progress. Do not assume whether the first request succeeded. Wait briefly, reconcile
with the same `Idempotency-Key` and `GET /v1/deposits/`, then retry the same body with
that key at most once. Do not create a new deposit intent until reconciliation is clear.

Both `prepare` and `create` can return:

| `code`                                     | Status | Meaning                                                                                                                                                | What to do                                                                                                               |
| ------------------------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| `validation_error`                         | `400`  | A request field is malformed or unsupported.                                                                                                           | Fix the named `field`.                                                                                                   |
| `minimum_deposit`                          | `400`  | `amountUsd` is below the current inclusive minimum.                                                                                                    | Re-read limits, keep the user on the amount screen, and show `minAmountUsd`.                                             |
| `transaction_limit`                        | `400`  | `amountUsd` is above the current inclusive maximum.                                                                                                    | Re-read limits, keep the user on the amount screen, and show `maxAmountUsd`.                                             |
| `daily_limit`, `monthly_limit`             | `400`  | A legacy period limit blocked the amount.                                                                                                              | Re-read `limitsByPriority` and use `clearsAt` when available.                                                            |
| `unsettled_deposit_limit`                  | `400`  | Too many ACH debits are still unsettled.                                                                                                               | Wait for settlement and use `clearsAt` when available.                                                                   |
| `unsettled_amount_limit`                   | `400`  | The amount would exceed the user's in-flight debit capacity.                                                                                           | Re-read limits; wait for settlement.                                                                                     |
| `bank_unsettled_deposit_limit`             | `400`  | A legacy bank-specific in-flight limit blocked the request.                                                                                            | Wait for settlement or offer another bank.                                                                               |
| `open_exposure`, `aggregate_exposure`      | `400`  | The amount exceeds current user or program exposure capacity.                                                                                          | Re-read limits and follow `suggestedAction` / `clearsAt`.                                                                |
| `new_user_admission_paused`, `rail_halted` | `400`  | ACH debit is temporarily unavailable for this request.                                                                                                 | Follow `suggestedAction`; offer another supported on-ramp or retry later.                                                |
| `source_not_found`                         | `404`  | The `sourceId` doesn't exist or isn't this user's.                                                                                                     | Re-list funding sources.                                                                                                 |
| `source_rerouted`                          | `409`  | The funding source is temporarily unavailable after a bank-risk check (`statusReason: "rerouted"`). The body carries `availableAt`.                    | Show "available again on `availableAt`"; relinking the same account does not shorten it. Offer a different bank account. |
| `source_disabled`                          | `409`  | The funding source is permanently unavailable (`status: "disabled"`). The body carries `permanent: true`; private disablement inputs are not returned. | Offer a different bank account.                                                                                          |
| `source_review_required`                   | `409`  | Ownership of the funding source is still under review.                                                                                                 | Wait; the user may be contacted.                                                                                         |
| `source_pending`                           | `409`  | The funding source is still being verified.                                                                                                            | Retry once it is `active`.                                                                                               |
| `source_not_eligible`                      | `409`  | The funding source isn't `active` for another reason (e.g. `ownership_mismatch`).                                                                      | Read its `status` and `statusReason` (see [Funding source status](#funding-source-status)), then choose another account. |
| `ach_debit_program_halted`                 | `409`  | ACH debit is paused for your integration.                                                                                                              | Do not loop. Show temporary unavailability and retry later.                                                              |
| `ach_debit_new_user_paused`                | `409`  | ACH debit is temporarily unavailable for this user.                                                                                                    | Do not expose user classification. Offer another supported on-ramp or retry later.                                       |
| `ach_debit_user_access_disabled`           | `409`  | This user's ACH debit access is disabled, usually after a return.                                                                                      | Not reinstated automatically; contact Spritz.                                                                            |
| `ach_debit_user_access_restricted`         | `409`  | New ACH debits are paused while Spritz reviews ACH access. Existing deposits continue to settle.                                                       | Stop retrying and show the support/review path.                                                                          |
| `ach_debit_source_identity_ambiguous`      | `409`  | The bank identity maps to more than one active funding source.                                                                                         | Remove the duplicate source.                                                                                             |
| `ach_debit_source_unsettled_limit`         | `409`  | The source already has an unsettled debit in flight.                                                                                                   | Wait for it to settle, then retry.                                                                                       |

Integration-level errors can also block prepare or create:

| `code`                       | Status         | What to do                                                                                                               |
| ---------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `integration_unavailable`    | `503`          | Temporary integration configuration failure. Log it, show a generic temporary error, and contact Spritz if it persists.  |
| `integration_not_approved`   | `403`          | The integration is not approved for this live-money action. Contact Spritz.                                              |
| `integration_amount_limit`   | `422`          | The amount exceeds the integration's current transaction allowance. Show the current maximum from deposit limits.        |
| `integration_usage_limit`    | `422`          | The integration's current usage allowance is exhausted. Stop retries and contact Spritz.                                 |
| `integration_state_conflict` | `404` or `409` | Spritz could not reconcile this attempt's current state. Reconcile by idempotency key and deposit reads before retrying. |

`create` can additionally return:

| `code`                     | Status | Meaning                                                                                                     |
| -------------------------- | ------ | ----------------------------------------------------------------------------------------------------------- |
| `preparation_not_found`    | `404`  | Unknown `preparationId`.                                                                                    |
| `preparation_expired`      | `409`  | The preparation passed its `expiresAt`. Prepare a new quote.                                                |
| `preparation_already_used` | `409`  | The preparation was already committed. A blocked attempt also consumes it, so prepare a new quote to retry. |

### Deposit decisions

At `create`, Spritz evaluates the deposit for ACH-return risk. These blocking outcomes
return `409` and **do not create the deposit**:

| `code`                        | Meaning                                                                         | What to do                                                                                                              |
| ----------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `risk_review_required`        | The deposit needs review.                                                       | Show “This deposit needs review.” Do not retry automatically.                                                           |
| `risk_rejected`               | Spritz declined the deposit.                                                    | Show “This bank deposit is unavailable.” Do not vary the amount or retry in a loop.                                     |
| `risk_rerouted`               | Spritz declined this attempt. Despite the name, no alternate route was created. | Refresh the funding source. Offer another active bank or wait until `availableAt`; do not probe with different amounts. |
| `risk_evaluation_unavailable` | A required decision could not be completed.                                     | Prepare a new authorization and make one bounded retry later.                                                           |

<Note>
  A decision can also change the funding source. Always re-read it after a blocked
  create. `disabled` / `permanent: true` requires another bank or support. `ineligible`
  / `rerouted` can recover at `availableAt`. Relinking the same account does not bypass
  either state. Do not show the raw risk code or reason to the user.
</Note>

### Instant release adjustments

High priority is a request, not a guarantee. The create response can reduce the instant
portion or set `priority: "normal"` without failing the deposit. The rest is released
after ACH settlement. Do not infer or expose the control that produced the adjustment;
render the returned result:

| Field                    | How to use it                                         |
| ------------------------ | ----------------------------------------------------- |
| `requestedPriority`      | What the user asked for.                              |
| `priority`               | The effective pricing and release priority.           |
| `releaseDecisionMode`    | `early_full`, `early_partial`, or `after_settlement`. |
| `instantPortionUsd`      | Principal released before settlement.                 |
| `settlementPortionUsd`   | Principal released only after settlement.             |
| `regularPublishedFeeUsd` | Fee for the settlement portion.                       |
| `instantPublishedFeeUsd` | Fee for the instant portion.                          |
| `publishedFeeUsd`        | Their sum before final adjustments and subsidy.       |

Always render timing, portions, `userFeeUsd`, and `totalDebitAmountUsd` from the create
response. A downgrade is a successful deposit, not an error. Continue tracking it and
show standard settlement timing; do not retry solely to recover instant release.

## Funding source status

A funding source moves through these states, and only `active` can be debited. When it isn't active, `statusReason` explains why.

| `status`          | Debitable | Typical `statusReason`                                                 |
| ----------------- | --------- | ---------------------------------------------------------------------- |
| `pending`         | No        | `null` — ownership and verification still resolving                    |
| `active`          | Yes       | `null`                                                                 |
| `review_required` | No        | `ownership_review_required`                                            |
| `ineligible`      | No        | `ownership_mismatch`, or `rerouted` (time-boxed — see `availableAt`)   |
| `disabled`        | No        | `risk_blocked`, `returned`, or `manually_disabled` (`permanent: true`) |
| `deleted`         | No        | The user unlinked this source; `deletedAt` records when                |

`statusReason` takes one of:

| Value                       | Meaning                                                                                           |
| --------------------------- | ------------------------------------------------------------------------------------------------- |
| `ownership_mismatch`        | The account holder's name didn't match the verified Spritz user.                                  |
| `ownership_review_required` | Ownership couldn't be confirmed automatically and needs review.                                   |
| `user_not_verified`         | The user isn't verified for ACH debit.                                                            |
| `duplicate_bank_account`    | The same bank account is already linked.                                                          |
| `returned`                  | A prior debit on this source or user was returned.                                                |
| `risk_blocked`              | Spritz made the source permanently unavailable. Show another bank or support, not the raw reason. |
| `rerouted`                  | Spritz made the source temporarily unavailable. It can be usable again at `availableAt`.          |
| `manually_disabled`         | An operator disabled the source.                                                                  |

Two further fields say what happens next:

| Field         | Meaning                                                                                                                 |
| ------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `availableAt` | For a time-boxed block (`statusReason: "rerouted"`), when the source becomes usable again on its own. `null` otherwise. |
| `permanent`   | `true` when the source is `disabled` and will not recover on its own — offer a different bank account.                  |

## Handling returns

ACH debits can be returned after the fact (for example, insufficient funds). When that happens, the deposit reports a `returnCode` and `returnReason`, the funding source becomes permanently disabled, and the `achDebitReturn.created` and `achDebitReturn.updated` [webhooks](/guides/webhooks) fire. A cached funding-source read can briefly lag the returned deposit, so use either return webhook as the trigger for bounded `GET /v1/funding-sources/:sourceId` polling. Branch on the return record's `userAction`: `none` can offer another bank, while `review_required`, `restricted`, and `disabled` must show the support path and stop new ACH debit attempts. See [ACH returns](/guides/ach-returns) for the exact frontend contract and [Sandbox](/guides/sandbox#simulate-ach-returns) for deterministic return scenarios.

## Related

<CardGroup cols={2}>
  <Card title="ACH debit user experience" icon="bell" href="/guides/ach-debit-user-experience">
    Implement every UI, email, and push state.
  </Card>

  <Card title="Sandbox" icon="flask" href="/guides/sandbox">
    Run deterministic scenarios and the documented contract-only cases.
  </Card>

  <Card title="On-ramp" icon="arrow-right-to-bracket" href="/guides/use-cases/on-ramp">
    Auto-converting deposit accounts for push funding.
  </Card>

  <Card title="Webhooks" icon="bell" href="/guides/webhooks">
    Track deposit status and ACH returns.
  </Card>
</CardGroup>

## Definition of complete

* [ ] Backend creates/connects users, waits for active ACH debit capability, and keeps all secrets server-side.
* [ ] Embedded Link calls `link-complete`; Hosted Link waits for Spritz's asynchronous completion. Both store stable bank/source IDs.
* [ ] Frontend handles every funding-source status and every link conflict above.
* [ ] Amount entry is driven by fresh `limitsByPriority`; no limits are hardcoded.
* [ ] Authorization displays the exact server-provided message and create-response summary.
* [ ] Deposit creation uses a persisted `Idempotency-Key`.
* [ ] Normal, partial-instant, full-instant, and high-to-normal results render correctly when enabled.
* [ ] Bank debit and crypto delivery are tracked as separate state machines.
* [ ] Resource webhook handlers verify signatures, fetch current state, and reconcile after outages; push handlers atomically deduplicate `achDebit.*` by `eventId` and reject stale `sequence` values.
* [ ] Returns disable the source in the UI and branch on `userAction`.
* [ ] Every scenario in the [sandbox test matrix](/guides/sandbox#ach-debit-release-checklist) passes before production.
