Skip to main content
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.
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.
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).
EventMeaning
sku-purchase.completedFulfilled successfully.
sku-purchase.failedTerminal failure. Refund the user in your payment system if you charged them off-platform.
sku-purchase.refundedYour Chipi credits were restored (internal accounting) — not the user’s money.
sku-purchase.refund-dueYou 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.
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:
{
  "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.
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);
}
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.