Skip to content
In this section

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 with autoSettlement left out on an account whose default is not automatic capture.
  • You need the same externalOrderRef string 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: false on the original /initialize-payment request, or autoSettlement left out on an account whose default is not automatic capture), and
  • its lifecycleStatus is authorized.

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 lifecycleStatus is captured, and
  • its settlementStatus is not in_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 409 with settlement 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 fresh Idempotency-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 /refund answers 409 ("this order was paid entirely by promotions; cancel the promotional order instead"). Cancel it with POST /payments-api/v1/payments/{payment_id}/cancel-promotion and a JSON body {"reason": "..."} (1 to 512 characters, same Idempotency-Key header). The buyer's credit and coupon are given back, the payment becomes refunded, and you receive a PaymentPromotionCancelled webhook instead of PaymentRefunded. 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 partial amount answers 409). If your original POST /refund timed out while the refund is still pending, retry with the same Idempotency-Key to replay the existing refund row. Using a different key is treated as a new refund attempt and stays blocked until the pending refund reaches succeeded, failed, or cancelled.

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 with webhook_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.