# The Transactions API

> Every public transaction endpoint, grouped by the job it does, with the state each one expects and the pitfalls that are easy to hit.
> Source: https://developers.coinbax.com/docs/api/transactions
> Last updated: 2026-08-28

The Transactions API is 23 endpoints. Most integrations use five of them.

This page is the map: what each endpoint is for, what state a transaction must
be in before you call it, and which endpoints exist only for a specific wallet
model. If you have not created a transaction yet, start with
[Your First Transaction](/docs/getting-started/first-transaction/), which walks
one payment end to end.

## The five you will actually use

| Endpoint | When |
|---|---|
| `POST /transactions/quote` | Before creating, to lock fees for 120 seconds |
| `POST /transactions` | Create the transaction |
| `GET /transactions/{id}` | Poll state, or reconcile after a webhook |
| `POST /transactions/{id}/hash` | RAW only: report the transfer hash |
| `POST /transactions/{id}/refund` | Return funds to the sender |

Everything else is either a read, a batch equivalent, or a release variant tied
to a particular wallet model. The rest of this page explains which is which.

## Create and quote

### `POST /transactions/quote`

Returns a quote with the platform fee, workspace fee, gas pass-through where it
applies, and the total charged. The quote has a **120-second TTL**. Pass the
returned `quoteId` to `POST /transactions` to lock those numbers in.

Without a `quoteId`, fees are computed at creation time and may differ from
anything you showed the user. If your UI displays a total before a confirm
step, quote first.

### `POST /transactions`

Creates the transaction. The response `status` tells you what has to happen
next, and it is not always `pending`:

- `pending` — nothing to do; the platform proceeds
- `risk_review` — compliance screening is running
- `pending_user_action` — a wallet must sign before funds move

### Fee previews

Use `POST /transactions/quote`, which returns a lockable `quoteId` alongside
the fee breakdown. The older fee-preview endpoint is no longer part of the
documented surface — it still answers on v1 until that version sunsets, but
there is no v2 equivalent and nothing new should call it.

## Reads

| Endpoint | Returns |
|---|---|
| `GET /transactions` | Paginated list with filters |
| `GET /transactions/{id}` | One transaction, full detail |
| `GET /transactions/stats` | Aggregate counts and volumes |
| `GET /transactions/template-config` | Sanitized control config for a template |

`GET /transactions/template-config` deserves a note. It returns public-safe
summaries of each active control on a template, for rendering a
"what protects this payment" panel before the user confirms, and for enforcing
client-side amount limits. It is deliberately sanitized: only a hand-picked
subset of each control's config is exposed, never policy or implementation
fields. Do not expect it to describe how a control decides anything.

## Reporting an on-chain hash

Which endpoint you call depends on the orchestration type, and they are not
interchangeable:

| Orchestration | Endpoint | What it reports |
|---|---|---|
| `RAW` | `POST /transactions/{id}/hash` | A direct wallet-to-wallet transfer |
| `COINBAX` (external wallet) | `POST /transactions/{id}/escrow-hash` | Funds deposited into the escrow contract |

`RAW` has no contract and no hold period. `COINBAX` uses the escrow contract
and a time delay. Calling the wrong one is a 400.

## Release: five endpoints, one decision

This is the part of the API most likely to send you to the wrong endpoint. All
five exist because **who broadcasts the on-chain write** and **who pays gas**
vary, and the escrow's `PRE_RELEASE` gate has to be satisfied before any of them
succeed.

Start here:

```
Is the hold window over?
├── YES  ─ Who broadcasts?
│         ├── Coinbax  ───────▶  POST /transactions/{id}/release
│         └── Your wallet ────▶  POST /transactions/{id}/release/attest
│                                then your own on-chain releaseAfterDelay
└── NO (early release, sender must consent)
          ├── Sender signs, relayer pays gas:
          │     1. POST /transactions/{id}/release-early/attest   → EIP-712 payload
          │     2. POST /transactions/{id}/release-early             → submit signature
          └── Sender's own wallet broadcasts:
                POST /transactions/{id}/release/attest
                then your own on-chain releaseEarly
```

### `POST /transactions/{id}/release`

Coinbax broadcasts `releaseAfterDelay` using a gas-only server signer. Runs the
`PRE_RELEASE` controls, records the on-chain attestation, then releases. The
payout goes to the **fixed on-chain receiver** — the server only pays gas, so
this stays non-custodial.

**Rejected before the window elapses.** Early release must be signed by the
sender; there is no way around that.

### `POST /transactions/{id}/release/attest`

Use when **your own wallet** will broadcast the release. Runs the `PRE_RELEASE`
controls and records the aggregate result on-chain so your subsequent
`releaseEarly` or `releaseAfterDelay` write does not revert on the escrow's
`PRE_RELEASE` gate.

Call it **immediately before** your on-chain write. It authorizes; it does not
release.

### `POST /transactions/{id}/release-early/attest` then `/release-early`

Gasless early release. The first call runs the `PRE_RELEASE` controls and, only
if they pass outright, returns an EIP-712 payload for the sender to sign — no
transaction, no gas. The second submits that signature, and the relayer
broadcasts and pays gas.

The first call has three outcomes, and only one of them yields a payload:

| `status` | Meaning |
|---|---|
| `ready` | Payload returned; have the sender sign it |
| `under_review` | A control is still pending. No payload. Retry later |
| `rejected` | A required control failed; the escrow auto-refunded on-chain |

Two constraints worth knowing before you build on this: it is **feature-flagged**
(403 when disabled) and v1 supports **only bare external-wallet EOA senders**.
Authorizations expire, and controls are re-checked fail-closed before the
relayer broadcasts, so a stale signature is a 400 rather than a silent no-op.

## Ending a transaction early

| Endpoint | Precondition | On-chain? |
|---|---|---|
| `POST /transactions/{id}/cancel` | `PENDING_USER_ACTION` | No — funds were never escrowed |
| `POST /transactions/{id}/rescind` | Within the hold period, sender only | Depends on the signing wallet — see below |
| `POST /transactions/{id}/refund` | Escrowed | Yes |
| `POST /transactions/{id}/release` | Escrowed, hold window elapsed | Yes — releases to the recipient |

`cancel` is for the user rejecting the wallet popup with no intent to retry. It
marks the transaction `FAILED` with `userCancelled=true` and **rejects with 400
once the transaction reaches `ESCROWED`** — at that point use `refund` or
`rescind`.

### Rescind comes from a control, not from this list

`rescind` is in the table above because it ends a transaction, but that is not
where it comes from. **A `TimeDelay` control grants the sender the right to pull
a payment back during the hold window.** Rescind is the sender exercising that
right — configure the control and the capability appears; leave it off and there
is nothing to call.

That origin explains every one of its conditions:

- the control must exist on the transaction, and have `allowSenderRescind` enabled
- the call must fall inside the hold window the control defines
- `senderWallet` must match the escrow's sender — a mismatch is a `403`, not a `400`
- if the control sets `rescindRequiresReason`, a `reason` is mandatory

**Who signs determines what the API call does.** The escrow contract only lets
the wallet that funded an escrow pull it back — `cancelEscrow` requires
`msg.sender` to be the original sender — so the answer follows from which wallet
created the escrow:

| The escrow was funded by | `POST /transactions/{id}/rescind` does |
|---|---|
| A wallet Coinbax operates for you (via your configured wallet provider) | Records the rescind **and** submits the on-chain call, because that wallet is the escrow's sender |
| Your own wallet (MetaMask, Send, Banno) | Records the rescind only — status, metadata, webhook. **Your wallet then calls `cancelEscrow`** |

In both cases the funds move under the authority of the wallet that owns them.
Coinbax operates a wallet on your behalf only where you have configured a wallet
provider to do so; it never takes custody of funds and cannot move an escrow your
own wallet created.

See [Controls](/docs/concepts/controls/) for configuring `TimeDelay`.

## Batches

Multi-recipient sends mirror the single-transaction endpoints:

| Endpoint | Purpose |
|---|---|
| `POST /transactions/batches` | Create a parent batch plus one leg per recipient |
| `GET /transactions/batches` | List batches (legs **not** embedded) |
| `GET /transactions/batches/{id}` | One batch, legs included |
| `POST /transactions/batches/{id}/cancel` | Cancel an unsubmitted batch |
| `POST /transactions/batches/{id}/escrow-hash` | Report the `batchCreateEscrow` hash |

Batch 2FA verification is not in the documented surface: it routes to a
control that is not in the catalog, and `TwilioSMS` is the real one. Verify a
batch the same way you verify a single transaction — see
[Controls](/docs/concepts/controls/).

Creation writes the parent and every leg in one database transaction. Your
client then signs a single `batchCreateEscrow` call using each leg's UUID as the
on-chain `transactionId`. Per-leg escrow events then advance each leg
independently — so **legs can end in different states**, and a batch is not
atomic after submission.

To list batches and show recipients, you need `GET /transactions/batches/{id}`
per batch; the list endpoint omits legs deliberately.

## Two things that surprise people

**`amount` is a number in requests and a string in responses.** The API accepts
`amount` as a JSON number, but the column is a SQL `decimal`, and the Postgres
driver returns decimals as strings to avoid float precision loss. So:

```jsonc
// request
{ "amount": 100.5 }

// response
{ "amount": "100.500000", "fee": "0.250000" }
```

Do arithmetic with a decimal library, and never `===` a response amount against
a number literal. The same applies to `fee`, `netAmount`, and other monetary
fields.

**Release does not always mean released.** A `POST /transactions/{id}/release` can legitimately
return 200 when the escrow was *already* released or refunded on-chain (the
platform syncs its record), or when a required `PRE_RELEASE` control failed and
the escrow was refunded instead. Read the response rather than assuming 200
means funds moved to the recipient.

## Related

- [Your First Transaction](/docs/getting-started/first-transaction/) — one payment, end to end
- [Controls](/docs/concepts/controls/) — what runs inside the lifecycle
- [API Scopes](/docs/api/scopes/) — releasing funds needs `move:transactions`, reversing them `reverse:transactions`; neither is granted by `write:transactions`
- [Webhooks](/docs/api/webhooks/) — react to state changes instead of polling
- [Errors](/docs/api/errors/) — the response envelope and error codes