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

# Refunds

> Process refunds through HitPay and report them back to Stripe for unified reconciliation in the Custom Payment Method integration.

Because HitPay processes the actual payment, refunds must be initiated through HitPay first — then reported back to Stripe. The bridge between the two systems is the **Stripe Payment Record**, which stores the HitPay payment ID at the time the payment is confirmed.

## How It Works

When a payment completes, your backend calls `paymentRecords.reportPayment()` and embeds the HitPay payment ID inside the Payment Record — both in `processor_details` and in `metadata`. This creates a persistent, queryable link between the Stripe record and the HitPay payment that you can use to issue refunds later.

### Key Concepts

| Component                           | Purpose                                                                                                                                                       |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Stripe Payment Record**           | The source of truth linking Stripe and HitPay. Stores the HitPay payment ID in `processor_details.custom.payment_reference` and `metadata.hitpay_payment_id`. |
| **HitPay Refund API**               | Executes the actual money movement. This is the critical step — if it fails, the refund is aborted.                                                           |
| **`paymentRecords.reportRefund()`** | Reports the completed HitPay refund back to Stripe so it appears in unified reporting and reconciliation.                                                     |

### API References

<CardGroup cols={3}>
  <Card title="HitPay Refund API" icon="code" href="https://docs.hitpayapp.com/apis/reference/refund-payment">
    Initiate a refund by `payment_id` and `amount`.
  </Card>

  <Card title="Stripe reportRefund" icon="stripe" href="https://docs.stripe.com/api/payment-records/report-refund">
    Report a completed refund back to a Stripe Payment Record.
  </Card>

  <Card title="Stripe Payment Records" icon="receipt" href="https://docs.stripe.com/api/payment-records">
    Retrieve Payment Records and their metadata.
  </Card>
</CardGroup>

***

## How the HitPay Payment ID is Stored

At payment confirmation time, your backend calls `paymentRecords.reportPayment()` with the HitPay payment ID embedded in two fields:

<Accordion title="Storing HitPay payment ID in reportPayment()">
  ```typescript theme={"system"}
  const paymentRecord = await stripe.paymentRecords.reportPayment(
    {
      // ... amount, payment_method_details, etc.
      processor_details: {
        type: "custom",
        custom: {
          payment_reference: hitpayPaymentId,  // ← stored here
        },
      },
      metadata: {
        hitpay_payment_id: hitpayPaymentId,    // ← and here for easy lookup
        hitpay_payment_request_id: hitpayPaymentRequestId,
        stripe_payment_intent_id: paymentIntentId,
      },
    },
    { idempotencyKey: `prec-${hitpayPaymentRequestId}` }
  );
  ```
</Accordion>

To look up the HitPay payment ID later, retrieve the Payment Record and read from either field:

```typescript theme={"system"}
const paymentRecord = await stripe.paymentRecords.retrieve(paymentRecordId);

// Option A — from processor_details
const hitpayPaymentId = paymentRecord.processor_details?.custom?.payment_reference;

// Option B — from metadata
const hitpayPaymentId = paymentRecord.metadata?.hitpay_payment_id;
```

<Note>
  The Payment Record ID itself is typically stored on the PaymentIntent metadata as `stripe_payment_record_id` at the time of payment confirmation, so you can always trace: **PaymentIntent → Payment Record → HitPay Payment**.
</Note>

***

## Refund Flow

```mermaid theme={"system"}
sequenceDiagram
    participant Backend
    participant Stripe as Stripe API
    participant HitPay as HitPay API

    Backend->>Stripe: paymentRecords.retrieve(paymentRecordId)
    Stripe-->>Backend: paymentRecord.metadata.hitpay_payment_id

    rect rgb(255, 240, 230)
    Note over Backend,HitPay: HitPay API — critical path
    Backend->>HitPay: POST /v1/refund { payment_id, amount }
    HitPay-->>Backend: { id: hitpayRefund.id, status, amount_refunded }
    end

    rect rgb(230, 240, 255)
    Note over Backend,Stripe: Stripe API — non-blocking
    Backend->>Stripe: paymentRecords.reportRefund(paymentRecordId, { amount, refund_reference: hitpayRefund.id })
    end
```

<Note>
  The HitPay refund call is the **only step that aborts the flow** on failure. The Stripe `reportRefund` call is best-effort — if it fails, the customer has still been refunded by HitPay.
</Note>

***

## API Calls Summary

| Step | System | Method / Endpoint                        | Purpose                                                      | Blocking?                   |
| ---- | ------ | ---------------------------------------- | ------------------------------------------------------------ | --------------------------- |
| 1    | Stripe | `paymentRecords.retrieve(id)`            | Fetch the HitPay payment ID from Payment Record metadata     | Yes                         |
| 2    | HitPay | `POST /v1/refund`                        | Execute the actual refund — moves funds back to the customer | **Yes — aborts on failure** |
| 3    | Stripe | `paymentRecords.reportRefund(id, {...})` | Report the completed refund back to Stripe                   | No                          |

***

## Implementation

