Skip to main content
RapidDev - Software Development Agency
API AutomationsStripeAPI Key

How to Automate Stripe Refunds using the API

Automate Stripe refunds by calling POST /v1/refunds with the charge or payment_intent ID and optional amount for partial refunds. Listen for the charge.refunded webhook to update internal systems after processing. Key gotcha: Stripe keeps the original processing fee (2.9% + $0.30) — you absorb it on refunds. Rate limit: 100 req/sec in live mode.

Need help automating? Talk to an expert
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner7 min read15-30 minutesStripeMay 2026RapidDev Engineering Team
TL;DR

Automate Stripe refunds by calling POST /v1/refunds with the charge or payment_intent ID and optional amount for partial refunds. Listen for the charge.refunded webhook to update internal systems after processing. Key gotcha: Stripe keeps the original processing fee (2.9% + $0.30) — you absorb it on refunds. Rate limit: 100 req/sec in live mode.

API Quick Reference

Auth

API Key (Bearer token)

Rate limit

100 requests/second (live mode)

Format

JSON

SDK

Available

Understanding the Stripe API

Stripe's Refunds API lets you issue full or partial refunds programmatically against any existing charge or payment intent. Refunds reduce your Stripe balance immediately, though they take 5-10 business days to appear on the customer's card statement depending on their bank.

The key architecture for automated refunds is: trigger (support ticket, business rule, or webhook) → call POST /v1/refunds → listen for charge.refunded webhook to confirm completion and update your database. Stripe handles all communication with the card networks — you don't need to interact with banks directly.

One critical business note: when you issue a refund, Stripe does not return the original processing fee (2.9% + $0.30 for standard domestic cards). This fee is absorbed by your business. Plan your refund policy accordingly — bulk refund operations will have a real cost impact. Official documentation: https://stripe.com/docs/api/refunds

Base URLhttps://api.stripe.com

Setting Up Stripe API Authentication

Stripe uses simple API key authentication. Your secret key (sk_*) is used for all server-side operations including refunds. Refund operations must always run server-side — never expose your secret key to the browser or mobile app code.

  1. 1Log in to your Stripe Dashboard at dashboard.stripe.com
  2. 2Click 'Developers' in the top navigation bar, then 'API keys'
  3. 3Copy your secret key — sk_live_* for production, sk_test_* for development
  4. 4Set it as an environment variable: export STRIPE_SECRET_KEY=sk_live_...
  5. 5For refund automation, consider creating a Restricted key with only 'Write' access to Charges/Refunds under Developers > API keys > Create restricted key
  6. 6Verify connectivity with a test call to GET /v1/balance
auth.py
1import os
2import stripe
3
4stripe.api_key = os.environ.get('STRIPE_SECRET_KEY')
5
6# Verify auth works
7try:
8 balance = stripe.Balance.retrieve()
9 available = balance.available[0].amount / 100
10 currency = balance.available[0].currency.upper()
11 print(f'Auth OK — Available balance: {currency} {available:.2f}')
12except stripe.error.AuthenticationError as e:
13 print(f'Authentication failed: {e}')

Security notes

  • Store secret keys in environment variables — never hardcode in source code
  • Refund operations must be server-side only — secret keys must never appear in frontend code
  • Create a Restricted key with only refund permissions rather than using the full secret key for this automation
  • Log refund requests with the charge ID and amount for audit trails, but never log the API key itself
  • Use sk_test_* keys to test your refund logic against Stripe's test charges before going live
  • Implement a human approval step for refunds above a threshold amount before calling the API

Key endpoints

POST/v1/refunds

Issues a refund against a charge or payment intent. Omit the amount parameter for a full refund, or specify amount in smallest currency units (cents) for a partial refund.

ParameterTypeRequiredDescription
chargestringoptionalThe charge ID (ch_*) to refund. Use this OR payment_intent, not both.
payment_intentstringoptionalThe payment intent ID (pi_*) to refund. Stripe resolves the underlying charge automatically.
amountnumberoptionalAmount to refund in smallest currency units (cents for USD). Omit for full refund.
reasonstringoptionalReason for the refund: duplicate, fraudulent, or requested_by_customer. Affects chargeback dispute documentation.
metadataobjectoptionalKey-value pairs to attach to the refund for internal tracking (support ticket ID, order ID, etc.)

