ServicesHandshake

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_BACK

The 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 party

Staging 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:

SituationResponse
Item leg names an item that does not exist400 invalid_offer
Item leg names an item the giver does not own403 wrong_party
Currency leg amount is fractional400 invalid_offer
Giver has no wallet in that currency409 invalid_settlement_state
Staging your side twice409 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.5 is accepted at offer time and survives accept, then fails every stage attempt with 400 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 status and settlement_id. The CLI's handshake settlement status is misleadingly named - it prints the session.

Reference

On this page