Engineering

A status pill is not a state machine

Most payment integrations render a status. A pill that says processing, then success, and a customer who has no idea whether that means the money left, arrived, or is sitting in a queue somewhere between the two.

Every transaction we return carries a timeline alongside that status — an ordered array of stages, each with its own state and its own timestamp. The pill is a summary. The timeline is what actually happened. Rendering the second one is most of the difference between a support ticket and a customer who can see for themselves.

{
  "status": "processing",
  "timeline": [
    { "stage": "requested", "status": "completed", "occurred_at": "…T10:16:57Z" },
    { "stage": "debited", "status": "completed", "occurred_at": "…T10:16:57Z" },
    { "stage": "processing", "status": "in_progress" },
    { "stage": "sent", "status": "pending", "occurred_at": null },
    { "stage": "delivered", "status": "pending", "occurred_at": null }
  ]
}

Do not compute the status

The summary status is derived from the steps: if one step fails the transaction failed, if all of them succeeded it succeeded, otherwise it is in flight. That derivation happens on our side and arrives in the response.

It is tempting to recompute it on the client anyway — the rule is two lines and it feels like defensive coding. It is the opposite. The moment the derivation gains a case your copy does not have, your UI disagrees with the system of record, and it will disagree silently, on the transactions where the extra case applies. Those are exactly the transactions somebody is calling about.

Render the status you were given. Use the stages for detail.

A null timestamp is information, not a gap

The timeline includes stages that have not happened yet. A pending stage carries an occurred_at of null, and that is the point: you can render the whole path, including the part still ahead, rather than revealing steps one at a time as they complete.

A customer looking at a payout that is three stages from done and can see all three is in a different state of mind from one looking at a spinner. The information is the same. The anxiety is not.

This does mean a null timestamp must never render as an empty cell or a dash that looks like an error. Pending is a state with a visual treatment, not missing data.

Stages are stable, the set of them is not

The stage identifier is canonical and safe to switch on. The list of stages is not fixed: a collection and a payout move through different ones, and no transaction is guaranteed to touch every stage that exists.

So build the stepper from the array you received, in the order you received it, rather than from a hardcoded list you match against. Hardcoding works until the first transaction type that skips a stage, at which point the UI shows a step that will never complete and the customer waits for something that is not coming.

Show the message, do not rewrite it

Each entry carries a message written for end users. Show it verbatim.

The instinct to rewrite it locally is understandable — it is your product's voice, after all — but it forks the copy. When the wording changes because a stage started meaning something slightly different, your version keeps saying the old thing. The stage identifier is the contract you build logic on; the message is the contract you build copy on. Neither one substitutes for the other.

Handle the empty array

Transactions created before the timeline shipped return an empty array. Not null, not absent — empty.

It is a small thing and it is the one that breaks in production, because your test data is all new and every transaction you developed against had a full timeline. Any historical view — a list, an export, a support tool — will eventually load one of these. Render the status pill alone and move on.

The event tells you when. The transaction tells you what.

You should not be polling for status. Point a webhook at an HTTPS endpoint and a signed event arrives as soon as money moves — collection.completed, payout.failed, orchestration.paying_out and the rest.

There is a difference, though, between using events and trusting them. An event says something changed at a moment in time. It can arrive again — we retry when your endpoint does not return 2xx, and you can replay any delivery yourself from the dashboard. A second delivery of the same event is a normal occurrence, not an incident.

So treat the event as the trigger and the transaction as the answer. Read the timeline, apply what you find there, and every replay converges on the same state instead of re-running whatever the payload happened to say. Handlers written that way are safe to replay deliberately — which is what makes the replay button a tool rather than a risk.

This is the same shape as a timeout on a write, which is resolved by reading rather than retrying — the argument in idempotency keys and what they cannot save you from.

The full stage reference is in the docs. Questions: hi@spendfigo.com.

Common questions

Should a client compute transaction status from individual steps?

No. The API derives the summary status from the steps and returns it, so a client that recomputes it will eventually disagree with the system of record. Render the status you are given and use the stages for detail.

Why does a transaction stage have a null timestamp?

Because the stage has not been reached yet. Stages are returned in order, including ones still pending, so the absence of a timestamp is the signal that the step is ahead of the movement rather than missing from it.

Do all transactions return the same stages?

No. A collection and a payout pass through different stages, and no transaction is guaranteed to use every stage. Render the stages the response contains rather than a fixed list you hardcoded.

← All engineering writing