Refunds

A refund returns money to a buyer for a previous successful payment — a completed checkout session or a paid invoice. The Python SDK wraps the three refund endpoints behind plugipay.refunds. For wire format and the full lifecycle (which depends heavily on the upstream payment method), see API → Refunds.

Namespace

plug.refunds        # _Refunds

Methods

create

plug.refunds.create(
    *,
    source_type: str,
    source_id: str,
    amount: int | None = None,
    reason: str | None = None,
) -> Refund

Creates a refund against a payment source. source_type is "checkout_session" or "invoice". source_id is the underlying cs_ or inv_ id. If amount is omitted, refunds the full remaining refundable amount; pass an integer minor-unit value for partial refunds. reason is a free-form string surfaced to the buyer (where supported by the provider) and recorded for audit. The SDK auto-generates an idempotency key.

# Full refund
refund = plug.refunds.create(
    source_type="checkout_session",
    source_id="cs_01HX...",
    reason="customer_request",
)
print(refund["id"])         # "rfd_01HX..."
print(refund["status"])     # "pending" — async on most providers
# Partial refund — IDR 50,000.00 off a 149,000.00 charge
partial = plug.refunds.create(
    source_type="invoice",
    source_id="inv_01HX...",
    amount=50_000_00,
    reason="adjustment",
)

get

plug.refunds.get(refund_id: str) -> Refund

Polls a refund's current state. Refunds move pending → succeeded (or failed) — the time to settlement is provider-dependent (cards: minutes; bank transfers: days). Subscribe to plugipay.refund.succeeded.v1 / plugipay.refund.failed.v1 instead of polling.

refund = plug.refunds.get("rfd_01HX...")
if refund["status"] == "failed":
    print(refund.get("failureReason"))

list

plug.refunds.list(
    *,
    limit: int | None = None,
    cursor: str | None = None,
    status: str | None = None,
    source_id: str | None = None,
) -> PageResult[Refund]

Lists refunds. Filter by status ("pending" | "succeeded" | "failed") or source_id to see all refunds against one payment.

# Every refund tied to a session
page = plug.refunds.list(source_id="cs_01HX...")
for r in page.data:
    print(r["id"], r["amount"], r["status"])
# Failed refunds — usually need a manual nudge
cursor = None
failed = []
while True:
    page = plug.refunds.list(limit=100, status="failed", cursor=cursor)
    failed.extend(page.data)
    if not page.has_more:
        break
    cursor = page.cursor

Refunds don't carry an update or cancel. Once submitted, a refund is final — it either settles or fails. There is no API to "undo" a refund mid-flight. If a refund fails, you can call create again with the same parameters; the SDK's auto idempotency key means an accidental double-create on the same call is safe, but a deliberate retry across calls is not deduped (you'd issue a second refund).

Types

from plugipay import Refund, PageResult

Refund is a Resource subclass. Likely-read fields:

  • refund["id"]rfd_ + ULID.
  • refund["status"]"pending" | "succeeded" | "failed".
  • refund["sourceType"], refund["sourceId"].
  • refund["amount"], refund["currency"].
  • refund.get("reason") — your free-form string.
  • refund.get("failureReason") — provider's reason if status == "failed".
  • refund["createdAt"], refund.get("settledAt").

Full reference at API → Refunds.

Common patterns

Idempotent partial refunds. Use plug.request with your own key so duplicate refund attempts (e.g. from a retried webhook handler) only execute once:

plug.request(
    method="POST",
    path="/api/v1/refunds",
    body={
        "sourceType": "invoice",
        "sourceId": invoice_id,
        "amount": 50_000_00,
        "reason": "adjustment",
    },
    idempotency_key=f"adjust:{invoice_id}:50000",
)

Wait-for-terminal helper. Poll-with-backoff for synchronous-ish flows where you can't wait for the webhook:

import time
from plugipay import PlugipayError

def wait_for_refund(plug, refund_id, *, timeout_sec=60):
    deadline = time.time() + timeout_sec
    delay = 1.0
    while time.time() < deadline:
        refund = plug.refunds.get(refund_id)
        if refund["status"] in ("succeeded", "failed"):
            return refund
        time.sleep(delay)
        delay = min(delay * 1.5, 8.0)
    raise TimeoutError(f"refund {refund_id} did not settle in {timeout_sec}s")

Refund-then-recreate flow. Wrong amount charged? Refund full, then create a new checkout session for the correct amount:

plug.refunds.create(source_type="checkout_session", source_id=bad_session_id)
new = plug.checkout_sessions.create(
    amount=corrected_amount,
    currency="IDR",
    methods=["card"],
    success_url="...",
    cancel_url="...",
    customer_id=customer_id,
    metadata={"corrects": bad_session_id},
)

Reconcile failed refunds nightly. A failed refund didn't return the money — your customer is still waiting. Cron a sweep:

page = plug.refunds.list(limit=100, status="failed")
for r in page.data:
    notify_support(
        refund_id=r["id"],
        reason=r.get("failureReason", "unknown"),
        amount=r["amount"],
    )

Errors

err.status err.code Cause
400 validation_error Bad source_type, missing source_id, negative amount.
404 not_found source_id doesn't exist or refund id missing.
409 not_refundable Source isn't in a refundable state (open invoice, expired session, etc.).
409 amount_exceeds_refundable Requested amount > remaining refundable balance.
409 idempotency_key_reused Same Idempotency-Key against a different body.
422 provider_unsupported Underlying payment method doesn't support refunds (some bank transfer types).

All raise PlugipayError. See Errors.

Next

Plugipay — Payments that don't tax your success