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

# Handle Failed Payments

> Workflows for detecting, classifying, and recovering from payment failures in Raise — covering one-time donation failures, recurring schedule failures, and the donor-update flows that bring failed schedules back to active.

Payment failures are a normal part of running a donation platform. Cards expire, accounts close, balances run low, gateways have transient issues. Partner integrations that handle failures well — distinguishing transient errors from permanent ones, recovering recurring schedules promptly, and surfacing the right signals to the customer's stewardship team — are much more useful than integrations that simply log errors and move on.

This workflow covers the three classes of payment failures in Raise: one-time donation failures (from `POST /api/Raise/give`), recurring schedule failures (the `hasPaymentFailed` flag), and the recovery flows that bring failed schedules back to active.

## Three classes of failure

| Class                          | When it happens                                       | How to detect                                                                  | Recovery path                                             |
| ------------------------------ | ----------------------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------- |
| **One-time donation failure**  | A `POST /api/Raise/give` request fails at the gateway | `400 Bad Request` with payment-specific `detail` in the ProblemDetails body    | Surface to donor; let them try a different payment method |
| **Recurring schedule failure** | A scheduled charge cycle fails                        | `hasPaymentFailed: true` on the RecurringGift record                           | Donor outreach to update payment method                   |
| **Gateway-level outage**       | The payment gateway is temporarily unavailable        | `5xx` responses, network errors, or batch failures across multiple submissions | Retry with backoff; escalate if sustained                 |

Each class needs a different response. The next sections walk through them in detail.

***

## One-time donation failures

When a donor submits a donation through `POST /api/Raise/give` and the gateway declines the charge, the request returns `400 Bad Request` with a `ProblemDetails` body. The Gift record is **not** created in this case.

### Classifying the failure

The most useful field is `detail` in the response body. Common payment-failure reasons:

| Detail (representative) | Cause                                                              | Recoverable?                                                                                   |
| ----------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- |
| `"Card declined"`       | Gateway declined the charge (insufficient funds, fraud flag, etc.) | No — same card will fail again; donor needs a different method.                                |
| `"Card expired"`        | The card's expiration date has passed                              | No — donor must use a different card.                                                          |
| `"Invalid card number"` | Tokenization or transcription error                                | No — donor must re-enter card details.                                                         |
| `"Insufficient funds"`  | Account balance below the donation amount                          | Sometimes — donor can try again after funding the account.                                     |
| `"Issuer unavailable"`  | The card issuer's authorization system is temporarily unreachable  | Yes — retry after a short delay (minutes).                                                     |
| `"Gateway timeout"`     | The payment gateway didn't respond in time                         | Yes — retry with caution (could be a successful charge that didn't complete the API response). |

<Note>
  The exact `detail` text returned by Raise depends on the payment gateway configured for the customer's organization. Gateway-specific messages may vary (Stripe says one thing, Authorize.net says another). Don't switch on exact `detail` strings — they're not a stable contract. Use them for logging and user-facing surfacing, not for retry logic.

  **⚠️ Spec gap:** The OpenAPI spec doesn't enumerate the standard payment-failure messages Raise produces. The list above is representative; the canonical mapping isn't published. Coordinate with the platform team for the authoritative list if your integration's classification logic needs it.
</Note>

### Handling a one-time failure

```javascript JavaScript theme={null}
async function processDonation(donationRequest) {
  const response = await fetch(
    'https://prod-api.raisedonors.com/api/Raise/give',
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.RAISE_API_TOKEN}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(donationRequest),
    }
  );

  if (response.ok) {
    return { success: true, gift: await response.json() };
  }

  const problem = await response.json();

  // Classify the failure
  const classification = classifyPaymentFailure(response.status, problem);

  return {
    success: false,
    classification,
    detail: problem.detail,
    fieldErrors: problem.errors,
  };
}

function classifyPaymentFailure(status, problem) {
  if (status === 401) return 'auth_error';
  if (status === 429) return 'rate_limited';
  if (status >= 500) return 'gateway_error';

  // 400 — could be validation or payment failure
  if (problem.errors) return 'validation_error';

  // Payment-failure 400: surface to donor
  return 'payment_declined';
}
```

The right response by classification:

| Classification     | Response                                                                                                                                       |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `validation_error` | Fix the request body and re-submit. Surface field-level errors to the UI.                                                                      |
| `payment_declined` | Display a clear "your payment was declined — please try a different method" message. Don't retry the same card.                                |
| `auth_error`       | Don't retry — the API token needs to be replaced. Alert the integration owner.                                                                 |
| `rate_limited`     | Honor `Retry-After` and back off. See [Rate Limits](/raise/rate-limits).                                                                       |
| `gateway_error`    | Retry with caution — see "Idempotency considerations" in [Process a Donation](/raise/workflows/process-a-donation#idempotency-considerations). |

### What the donor sees

For donor-facing flows (forms embedded on a customer's site), the partner integration's job is to display a clear, actionable error message. A few patterns:

| Failure type          | Donor-facing message                                                                          |
| --------------------- | --------------------------------------------------------------------------------------------- |
| Card declined         | "Your card was declined. Please try a different payment method."                              |
| Card expired          | "Your card has expired. Please enter a card with a valid expiration date."                    |
| Invalid card          | "We couldn't process this card. Please check the card number and try again."                  |
| Network/gateway error | "We're having trouble processing your donation right now. Please try again in a few minutes." |
| Generic fallback      | "Your donation couldn't be processed. Please try again or contact support."                   |

Don't surface the raw `detail` string to donors — it may contain technical jargon, gateway-specific codes, or unhelpful messages. Map to a friendly message and log the raw `detail` server-side for diagnostics.

***

## Recurring schedule failures

A failed payment on a recurring schedule produces a different signal: the schedule's `hasPaymentFailed` flag becomes `true`. The schedule remains in the system but stops processing new charge cycles until the donor's payment method is updated.

### Detecting failed schedules

Query for failed schedules on a regular cadence — typically daily as part of the customer's stewardship workflow:

```javascript JavaScript theme={null}
async function findFailedSchedules() {
  const allFailedSchedules = [];
  let skip = 0;
  const take = 1000;

  while (true) {
    const response = await fetch(
      'https://prod-api.raisedonors.com/api/RecurringGift/query',
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.RAISE_API_TOKEN}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          skip,
          take,
          sortBy: 'id',
          groups: [
            {
              conditions: [
                {
                  parameter: 'hasPaymentFailed',
                  operator: 0 /* equals — discover via QueryOptions */,
                  value: 'true',
                },
              ],
            },
          ],
        }),
      }
    );
    const page = await response.json();
    allFailedSchedules.push(...page.items);

    if (page.items.length < take) break;
    skip += take;
  }

  return allFailedSchedules;
}
```

For ongoing monitoring, store the last-known set of failed schedule IDs and compare to the latest query result on each cycle. New entries (schedules that flipped from healthy to failed since the last check) are the highest-priority candidates for donor outreach.

### Inspecting payment history

To understand *why* a specific schedule failed, fetch its activity history:

```bash cURL theme={null}
curl https://prod-api.raisedonors.com/api/RecurringGift/9876/activities \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

The activity records typically include each cycle's outcome (success or failure) and the reason. Useful for showing the customer's stewardship team a clear picture before they contact the donor.

### The recovery flow

The typical flow for recovering a failed schedule:

<Steps>
  <Step title="Detect the failure">
    Daily failed-schedules query identifies the schedule. New failures (not previously known) flow into the recovery queue.
  </Step>

  <Step title="Generate a personalized donor page">
    Use `POST /api/Donor/{donorId}/generate-page` to create a URL pre-filled with the donor's identity. The donor will land on a page where they can update payment info and resume their schedule.
  </Step>

  <Step title="Send the outreach email">
    Email the donor with the personalized URL. Be clear and kind: explain that their support has paused, that updating their payment method takes a few seconds, and provide the direct link.

    ```javascript JavaScript theme={null}
    async function sendPaymentUpdateEmail(donor, scheduleAmount) {
      const pageResp = await fetch(
        `https://prod-api.raisedonors.com/api/Donor/${donor.id}/generate-page`,
        {
          method: 'POST',
          headers: {
            Authorization: `Bearer ${process.env.RAISE_API_TOKEN}`,
            'Content-Type': 'application/json',
          },
        }
      );
      const { pageUrl } = await pageResp.json();

      await emailService.send({
        to: donor.email,
        subject: 'Update your payment method to continue your support',
        body: `Hi ${donor.firstName},

    We weren't able to process your most recent ${scheduleAmount} donation. To keep your support going, please take a moment to update your payment method:

    ${pageUrl}

    Thank you for your generosity.`,
      });
    }
    ```
  </Step>

  <Step title="Track the outreach">
    Record that you've contacted the donor and when. On the next daily run, skip donors you've already notified within the past N days to avoid spamming.
  </Step>

  <Step title="Re-check the schedule">
    After the donor updates their payment method (typically through the personalized page), the next charge cycle will succeed and `hasPaymentFailed` flips back to `false`. Your daily query naturally stops returning the schedule.
  </Step>

  <Step title="Escalate persistent failures">
    Schedules that remain `hasPaymentFailed: true` after multiple outreach attempts (e.g., 30 days, 3 emails) should be escalated for human review by the customer's stewardship team. They may need phone outreach or eventual schedule cancellation.
  </Step>
</Steps>

### Proactive: detect upcoming card expirations

A more effective pattern: contact donors before their card actually fails. Check the `expMonthAndYear` field on active schedules:

```javascript JavaScript theme={null}
async function findSchedulesExpiringWithinMonths(monthsAhead) {
  const allActive = await listAllActiveRecurringGifts();
  const now = new Date();
  const cutoff = new Date(now.getFullYear(), now.getMonth() + monthsAhead, 1);

  return allActive.filter((schedule) => {
    if (!schedule.expMonthAndYear) return false;
    const [month, year] = schedule.expMonthAndYear.split('/').map(Number);
    const expDate = new Date(year, month - 1, 1);
    return expDate < cutoff && expDate > now;
  });
}
```

<Note>
  The exact format of `expMonthAndYear` (`MM/YYYY`, `MM/YY`, or another shape) is not documented in the spec. Confirm against live data before parsing in production. See [Recurring Gifts: Detecting upcoming card expirations](/raise/concepts/recurring-gifts#detecting-upcoming-card-expirations).
</Note>

Donors notified before their cards expire respond at much higher rates than donors notified after the failure has already happened. The proactive flow is the same as the recovery flow — generate a personalized page, send an email — but with friendlier framing ("just a heads up, your card on file expires next month").

***

## Gateway-level outages

The third class of failures: the payment gateway itself is temporarily unavailable. Symptoms include:

* Multiple `POST /api/Raise/give` requests across different customers returning `5xx` responses or timing out within a short window.
* Multiple recurring schedules flipping to `hasPaymentFailed: true` in close succession.
* Network errors connecting to the API host.

For partner integrations:

### What to do during a likely outage

| Behavior                        | Description                                                                                                                                      |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Slow down submissions**       | Don't hammer a struggling system with retries. Back off aggressively.                                                                            |
| **Queue retries for later**     | For non-time-sensitive donations, queue and retry after the outage resolves.                                                                     |
| **Alert on sustained failures** | If >20% of recent submissions fail over 10+ minutes, alert the integration owner — this likely indicates an upstream issue, not random failures. |
| **Surface to donors clearly**   | Real-time donation flows should show a clear "we're having trouble — please try again in a few minutes" message rather than appearing broken.    |

### What not to do

| Anti-pattern                          | Why                                                                                                                       |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Retry forever                         | A 5xx that persists for hours is a gateway outage, not a transient blip. Cap retries and surface for human review.        |
| Retry without `Retry-After` awareness | If Raise returns `429 Too Many Requests`, honor the header. Adding more retries during throttling makes things worse.     |
| Silently absorb failures              | Failed donations are lost donations. Make sure the failure is logged, surfaced, and (where appropriate) queued for retry. |

See [Error Recovery Patterns](/raise/best-practices/error-recovery-patterns) for the broader retry-with-circuit-breaker pattern that handles outages gracefully.

***

## Webhook events for payment failures

The Raise webhook system delivers events when records change. For payment failures, the relevant events are typically:

| Likely event                | What it signals                                                                                  |
| --------------------------- | ------------------------------------------------------------------------------------------------ |
| RecurringGift update events | The schedule's `hasPaymentFailed` flag changed — useful for real-time detection without polling. |
| Gift events                 | A new Gift was created (successful charge) or a Gift was refunded.                               |

<Warning>
  **⚠️ Spec gap:** The Raise OpenAPI spec doesn't label the `EventType` enum integers (`[10, 11, 12, 20, 21, 22, 30, 31, 32, 40, 41, 42, 50, 51, 52]`). Identifying the specific event type integers that correspond to payment-failure scenarios requires discovery against the live API or coordination with the platform team.

  Until the labels are published, the daily-polling pattern shown above is the most reliable detection method. When event type labels become available, the same recovery flow can be triggered in real time from webhook events instead of from the polling query.
</Warning>

The webhook log endpoints (`GET /api/Webhook/{id}/log/list`, `GET /api/Webhook/{id}/log/{logId}`) are particularly useful here — partner integrations can confirm which events have fired for a customer and inspect the payload shapes to map event type integers to their semantic meanings.

***

## A complete failed-payment monitor

Pulling the pieces together as a daily monitor job:

```javascript JavaScript theme={null}
class FailedPaymentMonitor {
  constructor({ token, emailService, outreachTracker }) {
    this.token = token;
    this.emailService = emailService;
    this.outreachTracker = outreachTracker;
  }

  async runDaily() {
    const failedSchedules = await this.findFailedSchedules();
    const expiringSchedules = await this.findSchedulesExpiringWithinMonths(2);

    const newFailures = [];
    const newExpiringSoon = [];

    for (const schedule of failedSchedules) {
      if (!await this.outreachTracker.hasContacted(schedule.id, 'failed', 14)) {
        newFailures.push(schedule);
      }
    }

    for (const schedule of expiringSchedules) {
      if (!await this.outreachTracker.hasContacted(schedule.id, 'expiring', 30)) {
        newExpiringSoon.push(schedule);
      }
    }

    for (const schedule of newFailures) {
      await this.notifyFailure(schedule);
    }
    for (const schedule of newExpiringSoon) {
      await this.notifyExpiringSoon(schedule);
    }

    return {
      failedCount: newFailures.length,
      expiringCount: newExpiringSoon.length,
    };
  }

  async notifyFailure(schedule) {
    const pageUrl = await this.generateDonorPage(schedule.donor.id);
    await this.emailService.send({
      to: schedule.donor.email,
      template: 'payment-failed',
      data: { donor: schedule.donor, pageUrl, amount: schedule.formattedAmount },
    });
    await this.outreachTracker.recordContact(schedule.id, 'failed');
  }

  async notifyExpiringSoon(schedule) {
    const pageUrl = await this.generateDonorPage(schedule.donor.id);
    await this.emailService.send({
      to: schedule.donor.email,
      template: 'card-expiring',
      data: { donor: schedule.donor, pageUrl, expDate: schedule.expMonthAndYear },
    });
    await this.outreachTracker.recordContact(schedule.id, 'expiring');
  }

  // findFailedSchedules, findSchedulesExpiringWithinMonths, generateDonorPage
  // omitted for brevity — see the snippets earlier on this page.
}
```

Run this on a daily cron. Track outreach in a small database to avoid duplicate notifications. Monitor the counts over time — sudden spikes in `failedCount` may indicate a gateway issue rather than typical donor activity.

***

## Where to go next

<CardGroup cols={2}>
  <Card title="Configure a Recurring Gift" icon="arrows-rotate" href="/raise/workflows/configure-a-recurring-gift">
    The full recurring schedule lifecycle including the failure detection patterns.
  </Card>

  <Card title="Process a Donation" icon="hand-holding-dollar" href="/raise/workflows/process-a-donation">
    The end-to-end donation flow including the one-time failure handling.
  </Card>

  <Card title="Error Recovery Patterns" icon="arrows-rotate" href="/raise/best-practices/error-recovery-patterns">
    The general retry, circuit-breaker, and dead-letter patterns that apply to payment failures.
  </Card>

  <Card title="Webhooks Overview" icon="webhook" href="/raise/webhooks/overview">
    Subscribe to payment events for real-time failure detection.
  </Card>
</CardGroup>
