hamsa software
← All articles
Integrations8 min read

The payment timed out. The money might have moved.

A lost response leaves the payment outcome unresolved. A worked attempt ledger follows provider evidence, concurrent updates and fulfilment recovery.

Two polygons face one another across an irregular seam, with a detached circle to one side; the separation evokes a payment outcome known differently by two systems.

The timeout is a gap in knowledge

A customer presses Pay and the application times out. The next button says Try again, which is a remarkably small label for three possible actions: check the first attempt, repeat it under the provider’s idempotency rules or start a new payment. Only the application can know which it intends. If it has lost the result, the customer cannot safely make that choice from the screen.

A timeout tells us that a caller stopped waiting. It does not establish that the remote operation failed. The distinction becomes uncomfortable when the person waiting is deciding whether to enter their details again. They should not have to infer the state of a distributed transaction from an apologetic banner.

I would give the unresolved attempt a durable identity before trying to improve the message. Support needs something it can look up, and a worker needs somewhere to save the next piece of information. Calling the attempt failed produces a simpler screen, but it can also let the customer pay again while the provider already holds the first £120. The extra state buys the business time to find out.

The following design walkthrough uses current Stripe documentation for API v1 to separate a transport timeout from the provider’s recorded payment state. The order, identifiers and timings are illustrative, not a customer incident or a measured integration. Assume a straightforward card payment with automatic capture; authentication and asynchronous outcomes still need their own branches. The proposed local states are our application's vocabulary, not Stripe status names.

Keep an attempt that survives the browser

Use a synthetic order, order-42, for £120. Its local attempt, pay-17, records 12000 pence and GBP. In this example, a PaymentIntent has already been created and its provider identifier durably linked before confirmation begins. That gives recovery a known object to inspect. If creation itself becomes uncertain, that is another operation to reconcile; Stripe describes using local identifiers in metadata to help match objects received later.[1]

Persist the attempt identity, order reference, fixed request parameters, provider reference, idempotency key, creation time, state version and latest evidence reference. The idempotency key labels one provider operation so an allowed repeat can be recognised as the same request, rather than a new payment. Keep payment credentials out of this operational record. A support colleague needs to find the attempt, not acquire access to the customer's payment details. Store each evidence observation separately so a later update does not erase why an earlier decision was made.

The local attempt starts prepared. A worker claims confirmation and moves it to in_flight; if the wait expires, it can record unknown without declaring the order unpaid. Later provider information may justify confirmed, awaiting_customer, awaiting_provider or needs_review. Those names should help the next action happen: fulfil the confirmed order, offer the supported authentication step or continue observing the provider. An unknown attempt still needs investigation before the interface offers another payment.

Make transitions conditional on the current version. If a webhook confirms the payment while a worker is still waiting, the worker's eventual timeout must not overwrite confirmed with unknown. A database update that only accepts its expected in_flight version can reject that stale write. This is a local concurrency rule, not a transaction spanning your database and Stripe. The remote call can still finish while the local process crashes.

json
{
  "attempt_id": "pay-17",
  "order_id": "order-42",
  "request": {
    "operation": "confirm existing PaymentIntent",
    "amount_minor": 12000,
    "currency": "GBP"
  },
  "provider_intent_id": "illustrative-existing-intent",
  "idempotency_key": "illustrative-confirm-pay-17",
  "state": "unknown",
  "version": 3,
  "last_evidence": "caller timeout at12:00:04"
}

Eight seconds, seen from two places

At 12:00:00, the application has persisted pay-17 and its existing PaymentIntent reference. At 12:00:01 it sends confirmation with the attempt's key and unchanged parameters. At 12:00:02 the provider's payment reaches succeeded. The response is lost. The application learns none of that and, at 12:00:04, records unknown when its wait expires.

The provider knows the payment succeeded, while the application still lacks that evidence. In this illustrative sequence, a verified success event arrives at 12:00:06 and the local transition records confirmed. A scheduled retrieval reports succeeded at 12:00:07, corroborating the same payment. It must not authorise another fulfilment, nor should the duplicate event arriving at 12:00:08 create another business action.