Request

json
1{"charge": "ch_1OqKLt2eZvKYlo2Cabc123", "amount": 1000, "reason": "requested_by_customer", "metadata": {"support_ticket": "TICKET-4821"}}

Response

json
1{"id": "re_1OqKMj2eZvKYlo2C", "object": "refund", "amount": 1000, "charge": "ch_1OqKLt2eZvKYlo2Cabc123", "created": 1706745820, "currency": "usd", "metadata": {"support_ticket": "TICKET-4821"}, "reason": "requested_by_customer", "receipt_number": null, "status": "succeeded"}
GET/v1/refunds/{id}

Retrieves the current status of a refund. Status values: pending, succeeded, failed, canceled. Failed refunds require creating a new refund.

ParameterTypeRequiredDescription
idstringrequiredThe refund ID (re_*) returned from the POST /v1/refunds call

Response

json
1{"id": "re_1OqKMj2eZvKYlo2C", "object": "refund", "amount": 1000, "charge": "ch_1OqKLt2eZvKYlo2Cabc123", "created": 1706745820, "currency": "usd", "status": "succeeded", "failure_balance_transaction": null, "failure_reason": null}
GET/v1/charges/{id}

Retrieve a charge to verify its status and refund eligibility before issuing a refund. Check amount_refunded vs amount to determine how much can still be refunded.

ParameterTypeRequiredDescription
idstringrequiredThe charge ID (ch_*) to retrieve

Response

json
1{"id": "ch_1OqKLt2eZvKYlo2Cabc123", "object": "charge", "amount": 5000, "amount_captured": 5000, "amount_refunded": 0, "currency": "usd", "paid": true, "refunded": false, "status": "succeeded", "customer": "cus_PqKLt2", "metadata": {"order_id": "ORD-881"}}
GET/v1/refunds

List all refunds, optionally filtered by charge ID. Use this to check if a charge has already been refunded before issuing a duplicate.

ParameterTypeRequiredDescription
chargestringoptionalFilter refunds by charge ID to check existing refunds before issuing a new one
limitnumberoptionalRecords per page, max 100

Response

json
1{"object": "list", "data": [{"id": "re_1OqKMj2", "amount": 1000, "charge": "ch_1OqKLt2", "status": "succeeded", "created": 1706745820}], "has_more": false}

Step-by-step automation

1

Verify the Charge Exists and Is Refundable

Why: Calling refund on an already-fully-refunded charge or a failed charge returns an error — pre-checking prevents wasted API calls and confusing error messages.

Before issuing a refund, retrieve the charge to verify it succeeded, check the amount_refunded field to know how much has already been returned, and calculate the maximum refundable amount. A charge with status 'succeeded' and amount_refunded < amount is eligible for refund.

request.sh
1curl https://api.stripe.com/v1/charges/ch_1OqKLt2eZvKYlo2Cabc123 \
2 -u "$STRIPE_SECRET_KEY:"

Pro tip: Store the charge ID on your order object when the payment succeeds. This makes looking up which charge to refund trivial without having to search by customer or payment intent.

Expected result: The charge object with amount, amount_refunded, and status fields. If status is 'succeeded' and refunded is false, you can proceed.

2

Issue the Refund

Why: POST /v1/refunds creates the refund and immediately updates your Stripe balance — the card network processing happens asynchronously.

Call POST /v1/refunds with either the charge ID or payment intent ID. For partial refunds, specify the amount in cents. Always include a reason (requested_by_customer, duplicate, or fraudulent) and attach metadata with your support ticket or order ID for audit trails. The refund status is usually 'succeeded' immediately for card payments.

