Skip to main content

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:
  • statussucceeded 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.