Refunds
Flowlix supports both full and partial refunds on any payment with the
succeeded status. Every refund event is stored with a mandatory reason
and returned as part of the payment’s refund history, giving you a complete
audit trail.
How refunds work
- Refunds can be full (omit
amount) or partial (specify amount).
- A payment can be refunded multiple times as long as the accumulated
refunded amount does not exceed the original payment amount.
- Every refund call creates an entry in the payment’s
refunds array.
- Every refund requires a
reason of at most 50 characters. The
reason is stored verbatim in the refund history and is forwarded to the
upstream provider as the refund comment.
- Payments can be refunded only within 180 days of creation. After that
the refund window expires.
- The payment status transitions from
succeeded to refunded only once the
total refunded amount equals the original payment amount. Until then the
payment stays in succeeded and refunded_amount reflects the running total.
- The
refunded_at field is set only on the final refund that fully refunds
the payment.
Create a refund
Send a POST request to /v1/payments/{id}/refund. The reason field is
required; amount is optional (omit it to refund the full remaining amount).
Full refund
curl -X POST https://api.flowlix.dev/v1/payments/pay_a1b2c3d4-e5f6-7890-abcd-ef1234567890/refund \
-H "Authorization: Bearer fl_test_sk_abc123def456" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: refund-order-1234" \
-d '{
"reason": "Customer cancelled order"
}'
Partial refund
curl -X POST https://api.flowlix.dev/v1/payments/pay_a1b2c3d4-e5f6-7890-abcd-ef1234567890/refund \
-H "Authorization: Bearer fl_test_sk_abc123def456" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: refund-shipping-1234" \
-d '{
"amount": 1500,
"reason": "Shipping fee refund"
}'
amount is in minor units (e.g. cents for EUR/USD).
Successful response
The response returns the full Payment object, including the updated
refunded_amount and the cumulative refunds history.
{
"id": "pay_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"object": "payment",
"amount": 4999,
"currency": "eur",
"status": "succeeded",
"description": "Order #1234",
"card": {
"brand": "visa",
"last4": "1111",
"exp_month": 12,
"exp_year": 2027,
"country": "US"
},
"customer": {
"email": "jenny@example.com",
"name": "Jenny Rosen"
},
"metadata": {
"order_id": "ord_1234"
},
"decline_code": null,
"decline_message": null,
"redirect_url": null,
"refunded_at": null,
"succeeded_at": 1719792000,
"failed_at": null,
"created": 1719792000,
"livemode": false,
"refunded_amount": 1500,
"provider_transaction_id": "1402758057",
"refunds": [
{
"id": "01h9zacxv2pq8r1s3t4v5w6x7y",
"amount": 1500,
"currency": "eur",
"reason": "Shipping fee refund",
"created_at": 1719878400,
"provider_refund_id": "WL-RET-9988"
}
],
"next_action": null
}
status stays succeeded after a partial refund — it only becomes
refunded once the accumulated refunded_amount equals amount.
provider_refund_id may be null if the upstream provider did not return an
identifier with the refund response.
Multiple partial refunds
A payment can be partially refunded multiple times, building up its refund
history over time. (The fragments below assume the same headers as the full
example above.)
# Refund €10.00 — customer complaint
curl -X POST https://api.flowlix.dev/v1/payments/pay_<id>/refund \
-H "Authorization: Bearer fl_test_sk_abc123def456" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: refund-complaint-1234" \
-d '{"amount": 1000, "reason": "Customer complaint"}'
# Later — refund another €5.00 — shipping issue
curl -X POST https://api.flowlix.dev/v1/payments/pay_<id>/refund \
-H "Authorization: Bearer fl_test_sk_abc123def456" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: refund-shipping-1234" \
-d '{"amount": 500, "reason": "Shipping delay"}'
# Later — refund the remaining €34.99 — cancelled
curl -X POST https://api.flowlix.dev/v1/payments/pay_<id>/refund \
-H "Authorization: Bearer fl_test_sk_abc123def456" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: refund-cancel-1234" \
-d '{"amount": 3499, "reason": "Order cancelled"}'
After the final refund:
status becomes refunded
refunded_amount equals the original amount
refunded_at is set to the timestamp of the final refund
refunds contains three entries, in chronological order
Idempotency for refunds
Refund retries are de-duplicated using the standard Idempotency-Key
header. The contract is body-hash-based:
- A retry with the same key and the same request body returns the
cached response from the original call.
- A retry with the same key but any different field (including a
different
reason or a different amount) is rejected with
409 idempotency_key_in_use.
If you need to “fix” the reason on a refund you already issued, you cannot
re-use the original key — create a new refund event with a different key, or
contact the merchant portal for an audit-trail correction.
Error scenarios
All refund validation errors below carry an Idempotency-Key-aware
Flowlix-Request-Id and a doc_url in addition to the fields shown.
Missing reason
{
"error": {
"type": "invalid_request_error",
"code": "parameter_invalid",
"message": "reason is required and must be non-blank",
"param": null,
"decline_code": null,
"doc_url": "https://docs.flowlix.dev/api-reference/errors",
"request_id": "req_abc123def456"
}
}
HTTP status: 400 Bad Request
Reason too long
If reason exceeds 50 characters the gateway rejects the request:
{
"error": {
"type": "invalid_request_error",
"code": "parameter_invalid",
"message": "size must be between 0 and 50",
"param": "reason",
"decline_code": null,
"doc_url": "https://docs.flowlix.dev/api-reference/errors",
"request_id": "req_abc123def456"
}
}
HTTP status: 400 Bad Request
Refund amount exceeds remaining
{
"error": {
"type": "invalid_request_error",
"code": "action_not_allowed",
"message": "Refund validation failed [refund_amount_exceeded]: Requested refund amount 5000 exceeds remaining refundable amount 3499.",
"param": null,
"decline_code": null,
"doc_url": "https://docs.flowlix.dev/api-reference/errors",
"request_id": "req_abc123def456"
}
}
HTTP status: 422 Unprocessable Entity
Payment not in a refundable state
{
"error": {
"type": "invalid_request_error",
"code": "action_not_allowed",
"message": "Refund validation failed [invalid_status]: Transaction status is 'pending', must be 'succeeded' to refund.",
"param": null,
"decline_code": null,
"doc_url": "https://docs.flowlix.dev/api-reference/errors",
"request_id": "req_abc123def456"
}
}
HTTP status: 422 Unprocessable Entity
Refund window expired
{
"error": {
"type": "invalid_request_error",
"code": "action_not_allowed",
"message": "Refund validation failed [refund_window_expired]: Transaction is older than 180 days. Refund window has expired.",
"param": null,
"decline_code": null,
"doc_url": "https://docs.flowlix.dev/api-reference/errors",
"request_id": "req_abc123def456"
}
}
HTTP status: 422 Unprocessable Entity
Already fully refunded
{
"error": {
"type": "invalid_request_error",
"code": "action_not_allowed",
"message": "Refund validation failed [already_refunded]: Transaction has already been fully refunded.",
"param": null,
"decline_code": null,
"doc_url": "https://docs.flowlix.dev/api-reference/errors",
"request_id": "req_abc123def456"
}
}
HTTP status: 422 Unprocessable Entity
Payment not found
{
"error": {
"type": "invalid_request_error",
"code": "resource_missing",
"message": "Transaction not found",
"param": null,
"decline_code": null,
"doc_url": "https://docs.flowlix.dev/api-reference/errors",
"request_id": "req_abc123def456"
}
}
HTTP status: 404 Not Found
All refund validation failures share the top-level code: "action_not_allowed". The specific reason is in the message string,
prefixed with Refund validation failed [<internal_code>]:. Match on the
bracketed code in the message if you need to branch on the specific
validation reason.
Checking refund history
Retrieve a payment to see its full refund history:
curl https://api.flowlix.dev/v1/payments/pay_a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Authorization: Bearer fl_test_sk_abc123def456"
Inspect these fields in the response:
status — succeeded if partially refunded, refunded if fully refunded.
refunded_amount — total amount refunded across all refund events.
refunds[] — array of refund events, oldest first, each containing the
amount, reason, and timestamp.
refunded_at — only set once the payment is fully refunded.
You can also filter the payments list to find all fully refunded payments:
curl "https://api.flowlix.dev/v1/payments?status=refunded&limit=50" \
-H "Authorization: Bearer fl_test_sk_abc123def456"
Integration example
import requests
FLOWLIX_API_KEY = "fl_test_sk_abc123def456"
BASE_URL = "https://api.flowlix.dev"
def refund_payment(payment_id, reason, *, amount=None, idempotency_key):
"""Refund a payment, either fully or partially. Reason and idempotency key required."""
headers = {
"Authorization": f"Bearer {FLOWLIX_API_KEY}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
}
body = {"reason": reason}
if amount is not None:
body["amount"] = amount
response = requests.post(
f"{BASE_URL}/v1/payments/{payment_id}/refund",
headers=headers,
json=body,
timeout=30,
)
if response.status_code == 200:
payment = response.json()
print(
f"Refund recorded. "
f"status={payment['status']} refunded_amount={payment['refunded_amount']}"
)
return payment
error = response.json()["error"]
print(f"Refund failed: {error['code']} — {error['message']}")
return None
# Full refund
refund_payment(
"pay_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
reason="Customer cancelled order",
idempotency_key="refund-order-1234",
)
# Partial refund
refund_payment(
"pay_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
reason="Shipping delay goodwill",
amount=1500,
idempotency_key="refund-shipping-1234",
)
Best practices
- Always provide a clear
reason within the 50-character limit.
Merchants, accountants, and dispute handlers rely on it later. The reason
is returned verbatim in the refunds array and in the merchant portal.
- Always use an
Idempotency-Key when refunding to prevent duplicate
refunds on retries. A deterministic key tied to the operation (e.g.
refund-order-{order_id}) works well — and remember that retries must
resend the exact same body, otherwise the API returns
409 idempotency_key_in_use.
- Check
refunded_amount before offering a refund to see how much is
still refundable: remaining = amount - refunded_amount.
- Match on the bracketed code in the error message if you need to
branch on the specific validation reason — all refund validation failures
share the same top-level
action_not_allowed code.
- Log the
request_id from refund responses for audit and support purposes.