Captures & refunds
Capture authorized payments and issue full or partial refunds — verified against the live codebase.
A capture settles the funds reserved by an authorization. A refund returns captured funds to the customer after the processor confirms the payment. Both operations act on an existing payment identified by your original externalOrderRef string.
Prerequisites
- For capture, the payment must be manual-capture: created with
autoSettlement: false, or withautoSettlementleft out on an account whose default is not automatic capture. - You need the same
externalOrderRefstring you sent on/initialize-payment— this is the{externalOrderRef}path parameter.
Tip
The
{externalOrderRef}path parameter is the string you chose when you created the payment (e.g.ORDER-1042). It is not a numeric ID.
Captures
When to capture
Capture is only available when:
- the payment is manual-capture (
autoSettlement: falseon the original/initialize-paymentrequest, orautoSettlementleft out on an account whose default is not automatic capture), and - its
lifecycleStatusisauthorized.
Otherwise capture returns 409 Conflict with "payment action is not allowed for the current payment status".
Full capture
curl -sS -X POST "$FYNEX_API/payments-api/v1/payments/ORDER-1042/capture" \
-H "Authorization: Bearer $FYNEX_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json"
Response (200 OK):
{
"paymentId": "ORDER-1042",
"status": "provider_completed",
"lifecycleStatus": "captured",
"legacyStatus": "provider_completed",
"providerCode": "<processor>",
"providerPaymentId": "pay_01J2EXAMPLE",
"amount": 49.99,
"currencyCode": "GBP"
}
Partial capture
Pass an amount smaller than the authorized total. The remainder is released automatically.
curl -sS -X POST "$FYNEX_API/payments-api/v1/payments/ORDER-1042/capture" \
-H "Authorization: Bearer $FYNEX_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "amount": 30.00 }'
Caution
Capture once for the final amount. You cannot issue a second capture on the same payment. Capture is also blocked while a refund is pending or after any successful refund on that payment.
Capture request body
| Field | Type | Required | Description |
|---|---|---|---|
amount |
float64 |
No | Major units (e.g. 30.00). Omit for full-amount capture. Must be > 0 and <= authorized amount. |
Capture response fields
| Field | Type | Description |
|---|---|---|
paymentId |
string |
Your externalOrderRef |
status |
string |
Payment status post-capture — the previous values until the switch (see Payment Lifecycle) |
lifecycleStatus |
string |
captured after a successful capture |
legacyStatus |
string |
Deprecated: the previous status value |
providerCode |
string |
Upstream processor identifier |
providerPaymentId |
string |
Provider's reference for the transaction |
amount |
float64 |
Captured amount in major units |
currencyCode |
string |
3-letter ISO currency |
failureCode |
int |
Present if processor declined |
failureDescription |
string |
Human-readable decline reason |
Refunds
When to refund
Refund is only available when:
- the payment's
lifecycleStatusiscaptured, and - its
settlementStatusis notin_flight, and - no refund on the payment is still
pending, and - refunds are enabled for your account.
Attempting a new refund outside these conditions is refused (409 Conflict when the payment's status does not allow it).
Note
A card refund also waits for the acquirer's settlement batch. Refunding on the day the payment was captured answers
409withsettlement not yet available for refund: nothing is refunded, the payment stays refundable, and the same request succeeds after the next batch, usually the next day. Send it again with a freshIdempotency-Key. See Errors.
Note
An order that a coupon and promotional credit paid in full took no cash, so there is nothing to refund:
POST /refundanswers409("this order was paid entirely by promotions; cancel the promotional order instead"). Cancel it withPOST /payments-api/v1/payments/{payment_id}/cancel-promotionand a JSON body{"reason": "..."}(1 to 512 characters, sameIdempotency-Keyheader). The buyer's credit and coupon are given back, the payment becomesrefunded, and you receive aPaymentPromotionCancelledwebhook instead ofPaymentRefunded. An order that took any cash must have that cash refunded first. A payment that a promotion part-paid can be captured only in full (a partialamountanswers409). If your originalPOST /refundtimed out while the refund is still pending, retry with the sameIdempotency-Keyto replay the existing refund row. Using a different key is treated as a new refund attempt and stays blocked until the pending refund reachessucceeded,failed, orcancelled.
Full refund
curl -sS -X POST "$FYNEX_API/payments-api/v1/payments/ORDER-1042/refund" \
-H "Authorization: Bearer $FYNEX_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json"
Partial refund
curl -sS -X POST "$FYNEX_API/payments-api/v1/payments/ORDER-1042/refund" \
-H "Authorization: Bearer $FYNEX_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "amount": 10.00 }'
Issue multiple partial refunds as long as the running total of successful refunds stays at or below the captured amount. Omit amount to refund the remaining refundable balance.
What customers see
Refunds typically reach the customer's bank within 3–10 business days, depending on the issuer. The funds appear as a separate credit transaction — the original charge is not reversed.
Refund response (200 OK)
{
"id": "6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b",
"paymentId": "ORDER-1042",
"status": "pending",
"amount": 10.00,
"currencyCode": "GBP",
"providerRefundId": "rfnd_01J2EXAMPLE",
"createdAt": "2026-05-11T12:34:56Z",
"updatedAt": "2026-05-11T12:34:56Z"
}
The status will be pending immediately after the request. Once the provider processes it, the refund moves to succeeded or failed.
Refund response fields
| Field | Type | Description |
|---|---|---|
id |
string |
Unique ID of this refund ledger row |
paymentId |
string |
Your externalOrderRef for the parent payment |
status |
string |
pending, succeeded, failed, or cancelled |
amount |
float64 |
Refunded amount in major units |
currencyCode |
string |
3-letter ISO currency |
providerRefundId |
string |
Provider's reference for the refund (present once issued) |
failureCode |
int |
Present if the provider rejected the refund |
failureDescription |
string |
Human-readable failure reason |
createdAt |
string |
When the refund was created |
updatedAt |
string |
When the refund row was last updated |
completedAt |
string |
When the refund reached a terminal state (present once complete) |
Status reference
Payment status is defined in Payment Lifecycle. A capture moves lifecycleStatus from authorized to captured; a partial capture, a partial refund and a refund in flight leave it captured, and it becomes refunded only once the whole captured amount is refunded.
Idempotency
Both endpoints require an Idempotency-Key UUID header. Always send one — network glitches can cause duplicate submissions without it.
The key is forwarded to the upstream processor as its own capture/refund idempotency key, so provider-side duplicates are also prevented.
See Idempotency & retries for the full retry pattern.
Dynamic webhook URL
Both POST /payments/{payment_id}/capture and POST /payments/{payment_id}/refund accept an optional webhookUrl field on the request body. When set, the resulting webhook event is delivered to that URL in addition to the seller's configured SellerWebhookConfig URLs.
The dynamic URL must:
- Be HTTPS (
http://rejected withwebhook_url_not_https) - Be ≤ 1024 chars (
webhook_url_too_long) - Resolve via DNS (
webhook_url_dns_failed) - Have every resolved IP covered by an active entry in the seller's webhook allowlist (
webhook_url_not_allowlisted) - Not resolve to a private / loopback / link-local / multicast range, even if explicitly allowlisted (
webhook_url_resolves_to_private_ip— defense-in-depth)
The seller must have at least one active SellerWebhookConfig row before webhookUrl is accepted — otherwise the request fails with webhook_url_requires_configured_webhook (422).
Manage the per-seller allowlist via GET / POST / DELETE /payments-api/v1/webhooks/allowlist. Outgoing deliveries to the dynamic URL are signed with the lexicographically-first active config's secret using HMAC-SHA256 over <X-Fynex-Timestamp>.<raw body> (X-Fynex-Signature: sha256=<hex>, X-Fynex-Timestamp: <unix-seconds>).
See the Webhooks tag for the full signature-verification flow.
Example
curl -sS -X POST "$FYNEX_API/payments-api/v1/payments/ORDER-1042/refund" \
-H "Authorization: Bearer $FYNEX_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount": 10.00,
"webhookUrl": "https://merchant.example.com/webhooks/refunds/ORDER-1042"
}'
Status codes
| Status | When |
|---|---|
200 |
Success |
400 |
Missing/invalid Idempotency-Key; bad body or amount |
401 |
Missing or invalid bearer token |
404 |
Payment not found for this externalOrderRef and seller |
409 |
Status precondition not met; already refunded; capture attempted while/after refunding; autoSettlement was true |
502 |
Upstream processor error |
Verify the result
Capture and refund events fire outbound webhooks — they're delivered to every active SellerWebhookConfig URL (plus any per-request webhookUrl you set, see the section above). The fastest way to learn the terminal status is to receive that webhook and verify its X-Fynex-Signature header.
If you'd rather poll, read the payment over REST with your bearer token —
GET /payments-api/v1/payments/{payment_id}, where the path value is your
externalOrderRef — and watch lifecycleStatus become refunded once the whole
amount is refunded. A partial or failed refund leaves it captured, so follow the
refund itself on GET /refunds/{refund_id}.
From a dashboard session the GraphQL query reads the same payment. Its
lifecycleStatus is the same value as on REST; its status is the internal value:
# id is Int! — use the numeric internal ID.
# genericPayment looks a payment up by its numeric id; there is no
# externalOrderRef argument on the GraphQL query.
query {
genericPayment(id: 42) {
lifecycleStatus
amount
}
}
Or use the SSE stream on the hosted checkout page. See Polling & SSE.
See also
- Polling & SSE — Poll payment status or subscribe to server-sent events.
- Errors — Interpret 409s and provider failure codes.
- Disputes & chargebacks — If a customer disputes a payment, see the Disputes & chargebacks guide.