The invented times make one ordering easy to follow, but the implementation has to survive the others. Retrieval can arrive before the webhook. The timeout can arrive after confirmation, when its expected-version update must fail. In each case the payment remains the same payment. The first useful message need not be the response to the original call, and the recovery worker cannot depend on being the only person to notice success.

The provider has a result before the application has evidence

An illustrative £120 payment, pay-17, against an already linked PaymentIntent. These are invented timings that expose the response-loss and duplicate-evidence races.

  1. 12:00:00 · ApplicationPrepared

    Persists order-42, pay-17, £120/GBP, the existing provider reference and fixed request identity.

  2. 12:00:01 · ApplicationIn flight

    Claims this attempt version and sends confirmation using unchanged parameters and its idempotency key.

  3. 12:00:02 · ProviderSucceeded

    The payment reaches succeeded. Its reply is lost before the caller receives it.

  4. 12:00:04 · ApplicationUnknown

    The caller stops waiting and records uncertainty; it has no evidence authorising another payment.

  5. 12:00:06 · Verified eventConfirmed

    Matching success evidence confirms pay-17. One local transaction records the state and uniquely identified fulfilment work.

  6. 12:00:07 · RetrievalStill confirmed

    A read also reports succeeded. It corroborates confirmation and creates no second fulfilment-work record.

  7. 12:00:08 · Duplicate eventNo new work

    Already accepted evidence is recognized. No additional business action is authorised.

Provider success, caller uncertainty and locally verified confirmation are different observations. The local fulfilment record still needs its own safe external execution and recovery path. Timings are illustrative.

Recovery begins with a read

Retrieve the known PaymentIntent through the supported provider route. Stripe distinguishes succeeded from states requiring customer action or further processing.[2] Our local unknown says we have not learned the outcome; Stripe’s processing says something about work at the provider. Substituting one for the other would let the application sound more certain without acquiring any information. Keep the two vocabularies distinct while deciding which continuation the provider result supports.

For this scoped example, a matching succeeded result can confirm the payment. Match the linked object, expected account context, amount and currency before associating evidence with the order. Route requires_action through the supported customer-action flow and processing into continued observation. A failed attempt may need a new payment method. Do not interpret every non-success status as a generic decline or automatically create a new intent to escape it.

Sometimes a read is unavailable too. Keep the unresolved state, schedule a bounded check and surface its age to operations. Polling aggressively is not a substitute for event handling; Stripe recommends webhook-based fulfilment rather than relying on the customer's page, and notes the limitations of polling.[2] The customer should be able to close the page while the business continues finding out what happened.

Idempotency protects a narrower operation. Stripe's API v1 guidance permits network-error retries with the same key and parameters within the first 24 hours. Keys are retained for at least 24 hours and may later be removed; reusing a removed key can execute a new request. A cached 500 can remain the replayed response even while side effects require reconciliation.[1] Returning the same error is therefore not proof that no payment occurred. API v2 has different replay behaviour; this example must not be copied across versions unexamined.

Confirming payment does not make fulfilment exactly once

Stripe documents duplicate webhook delivery and does not guarantee event ordering. Verify signatures before accepting an event.[3] Store accepted evidence durably and make duplicate handling explicit. An event identifier can identify a redelivery, while a business-action identity is needed when different evidence paths both establish the same payment outcome.

For order-42, suppose the verified event and retrieval worker both try to mark the order paid. In the proposed design, a conditional local transition and one uniquely identified fulfilment-work record, fulfil:order-42, are committed together in the same database transaction. Only the transition that first establishes the required state creates that work. A second observation records corroboration without creating a second dispatch instruction. The pseudocode below shows that local rule only; it omits implementation-specific locking, retry and error handling, and is not production-certified payment code.

The warehouse creates another opportunity to lose an acknowledgement. A dispatcher can send the fulfilment instruction successfully, then crash before recording the reply. On restart, duplicate-webhook filtering says nothing about whether the warehouse received that instruction. The dispatcher needs a supported external operation identity or a way to reconcile the warehouse result before sending again. Keeping that work in a background job lets the investigation continue after the customer leaves, but it does not make the remote operation atomic.

