Skip to main content
Reconciliation proves that what you recorded matches what moved. Several signals look like an answer; only GET /v2/payments/{paymentId} is one.

Credit on verified receipt, never on a report

Value counts once Stableyard has independently verified the expected receipt. That is what accepted and succeeded mean, and nothing earlier means it. The last row is the expensive one: on a timeout, read the payment, and do not create a second one. Feed reads from both webhook events and a scheduled sweep. Events keep latency low; the sweep catches deliveries that never arrived.

Key both sides

Set externalReference when you create the payment, and store the returned payment_ id in the same write.
externalReference takes up to 256 characters and is matched exactly. If you lose the payment id, it finds the payment.
Use a reference that survives a process restart, and derive your Idempotency-Key from the same operation; see Idempotency. metadata is a free-form object on the payment but not a query filter, so put the identifier you will search on in externalReference.

Group ledger rows by payment

GET /v2/accounts/{accountId}/transactions is the durable record, and one payment produces several rows: collection, settlement, fee payout and refund are separate legs. Every row carries paymentId and a financialLegKind, so group by paymentId; matching one row to one payment loses the rest. The same row can read as sent for one account and received for another, so label it with perspective.direction for the account you asked about.

Store these fields

One column cannot hold both the financial result and the operational health. Keep two. A payment can be succeeded and requires_intervention at the same time, and overwriting one with the other loses the fact you will need.

Know what sits beside status

status and operationalState are the two axes to act on. Three more fields sit beside them, and none substitutes for status. A financial status never regresses after accepted. When settlement, a fee payout or a refund fails afterwards, the result stands and operationalState moves instead.

Survive duplicates and reordering

Delivery is at-least-once and ordering is not guaranteed. You want all three defences.
1

Dedupe on the event id

Record x-stableyard-event-id under a unique constraint, inside the same transaction as the business update. If the insert conflicts, return 2xx and do nothing else.
2

Derive the effect from a read

If your handler acts on a fresh GET rather than on the event body, a duplicate or reordered delivery converges on the same answer instead of corrupting it.
3

Compare statusVersion

statusVersion increases by one whenever status, stage, operationalState or operationalReasonCode changes. Keep the highest you have seen and discard a read that reports a lower one.
Headers are on Webhooks, signature verification on Verifying signatures, and retry behaviour on Delivery and retries.

Sweep on your own schedule

Read every non-terminal payment older than your funding window, rather than waiting for a delivery that may never come.
Page with cursor until nextCursor is null. Drop a payment from the sweep once it is terminal: a terminal result never reopens. Status codes names which values are terminal.

Handle the money edges

Tell a recovery from a refund

Every Refund carries a reasonCode that tells you who started it. Read it before you post anything against a customer’s record. On the payment, refundSummary aggregates the attached Refund resources as status, count, requestedAmountAtomic and confirmedAmountAtomic; each refund_ resource stays the record of its own operation. incidentRecoverySummary covers duplicate, late, overpaid and underpaid receipts, and its amounts never contribute to refundSummary. That is what stops the return of an accidental duplicate from making a completed payment look refunded.

Next: Error handling

What fails in a payment flow, and what to do about each case.