Skip to main content
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

ComponentPurpose
Stripe Payment RecordThe 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 APIExecutes 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

HitPay Refund API

Initiate a refund by payment_id and amount.

Stripe reportRefund

Report a completed refund back to a Stripe Payment Record.

Stripe Payment Records

Retrieve Payment Records and their metadata.

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:
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}` }
);
To look up the HitPay payment ID later, retrieve the Payment Record and read from either field:
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;
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.

Refund Flow

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.

API Calls Summary

StepSystemMethod / EndpointPurposeBlocking?
1StripepaymentRecords.retrieve(id)Fetch the HitPay payment ID from Payment Record metadataYes
2HitPayPOST /v1/refundExecute the actual refund — moves funds back to the customerYes — aborts on failure
3StripepaymentRecords.reportRefund(id, {...})Report the completed refund back to StripeNo

Implementation

1

Retrieve the Payment Record

Server-sideLook 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.
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 }
  );
}
2

Call the HitPay Refund API

Server-side · Critical pathConvert the amount from cents to a decimal string and call HitPay’s refund endpoint. If this fails, the entire refund is aborted.
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
HitPay accepts amount as a decimal string (e.g. "50.00"). Stripe works in integer cents (e.g. 5000). Always convert at system boundaries.
3

Report the refund to Stripe

Server-side · Non-blockingReport the completed refund back to the Stripe Payment Record. Pass the HitPay refund ID as refund_reference to cross-link the records.
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
}

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:
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.
See Auto-Charge and Out-of-Band for how subscription payments are confirmed and how these metadata values are written.

Error Handling

The refund flow uses a critical path / best-effort pattern:
StepFailure Behavior
HitPay POST /v1/refundReturns error immediately — Stripe reportRefund does not execute
paymentRecords.reportRefundLogs 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:
SystemEnvironment Variable
HitPayHITPAY_API_KEY_SANDBOX
StripeSTRIPE_SECRET_KEY_SANDBOX

Troubleshooting

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.
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).
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.