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
failedonly 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_RESOLVEDto a payment report, acknowledges one.
Term |
Meaning |
|---|---|
Session |
One checkout payment Tringify asks your app to take, identified by |
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 |
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 |
|---|---|---|
|
Created; no outcome yet. |
Lookups (see Check unfinished checkouts). |
|
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. |
|
Tringify acknowledged the failure. |
Lookups for a late capture until the window ends. |
|
Tringify refused the report. |
Investigation, and the same report once a day. |
|
A capture with the session's amount and currency. |
A report to Tringify, and lookups for extra captures until the window ends. |
|
Tringify acknowledged the payment. |
Lookups for extra captures until the window ends. |
|
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.
{
"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 |
Mark the report done. |
409 |
You reported |
Treat the report as acknowledged and stop sending it. Look the order up again at once: if it shows a capture, report |
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 |
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 |
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
failedwith a code such asNO_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 |
|
An outcome. |
At |
All |
|
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 |
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, answerfailedwith nothing sent; unreadable, answer503.The same
refund_idwith the same content gets the refund's current answer. With different content it gets409IDEMPOTENCY_CONFLICT.Only the request that stored the refund makes the first refund call. Every later attempt looks the refund up under
refund_idfirst, 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_idafter 5 minutes instead of creating it. Mark itfailedwithREFUND_NOT_SENTonly 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
409PAYMENT_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
failedwith 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
refundedand 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 |
|
The capture isn't refundable / the same |
|
Stored, and the first call answers processed |
|
Stored, and the first call answers with a failed refund or a documented business refusal, or the cap check before it fails |
|
Stored, and anything else: accepted, pending, a timeout, a 5xx or an authentication error |
|
The |
|
Refund states#
State |
Meaning |
Tringify sees |
|---|---|---|
|
Stored and reserved; the refund call may be on its way. |
|
|
Your service has it or may have it. Only look it up from now on; never call to create it again. |
|
|
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. |
|
|
Your service shows it processed. Final: a later failure from your service is only flagged. |
|
|
Explicit failure evidence. Keep looking it up until the failure is confirmed twice; it can still become |
|
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.
{
"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. |
409 |
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 |
Its stored answer: |
Refund request with a new |
|
Refund request while your storage can't be read |
|
Your service's webhooks |
|
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 |
|
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 |
A capture two days after a reported failure |
|
|
|
|
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 |
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 |
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 |
|
A webhook signed with the previous credentials during the overlap |
Verified and handled. |
A refund request with a new ID while paused |
|
Run these on a development store, with your service's test mode.