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

# Refunds & failed purchases

> When a bill payment, recharge, or gift card can't be fulfilled after the user paid, return their money — safely, idempotently, and in the right place depending on how you collected it.

A purchase can fail at the provider *after* you've already charged your user.
Carriers reject the **same product + reference within a short window** by design —
airtime: "same transaction within 15 minutes"; gift cards: "one code per phone
number". When that happens the user paid but received nothing, and you need to
give it back.

Where the refund happens depends on **how you collected the money**.

**You charged the user off-platform (credits flow)**

Most integrators pre-fund Chipi credits and charge their own users by card,
transfer, or in-app balance — that side is yours. When a purchase fails:

* Chipi restores **your credits** automatically and sends
  `sku-purchase.refunded` (so your ledger reconciles off one signal).
* **You refund the user in your own payment system** (reverse the card charge,
  credit their in-app balance, etc.) — Chipi never touched the user's money,
  so only you can return it.

Listen for `sku-purchase.failed` / `sku-purchase.refunded` and trigger your
own refund. No on-chain work is involved.

**You collected an on-chain USDC payment**

If your user paid **USDC on-chain into your wallet** (e.g. a consumer wallet
app), the funds sit in **your** wallet — only you can sign them back. Chipi
emits `sku-purchase.refund-due` with a ready-to-use refund instruction; you
execute the on-chain refund. See [Executing an on-chain refund](#executing-an-on-chain-refund).

> [!NOTE]
>
> Returning the user's money is always the **integrator's responsibility** — you
> collected it, so you control where it sits. Chipi restores *your* credits and
> gives you the signal + data to make the user whole.

## Webhook events

Configure a webhook at `/configure/notifications` in the dashboard. Every body is
HMAC-signed (see [Verifying the signature](#verifying-the-signature)).

| Event | Meaning |
| - | - |
| `sku-purchase.completed` | Fulfilled successfully. |
| `sku-purchase.failed` | Terminal failure. Refund the user in *your* payment system if you charged them off-platform. |
| `sku-purchase.refunded` | **Your Chipi credits** were restored (internal accounting) — not the user's money. |
| `sku-purchase.refund-due` | You collected the user's payment **on-chain** and owe it back. Carries a `refund` instruction. |

## Verifying the signature

Each delivery includes a `chipi-signature` header: `HMAC-SHA256(rawBody, signingKey)`
as hex. Verify it against the **raw** request body before trusting the event.

```ts theme={null}
import { createHmac, timingSafeEqual } from "crypto";

function verifyChipiSignature(rawBody: string, signature: string, signingKey: string): boolean {
  const expected = createHmac("sha256", signingKey).update(rawBody).digest("hex");
  try {
    return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"));
  } catch {
    return false;
  }
}
```

## Executing an on-chain refund

Only relevant if you collected an on-chain USDC payment. On `sku-purchase.refund-due`
the payload carries everything you need:

```json theme={null}
{
  "event": "sku-purchase.refund-due",
  "data": {
    "skuPurchaseTransaction": { "...": "the full transaction row" },
    "refund": {
      "recipient": "0x…",                 // the wallet that paid — refund here
      "token": "USDC",
      "amount": "12.50",                   // ADVISORY (recorded debit) — verify on-chain
      "paymentTransactionHash": "0x…",     // the idempotency key
      "reason": "fulfillment_failed"
    }
  }
}
```

Returning money is irreversible, so the executor must be defensive. These are the
same four guardrails Chipi's own consumer wallet uses.

**1. Verify settlement by the Transfer event — never the tx status**

A Starknet tx can report `execution_status = SUCCEEDED` while the inner
transfer no-op'd (a "phantom success"). Read the receipt and confirm a **USDC
`Transfer` event into your wallet** actually fired. No event → no money
arrived → do not refund. This doubles as the ownership gate: only refund
payments that settled into *your* wallet.

**2. Be idempotent on the payment hash**

Reserve `refund-<paymentTransactionHash>` **before** sending; if it already
exists, skip. Webhooks can deliver more than once — refund **exactly once**.

**3. Cap the amount**

`refund.amount` is advisory; verify the actual settled `Transfer` amount and
never exceed it, bounded by a sane maximum. Above the cap, escalate to manual
review instead of auto-paying.

**4. Refund USDC from your wallet**

Send USDC from your collection wallet back to `refund.recipient` and record the
refund tx hash so it's auditable.

```ts theme={null}
import { Account, Contract, RpcProvider, cairo } from "starknet";

async function handleRefundDue(refund: {
  recipient: string;
  paymentTransactionHash: string;
  amount: string;
}) {
  const provider = new RpcProvider({ nodeUrl: process.env.STARKNET_RPC_URL! });

  // 1. Verify a USDC Transfer into YOUR wallet actually settled (phantom guard).
  const receipt: any = await provider.getTransactionReceipt(refund.paymentTransactionHash);
  const settled =
    receipt.execution_status === "SUCCEEDED" &&
    receipt.events?.some(
      (e: any) =>
        BigInt(e.from_address) === BigInt(USDC_ADDRESS) &&
        [...(e.keys ?? []), ...(e.data ?? [])].some((x: string) => BigInt(x) === BigInt(MY_WALLET)),
    );
  if (!settled) return;

  // 2. Idempotency: reserve before sending.
  if (!(await reserveRefundOnce(refund.paymentTransactionHash))) return;

  // 3. Cap. 4. Send USDC back to the user.
  const amount = Math.min(Number(refund.amount), MAX_REFUND_USD);
  const account = new Account({ provider, address: MY_WALLET, signer: process.env.MY_WALLET_PK! });
  const usdc = new Contract({ abi: ERC20_ABI, address: USDC_ADDRESS });
  const call = usdc.populate("transfer", [
    refund.recipient,
    cairo.uint256(BigInt(Math.floor(amount * 1_000_000))), // USDC is 6 dp
  ]);
  const { transaction_hash } = await account.execute(call);
  await markRefundSent(refund.paymentTransactionHash, transaction_hash);
}
```

> [!TIP]
>
> Return `2xx` quickly and do the on-chain work asynchronously so a slow refund
> doesn't trigger webhook retries. Idempotency makes retries safe regardless.

## Avoid charging twice in the first place

Refunds are the safety net — the better fix is not double-charging:

* **Reuse the idempotency key on retry.** `transactionHash` on `POST /v1/sku-purchases`
  is the idempotency key. Persist it per `(skuId, reference, amount)` and reuse it
  when the user taps "try again" — a repeated key returns the **existing** purchase
  instead of creating a second charge.
* **Lock the buy action while a purchase is in flight** to prevent a double-submit.
* Chipi also rejects an exact `(skuId, reference, amount)` repeat within the
  carrier's dedup window before any new debit, as a server-side backstop.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.