request.sh
1# Full refund
2curl https://api.stripe.com/v1/refunds \
3 -u "$STRIPE_SECRET_KEY:" \
4 -d charge="ch_1OqKLt2eZvKYlo2Cabc123" \
5 -d reason="requested_by_customer" \
6 -d "metadata[support_ticket]"="TICKET-4821"
7
8# Partial refund ($10.00)
9curl https://api.stripe.com/v1/refunds \
10 -u "$STRIPE_SECRET_KEY:" \
11 -d charge="ch_1OqKLt2eZvKYlo2Cabc123" \
12 -d amount=1000 \
13 -d reason="requested_by_customer"

Pro tip: For payment_intent-based flows (Stripe's modern approach), pass payment_intent: 'pi_...' instead of charge. Stripe resolves the correct charge automatically, which avoids errors when a payment intent has multiple charge attempts.

Expected result: A refund object with id (re_*), status (usually 'succeeded' for cards), and amount. The refund is now queued with the card network.

3

Handle the charge.refunded Webhook

Why: Webhooks ensure your database stays in sync with Stripe — the refund may take seconds to hours to reach 'succeeded' status, and you should update your order status exactly once.

Register a webhook endpoint in your Stripe Dashboard for the charge.refunded event. When Stripe fires it, verify the signature using constructEvent() with the raw request body (not parsed JSON), then update your order's status. The most common error is parsing the body before verification, which breaks the signature check.

request.sh
1# Register a webhook endpoint programmatically
2curl https://api.stripe.com/v1/webhook_endpoints \
3 -u "$STRIPE_SECRET_KEY:" \
4 -d url="https://yourapp.com/webhooks/stripe" \
5 -d "enabled_events[]"="charge.refunded" \
6 -d "enabled_events[]"="charge.failed"

Pro tip: The charge.refunded event fires for every refund on a charge, including partial ones. Check event.data.object.amount_refunded vs event.data.object.amount to determine if the charge is now fully refunded.

Expected result: Your webhook handler receives the charge.refunded event, verifies the signature, and updates your order status. Stripe expects a 2xx response within 30 seconds.

4

Process Bulk Refunds from a Queue

Why: Batch refund scenarios (product recalls, billing errors, SLA credits) involve hundreds of refunds — processing them sequentially with rate limiting prevents 429 errors.

For bulk refund operations, load the list of charge IDs and amounts from your database or CSV, then process them with controlled pacing. Add a small delay between requests to stay well within the 100 req/sec limit. Implement a checkpoint mechanism to resume if the script fails midway through.

request.sh
1# Process a list of charges from a file (one per line)
2while IFS=',' read -r charge_id amount; do
3 echo "Refunding charge $charge_id for $amount cents"
4 curl https://api.stripe.com/v1/refunds \
5 -u "$STRIPE_SECRET_KEY:" \
6 -d charge="$charge_id" \
7 -d amount="$amount" \
8 -d reason="requested_by_customer"
9 sleep 0.1 # Stay well under 100 req/sec
10done < charges_to_refund.csv

Pro tip: For very large bulk refunds (1,000+), stagger processing over multiple hours to avoid sudden drops in your Stripe balance that could affect payout timing.

Expected result: All charges in the list are refunded, with succeeded/failed counts logged. The checkpoint file allows the script to resume if interrupted.

Complete working code

This script processes refund requests from a support queue — reads pending refunds from a database or CSV, validates each charge, issues the refund via Stripe, and updates order status. Includes idempotency checking to prevent double-refunds.

automate_stripe_refunds.py
1import stripe
2import os
3import time
4import logging
5from dataclasses import dataclass
6from typing import Optional
7
8logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
9log = logging.getLogger(__name__)
10
11stripe.api_key = os.environ['STRIPE_SECRET_KEY']
12
13@dataclass
14class RefundRequest:
15 charge_id: str
16 amount_cents: Optional[int] = None # None = full refund
17 reason: str = 'requested_by_customer'
18 ticket_id: Optional[str] = None
19
20def is_already_refunded(charge_id, amount_cents):
21 existing = stripe.Refund.list(charge=charge_id, limit=10)
22 total_refunded = sum(r.amount for r in existing.data if r.status == 'succeeded')
23 charge = stripe.Charge.retrieve(charge_id)
24 if amount_cents is None: # Full refund check
25 return charge.refunded
26 return total_refunded >= amount_cents
27
28def process_refund(req: RefundRequest):
29 try:
30 charge = stripe.Charge.retrieve(req.charge_id)
31 except stripe.error.InvalidRequestError:
32 log.error(f'Charge not found: {req.charge_id}')
33 return None
34
35 if charge.status != 'succeeded':
36 log.warning(f'Charge {req.charge_id} status is {charge.status} — skipping')
37 return None
38
39 if is_already_refunded(req.charge_id, req.amount_cents):
40 log.info(f'Charge {req.charge_id} already refunded — skipping')
41 return None
42
43 max_refundable = charge.amount - charge.amount_refunded
44 amount = req.amount_cents or max_refundable
45
46 if amount > max_refundable:
47 log.error(f'Requested {amount} > max refundable {max_refundable} for {req.charge_id}')
48 return None
49
50 try:
51 refund = stripe.Refund.create(
52 charge=req.charge_id,
53 amount=amount,
54 reason=req.reason,
55 metadata={'ticket_id': req.ticket_id or 'auto'}
56 )
57 log.info(f'Refunded {req.charge_id}: {refund.id} ({charge.currency.upper()} {amount/100:.2f})')
58 return refund
59 except stripe.error.StripeError as e:
60 log.error(f'Refund failed for {req.charge_id}: {e}')
61 return None
62
63def main():
64 # Replace this with your actual refund queue source
65 refund_queue = [
66 RefundRequest('ch_abc123', ticket_id='TICKET-001'),
67 RefundRequest('ch_def456', amount_cents=2500, ticket_id='TICKET-002'),
68 RefundRequest('ch_ghi789', reason='duplicate', ticket_id='TICKET-003'),
69 ]
70
71 log.info(f'Processing {len(refund_queue)} refund requests')
72 succeeded = failed = skipped = 0
73
74 for req in refund_queue:
75 result = process_refund(req)
76 if result:
77 succeeded += 1
78 elif result is None:
79 skipped += 1
80 else:
81 failed += 1
82 time.sleep(0.05) # 20 req/sec pacing
83
84 log.info(f'Complete: {succeeded} succeeded, {failed} failed, {skipped} skipped')
85
86if __name__ == '__main__':
87 main()

Error handling

400Charge ch_... has already been refunded.
Cause

Attempting to refund a charge that has already been fully refunded. The amount_refunded equals the amount.

Fix

Check the charge's refunded field and amount_refunded before calling POST /v1/refunds. Use GET /v1/refunds?charge={id} to list existing refunds.

Retry strategy

No retry — the charge is already fully refunded. Check if a partial refund is still possible (amount > amount_refunded).

400Refund amount (10000) is greater than remaining charge amount (5000)
Cause

The requested refund amount exceeds what's left to refund. This happens after partial refunds when the remaining amount is less than the new requested amount.

Fix

Retrieve the charge first, calculate max_refundable = charge.amount - charge.amount_refunded, and cap your refund amount at that value.

Retry strategy

No retry — reduce the amount and re-request

400This charge cannot be refunded because it has not been captured
Cause

Attempting to refund an uncaptured charge (one that was authorized but not captured). Uncaptured charges are canceled, not refunded.

Fix

Use stripe.PaymentIntent.cancel() for uncaptured payments instead of creating a refund.

Retry strategy

No retry — use the correct cancel endpoint

429Too many requests
Cause

Exceeded Stripe's rate limit during bulk refund processing. 100 req/sec in live mode.

Fix

Add delays between requests (50ms = 20 req/sec, well under the limit). Use a queue with controlled concurrency. Honor the Retry-After header if present.

Retry strategy

Exponential backoff: 1s, 2s, 4s, 8s. For bulk operations, maintain steady 20 req/sec pace proactively.

400Webhook signature verification failed
Cause

The charge.refunded webhook handler is using parsed JSON instead of the raw request body for signature verification.

Fix

Use express.raw() middleware in Node.js or request.get_data() in Flask to get the raw bytes. Never parse the webhook body with JSON.parse() or request.json before passing to constructEvent().

Retry strategy

No retry — fix the middleware configuration

Rate Limits for Stripe API

ScopeLimitWindow
Live mode100 requestsper second
Test mode25 requestsper second
retry-handler.ts
1import time
2import stripe
3
4def with_retry(fn, max_retries=5):
5 for attempt in range(max_retries):
6 try:
7 return fn()
8 except stripe.error.RateLimitError:
9 wait = min(2 ** attempt, 32)
10 print(f'Rate limited, retrying in {wait}s...')
11 time.sleep(wait)
12 except stripe.error.APIConnectionError:
13 if attempt == max_retries - 1:
14 raise
15 time.sleep(2 ** attempt)
16 raise Exception('Max retries exceeded')
  • For bulk refunds, process at 20 req/sec maximum (50ms between requests) — well under the 100/sec limit with headroom for spikes
  • Use a checkpoint file or database flag to mark processed refunds so you can resume interrupted bulk runs without double-refunding
  • Retrieve each charge before refunding to validate eligibility — this extra call is worth the protection against error waste
  • Subscribe to charge.refunded webhooks instead of polling GET /v1/refunds/{id} for completion status
  • Log every refund attempt with the charge ID, amount, and result for your financial audit trail

Security checklist

  • Store STRIPE_SECRET_KEY in environment variables — never hardcode or commit to git
  • Verify webhook signatures using stripe.webhooks.constructEvent() with the raw request body (not parsed JSON)
  • Store your webhook signing secret (whsec_*) separately from your API key in environment variables
  • Implement authorization checks before issuing refunds — verify the requesting user owns the order
  • Add a refund amount threshold requiring human approval (e.g., refunds over $500 require manual confirmation)
  • Log all refund attempts with charge ID, requested amount, outcome, and requestor identity for audit trails
  • Use a Restricted API key with only refund permissions for this automation rather than the full secret key
  • Implement idempotency checks — verify a charge hasn't already been refunded before calling the API

Automation use cases

Support Queue Auto-Refund

intermediate

Poll a support ticket system for approved refund requests and automatically process them via the Stripe API, updating both the ticket and order status.

SLA Credit Automation

advanced

Detect service outages or SLA breaches from your monitoring system and automatically issue partial refunds or credits to affected customers.

Subscription Cancellation Proration

intermediate

When a customer cancels mid-cycle, calculate the unused days and issue a prorated partial refund of the most recent invoice payment.

Duplicate Charge Detection

beginner

Scan for duplicate charges (same customer, same amount, within 5 minutes) and automatically refund one with reason='duplicate'.

No-code alternatives

Don't want to write code? These platforms can automate the same workflows visually.

Zapier

Free tier (100 tasks/month), paid from $19.99/month

Zapier's Stripe integration can trigger on events like new form submissions or Airtable record updates to issue refunds — but configuration is limited and bulk operations aren't well supported.

Pros
  • + No code required
  • + Easy to connect to support tools like Zendesk
  • + Instant setup
Cons
  • - No bulk refund support
  • - Limited refund parameters (no partial refund control)
  • - Each refund costs a Zap task

Make

Free tier (1,000 ops/month), paid from $9/month

Make's Stripe module supports creating refunds as part of automated workflows, including conditions and routing based on order amount or customer tier.

Pros
  • + Visual workflow with conditional logic
  • + Supports partial refund amounts
  • + Better error handling than Zapier
Cons
  • - Requires workflow design knowledge
  • - Monthly operation limits
  • - No bulk processing

n8n

Free (self-hosted), Cloud from €20/month

n8n can build a full refund automation pipeline: trigger from a support webhook, fetch order data, call Stripe's refund API via HTTP Request node, and update your database.

Pros
  • + Full API access including metadata and reason fields
  • + Self-hostable for data privacy
  • + Can process batches with SplitInBatches node
Cons
  • - Requires technical configuration
  • - No native Stripe module — uses HTTP Request
  • - Self-hosted infrastructure needed for free tier

Best practices

  • Always check the charge status and amount_refunded before calling POST /v1/refunds — prevents 400 errors and double-refund attempts
  • Use payment_intent ID instead of charge ID when available — it's more robust for multi-attempt payments
  • Always include a reason parameter (requested_by_customer, duplicate, or fraudulent) — this documentation protects you in chargeback disputes
  • Include support ticket or order IDs in refund metadata for financial reconciliation and audit trails
  • Verify webhook signatures with the raw body on every incoming charge.refunded event — never skip this check
  • Factor in the unrecovered processing fee when designing your refund policy — a $50 refund costs you ~$1.75 in unrecovered fees
  • Implement idempotency: check GET /v1/refunds?charge={id} before creating a new refund to prevent duplicates from retry logic

Ask AI to help

Copy one of these prompts to get a personalized, working implementation.

ChatGPT / Claude Prompt

I'm building a Stripe refund automation in Python. I use stripe.Refund.create(charge='ch_...', amount=1000, reason='requested_by_customer'). Help me: (1) add a pre-check that retrieves the charge and validates it's refundable, (2) handle the charge.refunded webhook with signature verification using stripe.Webhook.construct_event() and the raw request body, (3) detect and skip already-refunded charges to prevent double-refunds, and (4) implement exponential backoff for 429 rate limit errors.

Lovable / V0 Prompt

Build a Stripe refund management dashboard in React with: (1) a table listing recent charges with their refund status and amount_refunded, (2) a 'Refund' button per row that opens a modal to specify full or partial refund amount, (3) a backend API endpoint POST /api/stripe/refund that accepts {chargeId, amountCents, reason} and calls the Stripe API, (4) a refund history section showing all processed refunds with date, amount, and status, (5) input validation ensuring partial refund amount doesn't exceed the remaining refundable amount.

Frequently asked questions

Does Stripe return the processing fee when I issue a refund?

No. Stripe keeps the original processing fee (typically 2.9% + $0.30 for standard domestic cards) when you issue a refund. You absorb this cost. For a $50 charge, you lose approximately $1.75 in fees that aren't returned. This is standard across all major payment processors and is worth factoring into your refund policy.

How long does a Stripe refund take to appear on the customer's statement?

Stripe processes the refund immediately when you call the API, but it takes 5-10 business days to appear on the customer's card statement, depending on their card issuer and bank. The refund object status becomes 'succeeded' almost instantly, but the customer won't see the credit until their bank processes it.

What's the difference between refunding a charge vs a payment intent?

Both work — pass either charge='ch_...' or payment_intent='pi_...' to POST /v1/refunds. Using the payment intent ID is recommended for modern integrations because it handles cases where a payment intent has multiple charge attempts (e.g., after a card decline). Stripe resolves the correct charge automatically.

What happens when I hit the rate limit?

Stripe returns a 429 Too Many Requests error. The Stripe SDK throws a RateLimitError in Python or RateLimitError in Node.js. For bulk refund operations, maintain a pace of 20 req/sec (50ms between requests) to stay well under the 100 req/sec live mode limit. Implement exponential backoff: wait 1s after the first 429, then 2s, 4s, 8s.

How do I prevent double-refunds when running bulk operations?

Before calling POST /v1/refunds, retrieve the charge with stripe.Charge.retrieve(charge_id) and check the refunded field (true if fully refunded) and amount_refunded vs amount. Alternatively, use GET /v1/refunds?charge={charge_id} to list existing refunds. Add a checkpoint file to your bulk script to track which charges have been processed so resuming doesn't reprocess them.

Can I cancel a refund after it's been submitted?

No. Once a refund is created with status 'succeeded', it cannot be canceled or reversed. This is why validation before calling the API is critical. Stripe does provide a POST /v1/refunds/{id}/cancel endpoint, but it only works for refunds in 'requires_action' status, which is rare.

Can RapidDev help build a custom refund automation integrated with our support system?

Yes. RapidDev has built 600+ apps including refund automation pipelines connected to Zendesk, Intercom, and custom support dashboards. If you need refund workflows with approval flows, automated triggers, or reconciliation reporting, get a free consultation at rapidevelopers.com.

RapidDev

Need this automated?

Our team has built 600+ apps with API automations. We can build this for you.

Book a free consultation

Skip the coding — we'll build it for you

Our experts have built 600+ API automations. From prototype to production in days, not weeks.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.