Settlement and Escrow
Accepting an offer moves nothing. It freezes the agreed bundle into a settlement that both parties must stage and then both confirm, and only the second confirmation performs the swap:
accept -> STAGING -> CONFIRMING -> COMMITTING -> COMMITTED
| | |
+------------+--- cancel / timeout -----+-> ROLLED_BACKThe session tracks the same progression as STAGING, CONFIRMING, and
finally SETTLED.
Accept creates the settlement
snug handshake offers accept --domain docs4-hs --id $SESSION --offer-id $OFFER{
"settlement_id": "yvZWwqamPnTAacQYQXpq",
"session_id": "prbcyxtVJaMXENFcJYXC",
"accepted_offer_id": "ENAQQXySzTMAcHcyydpm",
"initiator_stage": { "party_id": "docs-wave", "ledger_hold_ids": [], "item_lock_ids": [], "staged": false, "confirmed": false },
"responder_stage": { "party_id": "docs4-hs-other", "ledger_hold_ids": [], "item_lock_ids": [], "staged": false, "confirmed": false },
"deadline_at": "2026-08-28T23:06:04.343096Z",
"status": "STAGING"
}deadline_at is now plus settlement_timeout_seconds; everything that
follows has to finish inside it.
Stage: place the real escrow
Each party stages their own side. This is where Handshake reaches into the other services - a Treasury hold per currency leg, an Item Engine reservation per item leg, both under the giving party.
snug handshake settlement stage --domain docs4-hs --id $SESSION # run by each partyStaging a 250-gold leg moved the giver's wallet from available 5000, held 0
to available 4750, held 250: the balance is untouched, the funds are simply
no longer spendable. The escrow ids land on the party's stage, an item lock
being {reservation_id}|{fencing_token}:
"initiator_stage": { "ledger_hold_ids": ["hold_5f25467b276b442e9c5cbef17d143a37"],
"item_lock_ids": [], "staged": true, "confirmed": false },
"responder_stage": { "ledger_hold_ids": [],
"item_lock_ids": ["resv_hSTcQyRZEFPq|1"], "staged": true, "confirmed": false }Staging is all-or-nothing per call: if the second leg of your side fails, the
first is released before the error returns. Once both sides are staged the
settlement advances to CONFIRMING in the same write, so the confirm phase
can never start against a half-staged settlement.
Failures reproduced at this step:
| Situation | Response |
|---|---|
| Item leg names an item that does not exist | 400 invalid_offer |
| Item leg names an item the giver does not own | 403 wrong_party |
| Currency leg amount is fractional | 400 invalid_offer |
| Giver has no wallet in that currency | 409 invalid_settlement_state |
| Staging your side twice | 409 invalid_settlement_state |
Confirm: the two-key step
snug handshake settlement confirm --domain docs4-hs --id $SESSION
snug handshake settlement confirm --domain docs4-hs --id $SESSION --staged-hash "sha256:e19a801e..."The first confirmation returns WAITING_FOR_COUNTERPARTY; the second returns
COMMITTED, and the swap has already happened by the time you read it:
{
"status": 200,
"msg": "OK",
"data": {
"outcome": "COMMITTED",
"settlement": {
"settlement_id": "yvZWwqamPnTAacQYQXpq",
"status": "COMMITTED",
"initiator_stage": { "staged": true, "confirmed": true, "...": "..." },
"responder_stage": { "staged": true, "confirmed": true, "...": "..." }
}
}
}Confirming before both sides have staged is 409 invalid_settlement_state.
The staged state hash
--staged-hash binds your confirmation to the exact bundle you staged
against, so it cannot be replayed onto different terms. A wrong value is
409 stale_confirmation; omitting the flag skips the check entirely.
The expected value is sha256: followed by the SHA-256 of the accepted
offer's bundle serialised as compact JSON in the server's field order.
No endpoint returns the stored hash, so rebuilding it from the transcript is
the only way to obtain it - and it has to come from the raw HTTP response:
curl -s -H "Authorization: Bearer $SNUG_API_TOKEN" \
"$SNUG_API_URL/api/v1/handshake/docs4-hs/sessions/$SESSION/history" \
| python3 -c 'import json,sys,hashlib
b=[o for o in json.load(sys.stdin)["data"]["offers"] if o["status"]=="ACCEPTED"][0]["bundle"]
print("sha256:"+hashlib.sha256(json.dumps(b,separators=(",",":")).encode()).hexdigest())'Do not derive it from snug --output json: the CLI sorts object keys
alphabetically, so it emits {"amount":75.0,"currency_id":"..."} where the
server hashed {"currency_id":"...","amount":75.0}. The digests differ and
every confirmation is rejected with 409 stale_confirmation. Verified both
ways against one live settlement - the curl-derived hash committed it, the
CLI-derived hash did not.
Because the value is this sensitive to byte-level formatting, leave the flag off unless you actually need replay protection.
What actually moves
Currency and items settle differently, and the difference matters.
Currency is captured from each giver's hold and credited to the receiving
counterparty in one atomic Treasury settle. On a 250-gold leg the initiator
went 5000 -> 4750 and the responder 0 -> 250, with a ledger entry tagged
handshake_settle and referenced back to the settlement:
{ "amount": 250, "direction": "CREDIT", "tags": ["handshake_settle"],
"reference": { "service": "handshake", "id": "yvZWwqamPnTAacQYQXpq" } }A receiver with no wallet in that currency gets one created for them (verified:
a delivered wallet came back with created_by: "handshake"). The giver's
wallet is only ever looked up, never created.
Items do not keep their identity. The giver's reservation is committed,
which consumes the item, and the receiver is granted a fresh instance of the
same definition in a new container Handshake creates for the delivery.
Verified: item_PwYhuyKhyhtJ ceased to exist and item_XfSfxrysezyA appeared
in a new receiver-owned container. Item legs therefore settle by definition
and quantity, so per-instance state - durability, attributes, slot - does not
survive the swap. Do not use an item leg where the individual instance is the
point.
Cancel, timeout, and the sweep
Either party can cancel at any non-terminal point, and escrow already placed is released:
snug handshake sessions cancel --domain docs4-hs --id $SESSION{ "session_id": "ZyaXbXdWjYrFZSADGpfV", "rolled_back": true, "message": "Session cancelled" }rolled_back tells you whether there was live escrow to unwind - verified,
the giver's wallet went straight back from available 4350, held 650 to
available 5000, held 0. Cancelling a terminal session is
409 invalid_session_state.
Past deadline_at the settlement is dead: a late confirmation gets
409 settlement_timed_out and rolls the settlement back on the spot, leaving
the session EXPIRED. Settlements whose parties simply walk away are caught
by a background sweeper running every 30 seconds. The same pass can be forced
for one domain with a platform admin token:
snug handshake settlement sweep --domain docs4-hs{ "cancelled": 1, "message": "Swept 1 expired settlement(s)" }Without an admin token this is 403 insufficient_permissions. Because the
background sweeper is already running, a manual sweep often legitimately
reports cancelled: 0 - check the session status rather than the count.
Gotchas
- Fractional currency amounts pass validation and die at staging. An
amount like
10.5is accepted at offer time and survives accept, then fails every stage attempt with400 invalid_offer("currency amount must be a whole number for settlement"). The deal cannot proceed and can only be cancelled, so keep currency legs whole. - There is no endpoint that fetches a settlement. The record only comes
back from accept, stage, and confirm. To poll one, read the session and
watch its
statusandsettlement_id. The CLI'shandshake settlement statusis misleadingly named - it prints the session.
Reference
- Handshake API - every endpoint, callable
- Treasury - the holds and ledger entries behind currency legs
- Item Engine - the reservations behind item legs