> ## 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**.

<Tabs>
  <Tab title="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.
  </Tab>

  <Tab title="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).
  </Tab>
</Tabs>

<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.
</Note>

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

<Steps>
  <Step title="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.
  </Step>

  <Step title="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**.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

```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.
</Tip>

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