Refunds

View as Markdown

Return funds for a previous charge, in full or in part.

POST   https://api.worthypayments.com/v1/refunds

Scope: refunds:create, server-only. A publishable key (pk_) cannot refund; use a secret key (sk_). See Authentication.


Create a refund#

POST /refunds

Request body#

FieldTypeRequiredNotes
chargestringYesThe id of the charge to refund (the id from POST /charges, or from a charge synced from your gateway).
amountintegerNoCents. Omit for a full refund of what remains. Provide a smaller amount for a partial refund. Must be a whole number ≥ 1; a negative, zero or fractional value is 400 parameter_invalid.
currencystringNoRead only with amount. When omitted it defaults to usd, so a partial refund of a non-USD charge must pass the charge's own currency. A different currency is refused (REFUND_CURRENCY_MISMATCH).
reasonstringNoOne of requested_by_customer, duplicate or fraudulent. Any other value is 400 parameter_invalid.

The Idempotency-Key header is required. A missing header is 400 idempotency_key_required, and a body field does not take its place.

Example: full refund#

bash
curl https://api.worthypayments.com/v1/refunds \
  -H "x-api-key: sk_live_xxx" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "charge": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3", "reason": "requested_by_customer" }'

Example: partial refund ($20.00 of a larger charge)#

bash
curl https://api.worthypayments.com/v1/refunds \
  -H "x-api-key: sk_live_xxx" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "charge": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3", "amount": 2000, "currency": "usd" }'

Response: succeeded#

json
{
  "id": "re_TXN-1f0c5a92-6b4d-4a17-9e88-2c7d54a10b33",
  "object": "refund",
  "charge": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3",
  "amount": 2000,
  "currency": "usd",
  "status": "succeeded",
  "reason": "requested_by_customer",
  "transaction_id": "01J..."
}

amount is always present: on a full refund (where you omit amount in the request) it is the remaining captured cents the service refunded, not an omitted field. There is no created field on this object.

Response: failed#

A refund that cannot be processed comes back as the standard error envelope. There is no single refund_failed code, branch on what actually arrives:

Unknown charge → 404

json
{
  "error": {
    "type": "invalid_request_error",
    "code": "resource_missing",
    "message": "TRANSACTION_NOT_FOUND",
    "request_id": "2358c7d9-…"
  }
}

Refused before any money moves → 400, with the reason in message:

messagecodeMeans
REFUND_EXCEEDS_REMAININGinvalid_argumentamount is more than what remains. Refunds made through the API and in your gateway's own portal both count.
ALREADY_FULLY_REFUNDEDfailed_preconditionNothing is left to refund.
TRANSACTION_DISPUTEDfailed_preconditionA chargeback or a matched pre-dispute alert is open on the charge. The cardholder may already be getting the money back, and a refund on top would pay twice.
REFUND_WINDOW_EXPIREDfailed_preconditionThe charge is more than 180 days old (for a gateway-synced charge, from the gateway's own date).
INVALID_STATE_FOR_REFUNDfailed_preconditionThe charge never captured money.
REFUND_CURRENCY_MISMATCHinvalid_argumentcurrency is not the charge's.

Refused by the gateway → 400 with code: "refund_declined", the gateway's decline_code, and its reason in message. No money moved. The refusal is the gateway's answer: a retry with the same Idempotency-Key replays it; send a new key once you have changed what made the gateway refuse.

json
{
  "error": {
    "type": "invalid_request_error",
    "code": "refund_declined",
    "decline_code": "generic_decline",
    "message": "Refund amount may not exceed the transaction balance",
    "request_id": "2358c7d9-…"
  }
}

A processor failure is 500 with code: "upstream_error". A 500 is the ambiguous case: the refund may or may not have been submitted. Reconcile with GET /v1/charges/{id} (its amount_refunded), or retry with the same Idempotency-Key, rather than retrying blindly. Edge validation (missing charge, a non-integer or non-positive amount, an unknown reason) is the separate 400 parameter_invalid.

See Errors for the full envelope and retry guidance.


Retrieve a refund#

GET /refunds/:id requires payments:read. It returns the fuller stored shape, including currency and reason:

json
{
  "id": "re_TXN-1f0c5a92-6b4d-4a17-9e88-2c7d54a10b33",
  "object": "refund",
  "charge": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3",
  "amount": 2000,
  "currency": "usd",
  "status": "succeeded",
  "reason": "duplicate",
  "transaction_id": "01J..."
}

status maps to succeeded / pending / failed. Unknown id → 404 resource_missing.

List refunds#

GET /refunds requires payments:read. Cursor-paginated, newest first. Accepts limit (default 10, max 100), starting_after, and charge to filter to one charge. See Pagination.

The charge filter lists the refunds made through the API. A refund made in your gateway's own portal points at the gateway's id and is not listed under the charge's id. For the full total a charge has given back, read its amount_refunded (GET /v1/charges/{id}).

bash
curl "https://api.worthypayments.com/v1/refunds?charge=TXN-8ac4796a-1940-4c6a-ba54-f151606330e3" -H "x-api-key: sk_live_xxx"
json
{ "object": "list", "data": [ { "id": "re_TXN-1f0c5a92-6b4d-4a17-9e88-2c7d54a10b33", "object": "refund", "charge": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3", "amount": 2000, "currency": "usd", "status": "succeeded", "reason": "duplicate", "transaction_id": "01J..." } ], "has_more": false }

Notes#

  • Refunds settle through the same acquirer (payarc, nuvei, worthy) that processed the original charge. You don't specify it.
  • A refund goes back to the card the charge was paid with, and only there. Each request refunds one charge; there is no bulk refund.
  • Partial refunds can be issued multiple times against one charge until the original amount is exhausted.
  • To refund a customer's past charges, for example instead of sending a payout, see Refund a customer's past charges.
  • Refunds are server-only by design: keep your sk_ key on the backend and never trigger refunds from the browser.

Refunding the same charge twice#

The protection against a double refund is your Idempotency-Key, not the charge id. Two refunds against the same charge are perfectly legal (that is what partial refunds are) so the API cannot tell an intentional second partial refund apart from an accidental replay. It only knows they are the same request if you send the same key.

The practical rule: derive the key from the thing you are refunding, not from the moment you are refunding it. A key like refund:order-1042:full collapses a retry into one refund; a fresh uuidgen on every attempt does not, and a timed-out request retried with a new key refunds twice.

Once the original amount is exhausted, further refunds are rejected upstream. You get a 400 with code: "invalid_argument" and message REFUND_EXCEEDS_REMAINING.

Rate limits#

Refunds are not counted against a per-minute refund budget; only the general per-key request ceiling applies (see Rate limits). A bulk refund script that does hit a 429 rate_limit_exceeded should back off by the Retry-After header.