Refunds
View as MarkdownReturn 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#
| Field | Type | Required | Notes |
|---|---|---|---|
charge | string | Yes | The id of the charge to refund (the id from POST /charges, or from a charge synced from your gateway). |
amount | integer | No | Cents. 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. |
currency | string | No | Read 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). |
reason | string | No | One 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#
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)#
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#
{
"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
{
"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:
message | code | Means |
|---|---|---|
REFUND_EXCEEDS_REMAINING | invalid_argument | amount is more than what remains. Refunds made through the API and in your gateway's own portal both count. |
ALREADY_FULLY_REFUNDED | failed_precondition | Nothing is left to refund. |
TRANSACTION_DISPUTED | failed_precondition | A 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_EXPIRED | failed_precondition | The charge is more than 180 days old (for a gateway-synced charge, from the gateway's own date). |
INVALID_STATE_FOR_REFUND | failed_precondition | The charge never captured money. |
REFUND_CURRENCY_MISMATCH | invalid_argument | currency 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.
{
"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:
{
"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}).
curl "https://api.worthypayments.com/v1/refunds?charge=TXN-8ac4796a-1940-4c6a-ba54-f151606330e3" -H "x-api-key: sk_live_xxx"
{ "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.