<Steps>
  <Step title="Retrieve the Payment Record">
    <Badge color="gray">Server-side</Badge>

    Look up the Stripe Payment Record to retrieve the HitPay payment ID. The Payment Record ID should have been stored on the PaymentIntent metadata as `stripe_payment_record_id` at payment confirmation time.

    <Accordion title="Retrieve Payment Record">
      ```typescript theme={"system"}
      import Stripe from "stripe";

      const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

      // Retrieve the payment record
      const paymentRecord = await stripe.paymentRecords.retrieve(paymentRecordId);
      const hitpayPaymentId = paymentRecord.metadata?.hitpay_payment_id;

      if (!hitpayPaymentId) {
        return Response.json(
          { error: "Payment Record is missing the HitPay payment ID." },
          { status: 400 }
        );
      }
      ```
    </Accordion>
  </Step>

  <Step title="Call the HitPay Refund API">
    <Badge color="gray">Server-side · Critical path</Badge>

    Convert the amount from cents to a decimal string and call HitPay's refund endpoint. If this fails, the entire refund is aborted.

    <Accordion title="Call HitPay Refund API">
      ```typescript theme={"system"}
      const HITPAY_API_URL = "https://api.hit-pay.com"; // use https://api.sandbox.hit-pay.com for sandbox

      const hitpayResponse = await fetch(`${HITPAY_API_URL}/v1/refund`, {
        method: "POST",
        headers: {
          "X-BUSINESS-API-KEY": process.env.HITPAY_API_KEY!,
          "Content-Type": "application/x-www-form-urlencoded",
        },
        body: new URLSearchParams({
          payment_id: hitpayPaymentId,
          amount: (amountInCents / 100).toFixed(2), // HitPay expects decimal, e.g. "50.00"
        }),
      });

      if (!hitpayResponse.ok) {
        const error = await hitpayResponse.json();
        return Response.json({ error: error.message ?? "HitPay refund failed" }, { status: 500 });
      }

      const hitpayRefund = await hitpayResponse.json();
      // hitpayRefund.id links this refund back in Stripe
      ```
    </Accordion>

    <Tip>
      HitPay accepts `amount` as a decimal string (e.g. `"50.00"`). Stripe works in integer cents (e.g. `5000`). Always convert at system boundaries.
    </Tip>
  </Step>

  <Step title="Report the refund to Stripe">
    <Badge color="gray">Server-side · Non-blocking</Badge>

    Report the completed refund back to the Stripe Payment Record. Pass the HitPay refund ID as `refund_reference` to cross-link the records.

    <Accordion title="Report refund to Stripe">
      ```typescript theme={"system"}
      try {
        await stripe.paymentRecords.reportRefund(paymentRecordId, {
          amount: {
            value: amountInCents,        // Stripe expects cents
            currency: currency,          // e.g. "sgd"
          },
          processor_details: {
            type: "custom",
            custom: {
              refund_reference: hitpayRefund.id,  // links to HitPay refund
            },
          },
          outcome: "refunded",
          refunded: {
            refunded_at: Date.now(),
          },
        });
      } catch (err) {
        console.error("[Refund] Failed to report refund to Stripe:", err);
        // Continue — HitPay refund already processed
      }
      ```
    </Accordion>
  </Step>
</Steps>

***

## Subscription Invoices

For subscriptions, payments are tied to Stripe invoices rather than PaymentIntents directly. The same linkage applies — when the HitPay payment is confirmed for a subscription invoice, store both IDs in the invoice metadata:

```typescript theme={"system"}
await stripe.invoices.update(invoiceId, {
  metadata: {
    hitpay_payment_id: hitpayPaymentId,
    stripe_payment_record_id: paymentRecord.id,
  },
});
```

At refund time, retrieve the invoice to get both IDs, then follow the same refund steps above.

<Tip>
  See [Auto-Charge](/connections/stripe/auto-charge) and [Out-of-Band](/connections/stripe/out-of-band) for how subscription payments are confirmed and how these metadata values are written.
</Tip>

***

## Error Handling

The refund flow uses a **critical path / best-effort** pattern:

| Step                          | Failure Behavior                                                   |
| ----------------------------- | ------------------------------------------------------------------ |
| **HitPay `POST /v1/refund`**  | Returns error immediately — Stripe `reportRefund` does not execute |
| `paymentRecords.reportRefund` | Logs error and continues — customer has already been refunded      |

***

## Testing

1. Complete a test payment using a sandbox HitPay payment method
2. Confirm the Payment Record has `hitpay_payment_id` in its metadata
3. Initiate a refund with an amount ≤ the original payment
4. Verify in the HitPay dashboard: refund appears under the original payment
5. Verify in the Stripe dashboard: Payment Record shows the refund report

**Sandbox credentials:**

| System | Environment Variable        |
| ------ | --------------------------- |
| HitPay | `HITPAY_API_KEY_SANDBOX`    |
| Stripe | `STRIPE_SECRET_KEY_SANDBOX` |

***

## FAQ

<AccordionGroup>
  <Accordion title="Why am I seeing 'Payment Record is missing the HitPay payment ID'?">
    The `hitpay_payment_id` was not written to the Payment Record's metadata when the original payment was confirmed. Check your `paymentRecords.reportPayment()` call and ensure the `metadata` object includes `hitpay_payment_id`. See [One-Time Payments](/connections/stripe/one-time-payments).
  </Accordion>

  <Accordion title="Why is the HitPay refund API returning an error?">
    Common causes:

    * **Invalid `payment_id`**: Ensure you're passing the HitPay payment ID, not a Stripe ID.
    * **Amount exceeds original payment**: HitPay rejects amounts greater than what was paid.
    * **Already refunded**: HitPay does not allow a second refund on a fully refunded payment.
    * **Wrong API key or environment**: Confirm your `HITPAY_API_KEY` matches the environment (sandbox vs. production).
  </Accordion>

  <Accordion title="Why is Stripe reportRefund failing with 'Payment Record not found'?">
    The Payment Record ID may be from a different Stripe account or environment. Confirm the `STRIPE_SECRET_KEY` matches the account where the Payment Record was originally created.
  </Accordion>
</AccordionGroup>
