Stripe Refunds: Process Full and Partial Refunds via API
In this tutorial, you will learn about Stripe Refunds: Process Full and Partial Refunds via API. We cover key concepts, practical examples, and best practices to help you master this topic.
Stripe refunds reverse a charge partially or fully. Processing refunds correctly requires understanding refund rules, timing, idempotency, and handling edge cases.
What You'll Learn
How to process full and partial refunds, use idempotency keys, handle refund timing Windows, manage refund reasons (requested_by_customer, duplicate, fraudulent), and build a refund dashboard.
Why It Matters
Refunds are a normal part of business, but errors (duplicate refunds, refunding the wrong amount) cost money and annoy customers. DodaTech processed 500+ refunds last year with zero errors through idempotency.
Real-World Use
A customer requests a refund. The support agent enters the PaymentIntent ID and refund amount in the admin dashboard. The system calls Stripe's refund API with idempotency and logs the result.
flowchart LR
A["Customer\nRequests Refund"] --> B["Support\nEnters Details"]
B --> C["Create Refund\n+ Idempotency Key"]
C --> D{"Refund\nStatus?"}
D -->|"succeeded"| E["Money Returned\nto Customer"]
D -->|"pending"| F["Bank Processing\n(7-10 days)"]
D -->|"failed"| G["Check Reason\nRetry or Escalate"]
style C fill:#dbeafe,stroke:#2563eb
style D fill:#fef3c7,stroke:#d97706
style E fill:#bbf7d0,stroke:#16a34a
Full Refund
import stripe
import uuid
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
def process_full_refund(payment_intent_id, reason="requested_by_customer"):
try:
refund = stripe.Refund.create(
payment_intent=payment_intent_id,
reason=reason,
idempotency_key=str(uuid.uuid4()),
metadata={
"initiated_by": "support_agent",
"ticket_id": "TKT-789"
}
)
print(f"Refund: {refund.id}")
print(f"Amount: ${refund.amount/100:.2f}")
print(f"Status: {refund.status}")
print(f"Reason: {refund.reason}")
return refund
except stripe.error.InvalidRequestError as e:
print(f"Refund failed: {e}")
return None
refund = process_full_refund("pi_3Mqwerty19QfG5XG0gTzFz1L")
# Expected output:
# Refund: re_1MqwertyABC123
# Amount: $29.99
# Status: succeeded
Partial Refund
def process_partial_refund(payment_intent_id, amount_cents, reason):
refund = stripe.Refund.create(
payment_intent=payment_intent_id,
amount=amount_cents,
reason=reason,
metadata={"partial_refund": "true", "remaining": "call_credits"}
)
print(f"Partial refund of ${amount_cents/100:.2f}: {refund.id}")
print(f"Remaining charge: ${refund.payment_intent/100:.2f}")
return refund
process_partial_refund("pi_3Mqwerty19QfG5XG0gTzFz1L", 1000, "requested_by_customer")
# Expected output: Partial refund of $10.00: re_1MqwertyABC456
Refund with Idempotency
def safe_refund(payment_intent_id, amount_cents=None):
idempotency_key = f"refund_{payment_intent_id}_{uuid.uuid4()}"
refund_params = {
"payment_intent": payment_intent_id,
"idempotency_key": idempotency_key
}
if amount_cents:
refund_params["amount"] = amount_cents
refund = stripe.Refund.create(**refund_params)
print(f"Safe refund: {refund.id} (key: {idempotency_key[:20]}...)")
# If network fails and we retry with same key, Stripe returns same refund
duplicate = stripe.Refund.create(**refund_params)
print(f"Duplicate with same key: {duplicate.id} (same as original)")
return refund
Checking Refund Status
def get_refund_status(refund_id):
refund = stripe.Refund.retrieve(refund_id)
print(f"Refund {refund_id}: {refund.status}")
print(f" Amount: ${refund.amount/100:.2f}")
print(f" Created: {refund.created}")
print(f" Reason: {refund.reason}")
if refund.status == "succeeded":
print(" Funds returned to customer")
elif refund.status == "pending":
print(" Processing — may take 7-10 business days")
elif refund.status == "failed":
print(f" Failed: {refund.failure_reason}")
return refund
Common Mistakes
1. Not Using Idempotency Keys
Without idempotency, a network retry can create duplicate refunds. Always use unique idempotency keys.
2. Refunding After the Time Window
Refunds must be processed within 120 days of the original charge. After that, issue a manual payment (send money via bank transfer).
3. Confusing Refunds with Disputes
A refund is voluntary. A dispute (chargeback) is initiated by the cardholder's bank. Don't refund disputed charges — handle them through the dispute process.
4. Over-Refunding
You can refund up to the original charge amount. Attempting to refund more fails with an error. Track cumulative refund amounts per PaymentIntent.
5. Forgetting to Log Refund Reasons
Stripe requires a refund reason. Log the reason and associate it with a support ticket for auditing and analytics.
Practice Questions
- What is the refund time window?
- How do idempotency keys prevent duplicate refunds?
- What is the difference between partial and full refunds?
- How do you handle a failed refund?
Answers:
- 120 days from the original charge. After that, the refund must be issued outside of Stripe (bank transfer, check).
- Same idempotency key + same parameters = same result. Retries with the same key return the original refund instead of creating a duplicate.
- Partial refunds return only part of the charge amount. Full refunds return the entire amount. Both can be combined (multiple partial refunds).
- Check
refund.failure_reasonfor details. Common reasons:merchant_request,account_closed,processing_error. Retry or handle manually.
Challenge: Build a refund management system: process refunds with idempotency keys, support full and partial refunds, check refund status, handle failed refunds with retry logic, and maintain a refund log with amounts, reasons, and support ticket associations.
FAQ
Mini Project
Build a refund management dashboard: process full and partial refunds with idempotency keys, handle 120-day window checks, log refunds with reasons and ticket IDs, create a refund status tracker with notifications on status changes, and report monthly refund totals.
What's Next
Disputes — handle chargebacks and dispute responses.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro