Payment and refund states

Track each payment and refund so Tringify, the merchant and your payment service always agree.

A payment app takes a store's payments through your payment service and refunds them when Tringify asks. This page gives the rules for tracking each payment and refund: which states to keep, what to report and when, what Tringify's replies mean, and what to do when your service doesn't answer. Request and response formats are in the payment protocol reference; connecting the merchant's account is in Connect a merchant account.

The rules in brief#

  • Store first, call second. Save each change before you call Tringify or your service, then save the reply.

  • Report failed only on explicit evidence from your service. No answer is never a failure.

  • Paid is final. A paid session never changes its outcome or its captured transaction.

  • Keep looking. Look every session up at expiry, 1 hour later, 24 hours later and at the end of its late-capture window, 7 days after expiry.

  • Report every capture. A late capture turns a failed session into a paid one; any further capture is reported as an extra so Tringify can refund it.

  • Never refund more than was captured, counting refunds made outside Tringify that your service reports.

  • Retry each report until Tringify acknowledges it. Only a 2xx, or PAYMENT_SESSION_ALREADY_RESOLVED to a payment report, acknowledges one.

Term

Meaning

Session

One checkout payment Tringify asks your app to take, identified by session_id, with an amount, a currency and expires_at.

Capture

Money your service shows as captured on the session's order. It counts as the session's payment only when it has the session's amount and currency and isn't fully refunded at your service.

Lookup

Asking your service for the order's status and every payment on it. Every lookup on this page is this full lookup, so one lookup can show an outcome, extra captures and refunded amounts at once.

Late-capture window

From expires_at to 7 days after it. Your service can still capture a payment in this time, after the checkout looked abandoned or failed.

Drain point

7 days after the late-capture window of the merchant account's last session ends. Keep the account's credentials and keep checking until then.

Session states#

State

Meaning

Your app owes

open

Created; no outcome yet.

Lookups (see Check unfinished checkouts).

failed

Explicit failure evidence, or your service answered with no outcome at the end of the late-capture window.

A report to Tringify, and lookups for a late capture until the window ends.

failed, reported

Tringify acknowledged the failure.

Lookups for a late capture until the window ends.

failed, conflict

Tringify refused the report.

Investigation, and the same report once a day.

paid

A capture with the session's amount and currency.

A report to Tringify, and lookups for extra captures until the window ends.

paid, reported

Tringify acknowledged the payment.

Lookups for extra captures until the window ends.

paid, conflict

Tringify refused the report.

Investigation, and the same report once a day.

Only three moves change an outcome: open to paid, open to failed, and failed to paid when a capture arrives late. Nothing leaves paid: once you store the captured transaction it never changes, and any other capture is an extra. A failure seen again changes nothing; keep the text you first reported.

Store first, call second#

Give every session and refund a version number and raise it by one on every new outcome you will report. Make each write conditional on the state and version you read, so a late reply or a webhook racing a lookup can never overwrite a newer state. If a write loses that race, read the record again and apply what you saw to the state you find; never drop it.

Keep two separate due times on each record: one for a report owed to Tringify and one for a lookup owed to your service. A Tringify reply settles only the report; an answer from your service settles only the lookup. Retry both with the same backoff: 1 minute, 5 minutes, 15 minutes, 1 hour, 6 hours, then once a day. Reports to Tringify have no cut-off. Lookups for a session stop at the drain point. Lookups for a pending or held refund keep going for as long as you hold the account's credentials.

Report a payment outcome#

Report each outcome to POST /payment-sessions/{session_id}/resolve with the store's token as soon as you have stored it. Send amount and currency_code exactly as the session had them. A paid report always carries the captured transaction's ID; never report paid without one.

json
{
  "status": "paid",
  "amount": "1499.00",
  "currency_code": "INR",
  "provider_transaction_id": "txn-784213",
  "failure_code": "",
  "failure_message": ""
}

Reply

What it means

What to do

2xx

Acknowledged. Reporting the same paid transaction again, or failed for a session already failed, also answers 2xx.

Mark the report done.

409 PAYMENT_SESSION_ALREADY_RESOLVED

You reported failed for a session Tringify already holds as paid. It can come on any send: the first, a retry or a daily resend.

Treat the report as acknowledged and stop sending it. Look the order up again at once: if it shows a capture, report paid (Tringify accepts failed to paid); if it shows none, flag the session for investigation. If this reply ever comes to a paid report, handle it the same way and flag it.

409 PAYMENT_SESSION_AMOUNT_MISMATCH, or any other 409

A conflict: Tringify refused the report.

Never treat it as acknowledged. Keep the outcome, flag it for investigation, and send the same report once a day until Tringify answers 2xx or PAYMENT_SESSION_ALREADY_RESOLVED.

5xx or a timeout

Unknown: Tringify may already have applied the report.

Keep the report owed and send it again with the backoff, with no cut-off. Tringify treats a repeat of a report it already applied as a no-op.

Any other 4xx, or no token

Not received.

Retry with the backoff, with no cut-off. If the store has uninstalled your app, keep the report until it is reinstalled.

A reply for an older version

Overtaken by a newer outcome.

Ignore it.

Late captures#

Your service can capture a payment after the checkout expired or failed, for example when a bank confirms late. A capture with the session's money inside the late-capture window makes an open or failed session paid: store it, raise the version and report paid with that transaction. Tringify accepts failed to paid.

If your service needs a separate capture call, capture an authorized payment with the session's money only while the session is open or failed and the store still has your app installed with payment access: don't make a capture you can't report. Leave every other authorization uncaptured so your service releases it, including any on a session that is already paid.

Extra captures#

An extra capture is any capture on the session's order other than the one you stored as its payment. Look for extras in every lookup. When one lookup shows the session's payment and extras, store them all in one write. Don't store failed or abandoned attempts; your service's dashboard keeps those.

Extra

What to do

Same amount and currency as the session

Report it as paid with its own provider_transaction_id, only after Tringify has acknowledged the session's payment. Tringify refunds it as a duplicate. Read the replies as for the session; PAYMENT_SESSION_ALREADY_RESOLVED marks it acknowledged, so flag it and look the order up again.

Other amount or currency

Never report it. Flag it for investigation.

Already fully refunded at your service, for example by the merchant in your dashboard

Store it and never report it. If an extra you were reporting turns out fully refunded, stop reporting it and close its investigation.

Failed needs explicit evidence#

Report a session failed only on one of these:

  • Your service shows the payment or order as failed.

  • Your service gives its documented evidence that no payment was made.

  • The shopper's return from your service reports a failure before the session expires.

  • The lookup at the end of the late-capture window is answered and still shows no outcome, or an attempt still in progress. Report failed with a code such as NO_PROVIDER_OUTCOME.

Timeouts, 5xx errors, authentication errors, a lookup with no answer, a passing deadline, a merchant account that is gone, and Tringify's 409 replies are never evidence. A session your service never answers for stays open: keep retrying the lookup until the drain point, then stop and flag it so the merchant can settle it from your service's dashboard. Never report it failed.

Check unfinished checkouts#

A webhook or shopper callback only wakes your app: before you change a session, confirm the outcome with your service's API (look up the payment or the order it names). Webhooks can be late, repeated or out of order, so the lookup is the evidence. Besides those wake-ups, look every session up on this schedule, whatever its state:

When

Sessions

What you look for

Every scheduled run before expiry

open

An outcome.

At expires_at

All

open: an outcome. failed: a late capture. paid: extra captures.

1 hour after expiry

All

The same.

24 hours after expiry

All

The same.

End of the late-capture window, 7 days after expiry

All

The same. An open session answered with no outcome becomes failed.

A session that became paid late, say on day 3, is still looked up at the end of its window, so a capture made at any time inside the window is found by then at the latest. Walk sessions in expiry order and remember your place, so a missed run catches up oldest first. If a lookup gets no answer, schedule its retry before you move past the session; retries stop at the drain point, after which the session is flagged, never reported failed. After the window, you learn of new captures only from webhooks.

Refund requests#

Tringify asks for a refund of one capture: the session's payment or a reported extra, named by provider_transaction_id. refund_id is Tringify's ID for the refund and is the same on every retry. Use it as your service's idempotency key on every refund call, the first and every retry.

  • Store the refund with its amount reserved (see The refund cap) before calling your service. Send nothing until you know the write succeeded. If the write errored, read it back by refund_id: found, carry on; not found, answer failed with nothing sent; unreadable, answer 503.

  • The same refund_id with the same content gets the refund's current answer. With different content it gets 409 IDEMPOTENCY_CONFLICT.

  • Only the request that stored the refund makes the first refund call. Every later attempt looks the refund up under refund_id first, then checks the cap again, then calls. Never create a refund again under an ID your service shows as failed.

  • If the request that stored a refund ended before sending it, look it up under refund_id after 5 minutes instead of creating it. Mark it failed with REFUND_NOT_SENT only if your service proves no refund exists under that ID.

  • A capture is refundable when the session is paid with a stored transaction, even while its report is in conflict, or when it is a same-money extra. Otherwise answer 409 PAYMENT_NOT_REFUNDABLE.

The refund cap#

Refunds of each capture together never exceed it: the amount your service reports as refunded, plus your reserved refunds, plus the new refund, must be no more than the captured amount. Enforce this in your app; don't rely on your service refusing an over-refund.

  • The refunded amount is the capture's refunded total from every read of your service that returns it: lookups, webhooks and capture replies. It includes refunds the merchant made in your service's dashboard. Store it only when it grows. If your service reports no refunded total, count it as 0 and tell merchants to refund through Tringify.

  • Reserved refunds are every refund of the capture that is requested, pending, held, failed until the failure is confirmed, or refunded until a read of your service started after it was refunded includes it in the refunded total.

  • Check and reserve in one atomic write, so two requests at once can't both fit.

  • Check again immediately before every refund call, the first and every retry: read the refunded total from your service again, then your reservations. If the check fails before the first call, the refund is failed with nothing sent; on a retry it is held, because an earlier call may have moved money.

  • A failed refund keeps its reservation until two confirmation checks after the first failure both show it failed. Make them at least 24 hours apart: the first at the earliest 24 hours after the failure, the second at the earliest 48 hours after it. A request that fits only without the reservation is answered failed, naming the refund still being confirmed and when to try again.

  • If a refund your service showed as failed is processed later anyway, report refunded and keep its amount counted. Further refunds of that capture go ahead only if they fit in what remains of the cap. If its reservation was already released and counting it again puts the capture over the cap, refuse every further refund of that capture and flag it for investigation.

Answer a refund request#

Situation

Answer

Nothing stored and nothing sent: over the cap, below your service's minimum, or a new refund_id during a maintenance pause

failed, with a code such as REFUND_EXCEEDS_PAYMENT, PROVIDER_UNAVAILABLE or PAYMENTS_PAUSED.

The capture isn't refundable / the same refund_id with different content

409 PAYMENT_NOT_REFUNDABLE / 409 IDEMPOTENCY_CONFLICT.

Stored, and the first call answers processed

refunded.

Stored, and the first call answers with a failed refund or a documented business refusal, or the cap check before it fails

failed.

Stored, and anything else: accepted, pending, a timeout, a 5xx or an authentication error

pending. Report the outcome later.

The refund_id can't be looked up at all because your storage can't be read

503. Tringify keeps the refund for reconciliation. This is the only 5xx you answer; never answer failed for a refund that may exist.

Refund states#

State

Meaning

Tringify sees

requested

Stored and reserved; the refund call may be on its way.

pending

pending

Your service has it or may have it. Only look it up from now on; never call to create it again.

pending

held

No answer for 72 hours after it was requested, still pending after 30 days, the cap check failed on a retry, or the merchant account is gone. Look it up once a day and flag it. Never report it failed.

pending

refunded

Your service shows it processed. Final: a later failure from your service is only flagged.

refunded

failed

Explicit failure evidence. Keep looking it up until the failure is confirmed twice; it can still become refunded.

failed

Mark a refund failed only when your service shows it failed (in the call's reply, a lookup or a verified webhook), when your service refuses the first refund call with a documented business reason, when your own check stopped it before anything was sent, or, for a refund never sent, when your service proves no refund exists under its ID. Timeouts, 5xx errors, authentication errors, a refused retry, a refusal of a duplicate ID, a merchant account that is gone, a passing deadline and Tringify's 409 replies are never evidence.

Report a refund outcome#

Report to POST /payments/refunds/{refund_id}/resolve. status is pending, refunded or failed. Send refunded or failed as soon as the outcome is final; pending reports a refund still with your service, including a held one. Fill failure_code and failure_message only for failed.

json
{
  "status": "refunded",
  "provider_refund_id": "rfnd-55120",
  "failure_code": "",
  "failure_message": ""
}

Reply

What to do

2xx

Acknowledged. Sending the same final status again is a no-op 2xx. refunded after failed is accepted: Tringify allows failed to refunded. Keep any confirmation lookup still due.

409 CONFLICT

Never acknowledged. Flag it, send it again once a day, and look the refund up again now; a changed outcome is reported as usual.

5xx, any other 4xx, a timeout, or no token

Retry with the backoff, with no cut-off.

A reply for an older version

Ignore it.

Verify your service's webhooks#

Verify each webhook from your payment service with any credential of the store you still hold: the merchant's current account, the previous credentials while they overlap with new ones after a change, and the account that took the payment or refund. A payment taken with an earlier account therefore still verifies when it is signed with the current or previous credentials. Then look the order or refund up with the account that took it.

  • A webhook for a known store that fails verification gets 401, so your service retries it.

  • Read the body only up to a fixed size. A webhook you can't match to a session and store, or whose credentials you no longer hold, is kept and flagged, and gets 202. Never acknowledge a webhook and drop it.

  • When the merchant changes account or uninstalls your app, keep the old credentials until the drain point, even if nothing is due, so late captures, refund lookups and webhooks still work. At the drain point, keep every unfinished session and refund, flag it and stop. Never report it failed and never delete it.

Maintenance pause#

Build a switch that stops all payment work for a few minutes, for example while you migrate your data. It's your own switch, not a Tringify setting. Read it when each request, webhook and scheduled run starts, and again immediately before every call to your service and every write; if it is on, stop there with nothing sent and nothing written. Make writes fail while it is on, so a request that started before the pause can't write after it.

While paused

Answer

Charge request

The protocol's retryable unavailable answer, so the shopper can try again in a minute. Never a failed payment.

Refund request with a refund_id you have stored

Its stored answer: pending for anything not final.

Refund request with a new refund_id

failed with PAYMENTS_PAUSED and a message such as "Payments were briefly paused; nothing was sent. Request the refund again." Nothing stored or sent.

Refund request while your storage can't be read

503.

Your service's webhooks

503, with nothing read or written, so your service retries.

Shopper return pages

A page saying payments are briefly unavailable and, if the shopper already paid, not to pay again: the order updates in a few minutes.

Health checks from Tringify

Answered from stored data only, as degraded.

Install and uninstall events

503, so Tringify delivers them again.

Your app's admin pages

Read-only, with a notice that payments are paused.

Scheduled runs

Return at once.

After the pause, look up every session that is unfinished or still inside its late-capture window: your service may have stopped retrying a webhook during the pause. Wind your scheduled checks back by the length of the pause so nothing is skipped.

Test#

Case

Expected

Lookups time out for a session past expiry

It stays open and is retried; never reported failed.

A capture two days after a reported failure

paid reported; Tringify answers 2xx.

failed reported for a session Tringify holds as paid

PAYMENT_SESSION_ALREADY_RESOLVED: acknowledged, the order looked up again, and paid reported if a capture shows.

PAYMENT_SESSION_AMOUNT_MISMATCH

Kept as a conflict, flagged, and sent again once a day.

Two captures on one order

The session is paid with one; the other is reported as an extra after the first is acknowledged.

The same refund_id twice at once

One refund at your service; the same answer both times.

Two refunds at once that together exceed the capture

One goes ahead; the other is failed with REFUND_EXCEEDS_PAYMENT, nothing sent.

A refund in your service's dashboard that your service reports in the capture's refunded total, then a Tringify refund that would pass the captured amount

Refused over the cap, nothing sent. The cap counts dashboard refunds only where your service reports a refunded total; if it doesn't, it can't catch this.

A refund your service shows failed, then processed

failed, then refunded reported; its amount stays counted, so further refunds of the capture go ahead only within the remaining cap. If its reservation was already released and the capture is now over the cap, further refunds are refused and the capture is flagged.

A webhook signed with the previous credentials during the overlap

Verified and handled.

A refund request with a new ID while paused

failed with PAYMENTS_PAUSED, nothing sent.

Run these on a development store, with your service's test mode.