Retain the distinction between payment success and later events. Refunds and disputes do not turn the original confirmation into something that never happened; they need their own recorded actions and policies. Likewise, a stale non-terminal observation should not roll confirmed backwards. Conflicting evidence should trigger a fresh authoritative check or review, rather than a last-message-wins update that rewards whichever worker happens to finish later.

text
ILLUSTRATIVE LOCAL TRANSACTION — NOT PRODUCTION CODE
Precondition: verified, matching provider success evidence.

BEGIN
  changed = update attempt pay-17
            set state = confirmed, version = version + 1
            where version = version_read
            and state allows confirmation

  if changed == 1:
    insert fulfilment work with unique key fulfil:order-42
  else:
    reread attempt
    if already confirmed by matching evidence:
      record corroboration; create no new fulfilment work
    else:
      stop and resolve the competing state
COMMIT

External fulfilment is a separate operation with its own recovery.
Transaction errors must not acknowledge lost work as completed.

Make a changed basket an explicit decision

Now let the customer add £6 delivery while pay-17 remains unknown. The revised total is £126, or 12600 pence. Sending the new amount under the old key conflicts with the original request. Generating a new key immediately avoids that parameter conflict by risking a new operation while the £120 payment may already exist. Neither is a satisfactory way to update a basket.

Hold the revision separately until the first outcome is established. If payment succeeded, apply the business's explicit adjustment or cancellation process; if another step is required, continue through the documented provider flow. Do not silently substitute an amount or assume a local Cancel button can undo an accepted payment. A support view should show both the unresolved attempt and the proposed basket change so a colleague can explain the hold.

A practical fault exercise has four cases: lose the confirmation response after provider success; deliver the event twice; let retrieval and the event race; and restart after recording payment but before acknowledging fulfilment. For each, inspect the local evidence, provider object and resulting fulfilment request. This is a test plan, not a claim that a particular integration has passed it. Add a case past the retry window so recovery cannot depend indefinitely on an old key.

Once recovery is doing that work, the screen can tell the customer that the payment is being checked, keep the order reference visible and ask them not to pay again yet. Give the unresolved attempt a review deadline the business can meet and an accessible route to support. A colleague opening order-42 later should see the £120 attempt and the proposed £6 change together, with enough history to explain why the basket is on hold.

The basket changes while pay-17 remains unknown

A separate illustrative decision concerning the £120 attempt and proposed £6 delivery addition.

Scroll sideways to see all columns.

OperationResulting problemRequired boundary
Reuse old key with £126Parameters differ from the £120 requestDo not mutate the original attempt
Create a new key immediately£120 may already have been paidEstablish the first outcome
Hold basket revision separatelyKeep £126 proposal visible to supportApply an explicit adjustment or cancellation policy
An idempotency key is tied to its operation; a changed basket is a new business decision, not a retry parameter.

Working on a workflow like this? Hamsa helps finance and operations teams connect their tools and handle the exceptions, starting with one workflow.

See the Payment Operations Pilot

References & further reading

Primary sources behind this article. Living documentation may change.

  1. Stripe: Advanced error handling and API v1 idempotency
  2. Stripe: Payment status updates
  3. Stripe: Receive webhook events
Hamsa Jama

Practical engineering. A human perspective.

More about Hamsa Software and the person behind it. Editorial notes.

A few more things
worth a read.

Back to the reading room
Integrations7 min read

A valid signature still needs a trust decision

An Oprindo sample had a matching asset hash and a valid signature, alongside an untrusted credential result. The useful story is in those separate answers.

Integrations6 min read

Should this old instruction still run?

Repairing a consumer makes replay possible. Current state, dependencies and recovery admission decide whether it is still appropriate.

Integrations7 min read

The backfill arrived after the live update

Historical work needs a population boundary, revision policy and crash-safe checkpoint strategy before its final page can mean done.