Partners
Markdown

Refund a fiat order

POST /api/v1/partner/fiat-orders/{orderKey}/refunds

Required scope: refunds:write. Also requires the refunds capability on the relationship that owns the order.

Sends money back to the buyer who paid the order — fully, or partially, as many times as the remaining balance allows. The identifier is order_key, the short integer returned on the order, and there is no customer id in the path: a partner that stored nothing but the key can refund.

Refunds are issued against the processor that took the payment, on the merchant account that holds the order. You never name a destination — it is the order’s own original payer, which is why this call needs no signed second factor, unlike a withdrawal or a payout-wallet change.

Request

curl -X POST https://www.niftipay.com/api/v1/partner/fiat-orders/1001/refunds \
  -H "Authorization: Bearer <partner_api_key>" \
  -H "Idempotency-Key: <uuid>" \
  -H "Content-Type: application/json" \
  -d '{ "amount_cents": 400, "description": "Item returned" }'
Field Type Required Notes
amount_cents integer yes Positive integer, in cents. Must not exceed remaining_refundable_cents.
description string no Memo forwarded to the processor. null is accepted.
order_lines array no Line-level refund, for processors that support it. null is accepted.
order_lines[].merchant_order_line_id string yes (in a line) Non-empty.
order_lines[].quantity integer yes (in a line) Positive integer.

An order_lines array may not be empty — send null or omit the field when you have no lines to name.

Idempotency-Key is required: money moves on this call, and a caller that retried after a socket timeout cannot know whether the first attempt landed. On a retry, reuse the same key; a fresh key is a second refund.

Response 201

{
  "request_id": "req-1",
  "api_version": "2026-08-01",
  "data": {
    "order_key": "1001",
    "currency": "EUR",
    "refund": {
      "amount_cents": 400,
      "status": "partially_refunded"
    },
    "service_fee_payer": "customer",
    "max_refundable_cents": 1025,
    "refunded_cents": 400,
    "remaining_refundable_cents": 625
  }
}
Field Meaning
refund.amount_cents Exactly the amount you asked for. This rail never substitutes a different figure.
refund.status refunded when nothing is left to refund, otherwise partially_refunded.
service_fee_payer customer or merchant — it decides the ceiling, see below.
max_refundable_cents The ceiling for the whole order.
refunded_cents Total refunded on this order so far, this refund included.
remaining_refundable_cents What a further refund may still ask for.

The order’s own status becomes refunded once remaining_refundable_cents reaches 0; a partial refund leaves it unchanged.

How much can be refunded

The ceiling depends on who paid the service fee, which is on the order itself:

service_fee_payer max_refundable_cents is Because
customer the order’s total_cents the buyer paid the fee on top, so the buyer gets it back
merchant the order’s amount_cents (subtotal) the buyer never paid the fee, so there is nothing of it to return

refunded_cents is read from the processor on every call, not from our own records, so a refund issued outside this API still counts against the ceiling.

Preconditions

  • The order must be a real order — a payment link cannot be refunded (409 not_refundable).
  • Its status must be paid or completed (409 order_not_paid).
  • It must have reached the processor at all; an order with no processor-side payment answers 409 not_refundable.

Some processors refund full value only

One processor exposes no partial-refund API. On such an order, an amount_cents that is not exactly remaining_refundable_cents is refused with 409 refund_full_value_only, and details[0].remaining_refundable_cents carries the one amount that would be accepted:

{
  "error": {
    "code": "refund_full_value_only",
    "message": "This provider only supports a full refund of the remaining balance. See details for the amount that would be accepted.",
    "request_id": "req-1",
    "details": [
      {
        "remaining_refundable_cents": 1025
      }
    ]
  }
}

Retry with that figure. Nothing is substituted silently — a machine caller sees only the number it sent, so refunding a different amount without saying so would be worse than a refusal.

Every miss is the same 404

Four different situations answer identically: the order key does not exist, it belongs to another partner on the same merchant, it belongs to a merchant you never onboarded, or the refunds capability is not granted on the relationship that owns it.

{
  "error": {
    "code": "resource_not_found",
    "message": "No partner-visible fiat order exists for this order key.",
    "request_id": "req-1"
  }
}

Note the last case: unlike every other rail, a missing grant here is a 404, not 403 capability_not_enabled. order_key is a small consecutive integer from a shared sequence, so anything that told the cases apart would not require guessing ids — you could count. Read capabilities to find out whether refunds is granted; do not infer it from this endpoint.

A capability that is granted but not currently usable is a separate 409 capability_unavailable — reaching that check already proves the order is yours, so the distinction reveals nothing.

Errors

Status code Meaning Key released
400 invalid_request Body is not a JSON object, amount_cents is not a positive integer, description is not a string, order_lines is malformed or empty yes
400 idempotency_key_required No Idempotency-Key header n/a
403 insufficient_scope Token minted without refunds:write n/a
404 resource_not_found Not there, not yours, or refunds not granted — see above n/a
409 not_refundable payment_link, or no processor-side payment to refund yes
409 order_not_paid Status is not paid or completed yes
409 nothing_to_refund remaining_refundable_cents is already 0 no
409 amount_exceeds_remaining Over the remaining balance; details[0].remaining_refundable_cents no
409 refund_full_value_only Full-value-only processor; details[0].remaining_refundable_cents no
409 capability_unavailable refunds granted, merchant not currently eligible; details[0].reason n/a
500 internal_error Unexpected failure — the outcome is unknown, do not blind-retry no
502 provider_error The processor refused or failed no
503 temporarily_unavailable Transient failure. Retry — the only code that means that n/a

“Key released” means the Idempotency-Key can be reused after fixing the request: it is released only for refusals decided before anything was sent to the processor. Once the processor has been contacted the key is consumed, because the outcome of the first attempt is no longer certain — replay the same key to read what happened rather than sending a second refund. Refusals marked n/a are decided before the key is recorded at all.

The event

A successful refund emits two webhook events: refund.completed, keyed on the refund, and payment.refunded, the order’s status twin. Both carry the order’s identifiers and status, not the refunded amount — use this call’s own response, re-read the order for refunded_cents, or call List an order’s refunds for the per-refund breakdown. See Webhooks.