Skip to content
Stellar testnet. No real money moves. Cross-chain quotes are simulated. All people and teams are sample data.

Build on Ahadi

A small, public REST API for prize funds on Stellar testnet: every fund, ledger line and receipt, the organiser's two signature approval flow, and a live stream of everything the Omega agent does.

Introduction

The API is served by the Ahadi bridge, a FastAPI service. This site proxies it at /api, so every example below works from the browser on this origin. Locally the bridge runs at http://127.0.0.1:8000, with an OpenAPI description at /api/openapi.json and interactive docs at /api/docs.

Base URL
/api on this site. The examples use $AHADI for your origin.
Format
JSON in and out. The ledger is also exported as CSV.
Money
Integer cents of the demo asset USDB, for example 17000 is USD 170.00. Never floats.
Network
Stellar testnet only. The bridge refuses to start against any other Horizon. No real money moves.

Access and authentication

There are no user accounts. Access depends on what an endpoint can change.
AccessWhoHow
PublicAnyone: participants, sponsors, judges, your appNothing to send. Every read under /api is public.
Console passcodeThe organiser's consoleWrites send an x-ahadi-passcode header. A demo passcode stands in for real accounts. Any money movement still needs the organiser's own signature, made in their browser.
Internal, shared secretThe Omega agent's skills/tools/* on the internal Docker network, with an x-ahadi-tool-secret header.

Errors

Errors from Ahadi's engine carry a stable error code and a plain sentence in detail. Validation and access errors raised before the engine carry detail only, so read detail first.
Captured from the bridge
// An engine error: a stable code plus a plain sentence
HTTP 404  {"error": "not_found", "detail": "unknown event nope"}
HTTP 409  {"error": "bad_state", "detail": "proposal is settled"}
HTTP 400  {"error": "duplicate", "detail": "eligibility for this event is already attested"}

// A request the framework rejects before it reaches the engine: detail only
HTTP 400  {"detail": "a Stellar transaction hash is 64 hexadecimal characters"}
HTTP 401  {"detail": "console passcode required"}
HTTP 422  {"detail": [{"type": "greater_than_equal", "loc": ["body", "eligible"], ...}]}
StatusMeaning
400The request is malformed, or it repeats something already done (error duplicate).
401The console passcode or the tool secret is missing or wrong.
404Unknown event, line or proposal.
409The action does not fit the current state, for example approving a proposal that was never prepared, or the rule no longer allows the payout.
422A body field failed validation.
502Stellar refused a deposit. Payout failures are not errors: they return ok false and go on the ledger.

Funds and ledger

Each event has a fund: a 2 of 2 vault account on Stellar testnet, a published rule, and an append only ledger. These reads are public.

List events

Public
GET/api/events

Every event with its status, rule fingerprint, prize pool and live totals. Totals include the fund's balance read from Horizon and whether the ledger balances against it.

[
  {
    "event_id": "december-2026",
    "name": "December Builders Hackathon",
    "short": "December 2026",
    "status": "registration",
    "cities": [
      "Lisbon",
      "Nairobi",
      "Chennai"
    ],
    "rule_hash": "d60208842bb018ab9ecece4194e8e7e510bff25da6ae4f30981adfd6e99ea536",
    "pool_cents": 102000,
    "totals": {
      "received_cents": 0,
      "carried_in_cents": 102000,
      "paid_cents": 0,
      "held_cents": 0,
      "carried_out_cents": 0,
      "returned_cents": 0,
      "available_cents": 102000,
      "network_fee_stroops": 100,
      "chain_balance_cents": 102000,
      "balanced": true
    },
    "fund": "GBURXP4QKAKIVJJVQYGUIP2CW3AMZGRVOVLA4ORIKLW7BTNFWTF7SSV2"
  },
  {
    "event_id": "october-2026",
    "name": "October Builders Hackathon",
    "short": "October 2026",
    "status": "closed",
    "cities": [
      "Lisbon",
      "Nairobi",
      "Chennai"
    ],
    "rule_hash": "4ea80d7c415bf35a0a9e4baa8647e15351bb85e56dece2cbf9273aec20bd9333",
    "pool_cents": 170000,
    "totals": {
      "received_cents": 170000,
      "carried_in_cents": 0,
      "paid_cents": 57120,
      "held_cents": 10880,
      "carried_out_cents": 102000,
      "returned_cents": 0,
      "available_cents": 0,
      "network_fee_stroops": 900,
      "chain_balance_cents": 0,
      "balanced": true
    },
    "fund": "GBG73NPGVW2V3ZULS657ZIOIYBKNZVDTTFUEIS2UQKWUX62KGGLAJPFC"
  }
]

Get a fund

Public
GET/api/funds/{event_id}

The full public view of one fund: the event, the canonical rule and its hash, the derivation with every step, the placements, the totals and every ledger line in order.

Parameters

event_idpath, string, required
Event id: october-2026 or december-2026.
{
  "event": {
    "event_id": "october-2026",
    "name": "October Builders Hackathon",
    "status": "closed",
    "eligible": 20,
    "rule_hash": "4ea80d7c415bf35a0a9e4baa8647e15351bb85e56dece2cbf9273aec20bd9333",
    "rule_published_tx": "7584b4e35c5c1a446aa9d767551d95cb24b16027413171b42232365d0b5eb5c8",
    "cities": [
      "Lisbon",
      "Nairobi",
      "Chennai"
    ],
    "short": "October 2026",
    "registered": 46,
    "fund": "GBG73NPGVW2V3ZULS657ZIOIYBKNZVDTTFUEIS2UQKWUX62KGGLAJPFC",
    "asset": "USDB",
    "issuer": "GCUKOGKXAAVFFEAEPNWNQ6WHMQ5XJUNPELDY6SDB4DL6BC34XPAF72SZ"
  },
  "rule": {
    "event_id": "october-2026",
    "name": "October Builders Hackathon",
    "asset": "USDB",
    "pool_cents": 170000,
    "target_teams": 50,
    "release": "eligible_over_target_capped_at_100",
    "place_shares_bp": [
      2500,
      2000,
      1600,
      1400,
      1300,
      1200
    ],
    "rounding": "floor_cent_remainder_to_reserve",
    "claim_window_days": 30,
    "carry_forward": "reserve_to_next_event",
    "version": 1,
    "hash": "4ea80d7c415bf35a0a9e4baa8647e15351bb85e56dece2cbf9273aec20bd9333"
  },
  "derivation": {
    "eligible": 20,
    "release_bp": 4000,
    "award_cents": 68000,
    "reserve_cents": 102000,
    "place_cents": [
      17000,
      13600,
      10880,
      9520,
      8840,
      8160
    ],
    "steps": [
      {
        "rule": "release",
        "text": "Release = min(100%, 20 eligible / 50 target) = 40.00%",
        "value": 4000
      },
      {
        "rule": "award-pool",
        "text": "Award pool = USD 1,700.00 x 40.00% = USD 680.00",
        "value": 68000
      },
      {
        "rule": "reserve",
        "text": "Reserve = USD 1,700.00 - USD 680.00 = USD 1,020.00, carried to the next event",
        "value": 102000
      },
      {
        "rule": "place-1",
        "text": "Place 1 = USD 680.00 x 25.00% = USD 170.00",
        "value": 17000
      }
    ]
  },
  "placements": [
    {
      "place": 1,
      "team": "Team Mvua",
      "person": "Wanjiku Kamau",
      "city": "Nairobi",
      "public": "GATT7DEYRM2GYSCKBUY4U22GKMS2I7EKWK3F4R356SS2CTXDNWQF4PHR"
    },
    {
      "place": 2,
      "team": "Team Alfama",
      "person": "Tomás Reis",
      "city": "Lisbon",
      "public": "GAP2CQG4K7W6QOAWVMCMXYT3THLMMMZWLS3EJT5Z74VKSVVY7T6KZBHV"
    }
  ],
  "totals": {
    "received_cents": 170000,
    "carried_in_cents": 0,
    "paid_cents": 57120,
    "held_cents": 10880,
    "carried_out_cents": 102000,
    "returned_cents": 0,
    "available_cents": 0,
    "network_fee_stroops": 900,
    "chain_balance_cents": 0,
    "balanced": true
  },
  "lines": [
    {
      "id": "l_c73c9ba8b6",
      "seq": 1,
      "event_id": "october-2026",
      "kind": "rule",
      "amount_cents": 0,
      "party": "Inês Duarte",
      "party_public": null,
      "request_id": "rule:4ea80d7c415bf35a0a9e4baa8647e15351bb85e56dece2cbf9273aec20bd9333",
      "tx_hash": "7584b4e35c5c1a446aa9d767551d95cb24b16027413171b42232365d0b5eb5c8",
      "fee_stroops": 100,
      "proposal_id": "p_cf44a13bbc",
      "related_line": null,
      "detail": {
        "rule_hash": "4ea80d7c415bf35a0a9e4baa8647e15351bb85e56dece2cbf9273aec20bd9333",
        "data_key": "rule:october-2026"
      },
      "created_at": "2026-10-23T10:43:42+00:00",
      "receipt_url": "https://stellar.expert/explorer/testnet/tx/7584b4e35c5c1a446aa9d767551d95cb24b16027413171b42232365d0b5eb5c8",
      "horizon_url": "https://horizon-testnet.stellar.org/transactions/7584b4e35c5c1a446aa9d767551d95cb24b16027413171b42232365d0b5eb5c8"
    },
    {
      "id": "l_627863eb56",
      "seq": 3,
      "event_id": "october-2026",
      "kind": "received",
      "amount_cents": 170000,
      "party": "Sample Sponsor Fund",
      "party_public": "GCIYSSZKKEVLAMKOLZVTMQE3BUICKVIEGEVOZ2GP6NUT64ZSZRK67P2V",
      "request_id": null,
      "tx_hash": "226415176cc35e90ce8b642de43f7d2a43d5295f61fa7d4d78705de8cd3bc494",
      "fee_stroops": 100,
      "proposal_id": null,
      "related_line": null,
      "detail": {
        "memo": "68ee7ddcc9301c757959aaa0d354c84fde132e674f79a1da8abb9cb0872bc704"
      },
      "created_at": "2026-10-23T10:43:52+00:00",
      "receipt_url": "https://stellar.expert/explorer/testnet/tx/226415176cc35e90ce8b642de43f7d2a43d5295f61fa7d4d78705de8cd3bc494",
      "horizon_url": "https://horizon-testnet.stellar.org/transactions/226415176cc35e90ce8b642de43f7d2a43d5295f61fa7d4d78705de8cd3bc494"
    }
  ]
}
Trimmed for reading: derivation.steps shows 4 of 9, placements 2 of 6, lines 2 of 14.

Export the ledger as JSON

Public
GET/api/funds/{event_id}/ledger.json

The event, rule, totals and lines, without the derivation or placements. Use it as a feed.

Parameters

event_idpath, string, required
Event id: october-2026 or december-2026.
{
  "event": {
    "event_id": "october-2026",
    "name": "October Builders Hackathon",
    "status": "closed",
    "eligible": 20,
    "rule_hash": "4ea80d7c415bf35a0a9e4baa8647e15351bb85e56dece2cbf9273aec20bd9333",
    "rule_published_tx": "7584b4e35c5c1a446aa9d767551d95cb24b16027413171b42232365d0b5eb5c8",
    "cities": [
      "Lisbon",
      "Nairobi",
      "Chennai"
    ],
    "short": "October 2026",
    "registered": 46,
    "fund": "GBG73NPGVW2V3ZULS657ZIOIYBKNZVDTTFUEIS2UQKWUX62KGGLAJPFC",
    "asset": "USDB",
    "issuer": "GCUKOGKXAAVFFEAEPNWNQ6WHMQ5XJUNPELDY6SDB4DL6BC34XPAF72SZ"
  },
  "rule": {
    "event_id": "october-2026",
    "name": "October Builders Hackathon",
    "asset": "USDB",
    "pool_cents": 170000,
    "target_teams": 50,
    "release": "eligible_over_target_capped_at_100",
    "place_shares_bp": [
      2500,
      2000,
      1600,
      1400,
      1300,
      1200
    ],
    "rounding": "floor_cent_remainder_to_reserve",
    "claim_window_days": 30,
    "carry_forward": "reserve_to_next_event",
    "version": 1,
    "hash": "4ea80d7c415bf35a0a9e4baa8647e15351bb85e56dece2cbf9273aec20bd9333"
  },
  "totals": {
    "received_cents": 170000,
    "carried_in_cents": 0,
    "paid_cents": 57120,
    "held_cents": 10880,
    "carried_out_cents": 102000,
    "returned_cents": 0,
    "available_cents": 0,
    "network_fee_stroops": 900,
    "chain_balance_cents": 0,
    "balanced": true
  },
  "lines": [
    {
      "id": "l_64f1409ef6",
      "seq": 6,
      "event_id": "october-2026",
      "kind": "paid",
      "amount_cents": 17000,
      "party": "Team Mvua",
      "party_public": "GATT7DEYRM2GYSCKBUY4U22GKMS2I7EKWK3F4R356SS2CTXDNWQF4PHR",
      "request_id": "d720ddf847393d19620c790ee822c5be93afb4abf37ae032549f974723516494",
      "tx_hash": "ab4e8a838e9991733eb4610182bb68707d13c964121e0204b1571a4753f55a76",
      "fee_stroops": 100,
      "proposal_id": "p_f90ec389dc",
      "related_line": null,
      "detail": {
        "place": 1,
        "quote": null
      },
      "created_at": "2026-10-23T10:44:02+00:00",
      "receipt_url": "https://stellar.expert/explorer/testnet/tx/ab4e8a838e9991733eb4610182bb68707d13c964121e0204b1571a4753f55a76",
      "horizon_url": "https://horizon-testnet.stellar.org/transactions/ab4e8a838e9991733eb4610182bb68707d13c964121e0204b1571a4753f55a76"
    }
  ]
}
Trimmed: lines shows 1 of 14.

Export the ledger as CSV

Public
GET/api/funds/{event_id}/ledger.csv

One row per ledger line with the receipt link, for spreadsheets and sponsors' reports.

Parameters

event_idpath, string, required
Event id: october-2026 or december-2026.
seq,time,kind,party,party_public,amount_usd,status_detail,tx_hash,receipt_url
"1","2026-10-23T10:43:42+00:00","rule","In\u00eas Duarte","","0.00","","7584b4e35c5c1a446aa9d767551d95cb24b16027413171b42232365d0b5eb5c8","https://stellar.expert/explorer/testnet/tx/7584b4e35c5c1a446aa9d767551d95cb24b16027413171b42232365d0b5eb5c8"
"3","2026-10-23T10:43:52+00:00","received","Sample Sponsor Fund","GCIYSSZKKEVLAMKOLZVTMQE3BUICKVIEGEVOZ2GP6NUT64ZSZRK67P2V","1,700.00","","226415176cc35e90ce8b642de43f7d2a43d5295f61fa7d4d78705de8cd3bc494","https://stellar.expert/explorer/testnet/tx/226415176cc35e90ce8b642de43f7d2a43d5295f61fa7d4d78705de8cd3bc494"
"4","2026-10-23T10:43:52+00:00","attested","In\u00eas Duarte","","680.00","","",""
First 4 of 15 rows.

List the testnet accounts

Public
GET/api/accounts

Public keys only: the agent and organiser co-signers, both fund vaults, the demo USD issuer, the sponsor, and each sample winner with the state of their wallet.

{
  "agent": {
    "label": "Ahadi agent (co-signer)",
    "public": "GBW2F6KIYLAEP5GWNRCO56WJAU7J4PIHEONTP7Q3ENYOTTS7BFXH7HYT"
  },
  "organiser": {
    "label": "Organiser (co-signer)",
    "public": "GC7TQYC4SYO3LDNDNBGUZ7FUEQUZFD4SDLNQ3BDXTF6FTORWY5PQI765"
  },
  "fund_e1": {
    "label": "October fund (2 of 2 vault)",
    "public": "GBG73NPGVW2V3ZULS657ZIOIYBKNZVDTTFUEIS2UQKWUX62KGGLAJPFC"
  },
  "fund_e2": {
    "label": "December fund (2 of 2 vault)",
    "public": "GBURXP4QKAKIVJJVQYGUIP2CW3AMZGRVOVLA4ORIKLW7BTNFWTF7SSV2"
  },
  "issuer": {
    "label": "Demo USD issuer",
    "public": "GCUKOGKXAAVFFEAEPNWNQ6WHMQ5XJUNPELDY6SDB4DL6BC34XPAF72SZ"
  },
  "sponsor": {
    "label": "Sponsor",
    "public": "GCIYSSZKKEVLAMKOLZVTMQE3BUICKVIEGEVOZ2GP6NUT64ZSZRK67P2V"
  },
  "winners": [
    {
      "place": 1,
      "team": "Team Mvua",
      "person": "Wanjiku Kamau",
      "city": "Nairobi",
      "public": "GATT7DEYRM2GYSCKBUY4U22GKMS2I7EKWK3F4R356SS2CTXDNWQF4PHR",
      "wallet_state": "ready"
    },
    {
      "place": 2,
      "team": "Team Alfama",
      "person": "Tomás Reis",
      "city": "Lisbon",
      "public": "GAP2CQG4K7W6QOAWVMCMXYT3THLMMMZWLS3EJT5Z74VKSVVY7T6KZBHV",
      "wallet_state": "ready"
    }
  ]
}
Trimmed: winners shows 2 of 6.

Receipts and verification

Every money line carries a Stellar transaction hash. The memo on that transaction is the request's fingerprint, so anyone can match the chain to the ledger.

Get one ledger line

Public
GET/api/lines/{line_id}

A line with everything needed to check it: the proposal and the rule decision behind it, the related line (for example the failure a held prize replaced), and the derivation for its event.

Parameters

line_idpath, string, required
Ledger line id, for example l_64f1409ef6.
{
  "id": "l_64f1409ef6",
  "seq": 6,
  "event_id": "october-2026",
  "kind": "paid",
  "amount_cents": 17000,
  "party": "Team Mvua",
  "party_public": "GATT7DEYRM2GYSCKBUY4U22GKMS2I7EKWK3F4R356SS2CTXDNWQF4PHR",
  "request_id": "d720ddf847393d19620c790ee822c5be93afb4abf37ae032549f974723516494",
  "tx_hash": "ab4e8a838e9991733eb4610182bb68707d13c964121e0204b1571a4753f55a76",
  "fee_stroops": 100,
  "proposal_id": "p_f90ec389dc",
  "related_line": null,
  "detail": {
    "place": 1,
    "quote": null
  },
  "created_at": "2026-10-23T10:44:02+00:00",
  "receipt_url": "https://stellar.expert/explorer/testnet/tx/ab4e8a838e9991733eb4610182bb68707d13c964121e0204b1571a4753f55a76",
  "horizon_url": "https://horizon-testnet.stellar.org/transactions/ab4e8a838e9991733eb4610182bb68707d13c964121e0204b1571a4753f55a76",
  "proposal": {
    "id": "p_f90ec389dc",
    "event_id": "october-2026",
    "kind": "payout",
    "request": {
      "event_id": "october-2026",
      "rule_hash": "4ea80d7c415bf35a0a9e4baa8647e15351bb85e56dece2cbf9273aec20bd9333",
      "place": 1,
      "team": "Team Mvua",
      "recipient": "GATT7DEYRM2GYSCKBUY4U22GKMS2I7EKWK3F4R356SS2CTXDNWQF4PHR",
      "asset": "USDB",
      "amount_cents": 17000
    },
    "request_id": "d720ddf847393d19620c790ee822c5be93afb4abf37ae032549f974723516494",
    "decision": {
      "allowed": true,
      "route": "direct",
      "expected_cents": 17000,
      "request_id": "d720ddf847393d19620c790ee822c5be93afb4abf37ae032549f974723516494",
      "checks": [
        {
          "name": "rule-published",
          "passed": true,
          "reason": "the rule is recorded on chain"
        },
        {
          "name": "rule-version",
          "passed": true,
          "reason": "request matches the published rule"
        }
      ],
      "decided_at": "2026-10-23T10:43:52+00:00"
    },
    "xdr": "AAAAAgAAAABN/bXmrbVd5ouXu/yhyMBU3NRzmWhES1SCrUv7…",
    "tx_hash": "ab4e8a838e9991733eb4610182bb68707d13c964121e0204b1571a4753f55a76",
    "status": "settled",
    "result": {
      "tx_hash": "ab4e8a838e9991733eb4610182bb68707d13c964121e0204b1571a4753f55a76",
      "ledger": 5345731,
      "fee": 100
    },
    "extra": {
      "source": "agent",
      "message": null,
      "derivation_step": "place-1",
      "metta": null
    },
    "parent_id": null,
    "created_at": "2026-10-23T10:43:52+00:00",
    "updated_at": "2026-10-23T10:44:02+00:00"
  },
  "derivation": {
    "eligible": 20,
    "release_bp": 4000,
    "award_cents": 68000,
    "reserve_cents": 102000,
    "place_cents": [
      17000,
      13600,
      10880,
      9520,
      8840,
      8160
    ],
    "steps": [
      {
        "rule": "release",
        "text": "Release = min(100%, 20 eligible / 50 target) = 40.00%",
        "value": 4000
      },
      {
        "rule": "award-pool",
        "text": "Award pool = USD 1,700.00 x 40.00% = USD 680.00",
        "value": 68000
      }
    ]
  },
  "event_name": "October Builders Hackathon"
}
Trimmed: proposal.decision.checks shows 2 of 9, derivation.steps 2 of 9; the signed XDR is shortened.

Verify a transaction

Public
GET/api/verify/{tx_hash}

Looks the hash up on Horizon, finds the matching ledger line and checks two things: the memo equals the request fingerprint, and both the agent and the organiser signed. A hash that is not on testnet returns found_on_chain false.

Parameters

tx_hashpath, string, required
A Stellar transaction hash: 64 hexadecimal characters.
{
  "found_on_chain": true,
  "tx": {
    "hash": "ab4e8a838e9991733eb4610182bb68707d13c964121e0204b1571a4753f55a76",
    "ledger": 5345731,
    "created_at": "2026-10-23T10:44:02Z",
    "source_account": "GBG73NPGVW2V3ZULS657ZIOIYBKNZVDTTFUEIS2UQKWUX62KGGLAJPFC",
    "fee_charged": "100",
    "memo_type": "hash",
    "successful": true,
    "operation_count": 1,
    "memo_hex": "d720ddf847393d19620c790ee822c5be93afb4abf37ae032549f974723516494"
  },
  "signers": [
    "GBW2F6KIYLAEP5GWNRCO56WJAU7J4PIHEONTP7Q3ENYOTTS7BFXH7HYT",
    "GC7TQYC4SYO3LDNDNBGUZ7FUEQUZFD4SDLNQ3BDXTF6FTORWY5PQI765"
  ],
  "line": {
    "id": "l_64f1409ef6",
    "seq": 6,
    "event_id": "october-2026",
    "kind": "paid",
    "amount_cents": 17000,
    "party": "Team Mvua",
    "party_public": "GATT7DEYRM2GYSCKBUY4U22GKMS2I7EKWK3F4R356SS2CTXDNWQF4PHR",
    "request_id": "d720ddf847393d19620c790ee822c5be93afb4abf37ae032549f974723516494",
    "tx_hash": "ab4e8a838e9991733eb4610182bb68707d13c964121e0204b1571a4753f55a76",
    "fee_stroops": 100,
    "proposal_id": "p_f90ec389dc",
    "related_line": null,
    "detail": {
      "place": 1,
      "quote": null
    },
    "created_at": "2026-10-23T10:44:02+00:00",
    "receipt_url": "https://stellar.expert/explorer/testnet/tx/ab4e8a838e9991733eb4610182bb68707d13c964121e0204b1571a4753f55a76",
    "horizon_url": "https://horizon-testnet.stellar.org/transactions/ab4e8a838e9991733eb4610182bb68707d13c964121e0204b1571a4753f55a76"
  },
  "checks": [
    {
      "name": "memo",
      "passed": true,
      "text": "The memo on chain equals the request fingerprint in the ledger"
    },
    {
      "name": "two-signatures",
      "passed": true,
      "text": "Signed by both the agent and the organiser"
    }
  ],
  "receipt_url": "https://stellar.expert/explorer/testnet/tx/ab4e8a838e9991733eb4610182bb68707d13c964121e0204b1571a4753f55a76"
}

Proposals and approval

Ahadi never pays on its own. It creates a proposal; the organiser prepares it, signs the transaction in their browser, and approves.
  1. 1Ahadi
    Proposes

    A skill call creates a proposal only if MeTTa and the bridge both allow it.

  2. 2Agent key
    prepare

    The bridge re-checks the rule, builds the transaction and signs it with the agent's key.

  3. 3Organiser key
    Signs in the browser

    The organiser's key adds the second signature locally. It never reaches a server.

  4. 4Bridge
    approve

    Same transaction, both signatures: the signed XDR is stored, then submitted to Stellar.

List proposals

Public
GET/api/proposals

Proposals for rule recording, payouts, held prizes and carry forward, with the rule decision and its nine checks.

Parameters

event_idquery, string
Only this event's proposals.
statusquery, string
proposed, submitted, settled, failed, expired or rejected.
[
  {
    "id": "p_cf44a13bbc",
    "event_id": "october-2026",
    "kind": "rule",
    "request": {
      "event_id": "october-2026",
      "name": "October Builders Hackathon",
      "asset": "USDB",
      "pool_cents": 170000,
      "target_teams": 50,
      "release": "eligible_over_target_capped_at_100",
      "place_shares_bp": [
        2500,
        2000,
        1600,
        1400,
        1300,
        1200
      ],
      "rounding": "floor_cent_remainder_to_reserve",
      "claim_window_days": 30,
      "carry_forward": "reserve_to_next_event",
      "version": 1
    },
    "request_id": "rule:4ea80d7c415bf35a0a9e4baa8647e15351bb85e56dece2cbf9273aec20bd9333",
    "decision": {
      "allowed": true,
      "route": "manage-data",
      "checks": [
        {
          "name": "rule-valid",
          "passed": true,
          "reason": "shares add to 100%, places are ordered, pool and target are positive"
        }
      ]
    },
    "xdr": "AAAAAgAAAABN/bXmrbVd5ouXu/yhyMBU3NRzmWhES1SCrUv7…",
    "tx_hash": "7584b4e35c5c1a446aa9d767551d95cb24b16027413171b42232365d0b5eb5c8",
    "status": "settled",
    "result": {
      "tx_hash": "7584b4e35c5c1a446aa9d767551d95cb24b16027413171b42232365d0b5eb5c8",
      "ledger": 5345727,
      "fee": 100
    },
    "extra": {
      "source": "agent",
      "rule_hash": "4ea80d7c415bf35a0a9e4baa8647e15351bb85e56dece2cbf9273aec20bd9333",
      "summary": "Record the October Builders Hackathon rule on chain"
    },
    "parent_id": null,
    "created_at": "2026-10-23T10:43:39+00:00",
    "updated_at": "2026-10-23T10:43:42+00:00"
  }
]
Trimmed: 1 of 9 proposals; decision.checks shows 2 of 9; the signed XDR is shortened.

Get a proposal

Public
GET/api/proposals/{pid}

One proposal. A failed payout keeps the network's result codes and the hash it attempted.

Parameters

pidpath, string, required
Proposal id, for example p_f90ec389dc.
{
  "id": "p_f6fbc8299a",
  "event_id": "october-2026",
  "kind": "payout",
  "request": {
    "event_id": "october-2026",
    "rule_hash": "4ea80d7c415bf35a0a9e4baa8647e15351bb85e56dece2cbf9273aec20bd9333",
    "place": 3,
    "team": "Team Kaveri",
    "recipient": "GCH5NYHINHSR7HQVY5QYHY7WUL4BBDQ5PU3U5WQHAPW2Q3O6FVCLLNSJ",
    "asset": "USDB",
    "amount_cents": 10880
  },
  "request_id": "bcdeda45beaaff60b76834eb3bb1c28fd2fe4b84463a79f451e5e6a2136a25eb",
  "decision": {
    "allowed": true,
    "route": "claimable",
    "expected_cents": 10880,
    "request_id": "bcdeda45beaaff60b76834eb3bb1c28fd2fe4b84463a79f451e5e6a2136a25eb",
    "checks": [
      {
        "name": "rule-published",
        "passed": true,
        "reason": "the rule is recorded on chain"
      },
      {
        "name": "rule-version",
        "passed": true,
        "reason": "request matches the published rule"
      }
    ],
    "decided_at": "2026-10-23T10:43:53+00:00"
  },
  "xdr": "AAAAAgAAAABN/bXmrbVd5ouXu/yhyMBU3NRzmWhES1SCrUv7…",
  "tx_hash": "ac55b0bfb1aadc41e175a1791b44d9f42cae686adcd2187d51b80435ab937d80",
  "status": "failed",
  "result": {
    "code": "op_no_destination",
    "tx_codes": "tx_failed",
    "op_codes": [
      "op_no_destination"
    ],
    "attempted_tx": "ac55b0bfb1aadc41e175a1791b44d9f42cae686adcd2187d51b80435ab937d80"
  },
  "extra": {
    "source": "agent",
    "message": null,
    "derivation_step": "place-3",
    "metta": null,
    "forced_direct": true,
    "warning": "Pre-flight found this wallet cannot receive yet; the organiser asked to try a direct payment anyway."
  },
  "parent_id": null,
  "created_at": "2026-10-23T10:43:53+00:00",
  "updated_at": "2026-10-23T10:44:27+00:00"
}
The failed payout to Team Kaveri. Trimmed: decision.checks shows 2 of 9; the signed XDR is shortened.

Get the organiser's public key

Public
GET/api/organiser-key/public

The browser checks that the key the organiser imports matches this one before it signs anything.

{
  "public": "GC7TQYC4SYO3LDNDNBGUZ7FUEQUZFD4SDLNQ3BDXTF6FTORWY5PQI765"
}

Prepare: the agent signs

Console passcode
POST/api/proposals/{pid}/prepare

Builds a fresh transaction for the proposal, re-runs the rule against current facts, and signs it with the agent's key. Returns the XDR for the organiser to sign. Transactions are built at this step, not when the proposal was made, so a waiting proposal never expires or collides on the fund's sequence number.

Parameters

pidpath, string, required
Proposal id, for example p_f90ec389dc.
x-ahadi-passcodeheader, string, required
The console passcode. Required on every write.
{
  "proposal_id": "p_f90ec389dc",
  "xdr": "AAAAAgAAAABN/bXmrbVd5ouXu/yhyMBU3NRzmWhES1SC…",
  "tx_hash": "ab4e8a838e9991733eb4610182bb68707d13c964121e0204b1571a4753f55a76",
  "network_passphrase": "Test SDF Network ; September 2015",
  "signed_by": ["agent"],
  "needs": ["organiser"],
  "ev": "october-2026"
}
Response shape from the bridge, with values from the settled October payout to Team Mvua. The XDR is shortened.

Approve: submit both signatures

Console passcode
POST/api/proposals/{pid}/approve

Send the XDR after the organiser has added their signature in the browser. The bridge checks it is the same transaction the agent prepared (by hash), that both signatures are present, stores the signed XDR, then submits it to Stellar and writes the ledger line.

Parameters

pidpath, string, required
Proposal id, for example p_f90ec389dc.
x-ahadi-passcodeheader, string, required
The console passcode. Required on every write.
signed_xdrbody, string, required
The prepared XDR with the organiser's signature added.
{
  "ok": true,
  "tx_hash": "ab4e8a838e9991733eb4610182bb68707d13c964121e0204b1571a4753f55a76",
  "receipt_url": "https://stellar.expert/explorer/testnet/tx/ab4e8a838e9991733eb4610182bb68707d13c964121e0204b1571a4753f55a76",
  "line": { "id": "l_64f1409ef6", "seq": 6, "kind": "paid", "amount_cents": 17000, "party": "Team Mvua" },
  "proposal": { "id": "p_f90ec389dc", "status": "settled" }
}
Response shape from the bridge, with values from the October run; line and proposal are shortened. A network failure returns ok false with code, text and next.

Reject a proposal

Console passcode
POST/api/proposals/{pid}/reject

The organiser declines. The refusal and its reason go on the ledger.

Parameters

pidpath, string, required
Proposal id, for example p_f90ec389dc.
x-ahadi-passcodeheader, string, required
The console passcode. Required on every write.
reasonbody, string
Up to 300 characters. Defaults to “Rejected by the organiser”.
{"error":"bad_state","detail":"proposal is settled"}
Captured from the bridge: a settled proposal cannot be rejected (HTTP 409).

Try with the agent's key alone

Console passcode
POST/api/proposals/{pid}/try-alone

The control demonstration. Prepares the proposal and submits it with only the agent's signature. Stellar refuses with tx_bad_auth, no fee is charged, and a control line is recorded.

Parameters

pidpath, string, required
Proposal id, for example p_f90ec389dc.
x-ahadi-passcodeheader, string, required
The console passcode. Required on every write.
{
  "ok": false,
  "code": "tx_bad_auth",
  "text": "Only one of the two required signatures was present.",
  "attempted_tx": "a44f0ef14fc3131de8c7b3629eeb2b9d21497ea4a638fee06c21afbc6761b58e",
  "line": { "id": "l_7a9846b309", "seq": 5, "kind": "control" }
}
Values from the single key line of the October run captured on 23 October; line is shortened.

Reroute a failed payout

Console passcode
POST/api/proposals/{pid}/reroute

After op_no_destination, op_no_trust or op_not_authorized, proposes holding the prize as a claimable balance for 30 days. Any other failure is refused.

Parameters

pidpath, string, required
Proposal id, for example p_f90ec389dc.
x-ahadi-passcodeheader, string, required
The console passcode. Required on every write.
{"error":"bad_state","detail":"only a failed payout can be rerouted"}
Captured from the bridge for a settled payout (HTTP 409). On success it returns the new claimable proposal.

Fund setup

Organiser actions that set an event up and close it. All need the console passcode, and every money movement still needs both signatures.

Propose recording the rule

Console passcode
POST/api/funds/{event_id}/rule

Creates a proposal to write the rule's SHA-256 hash to the fund with manageData. Do this before registration opens.

Parameters

event_idpath, string, required
Event id: october-2026 or december-2026.
x-ahadi-passcodeheader, string, required
The console passcode. Required on every write.
{"error":"duplicate","detail":"the rule for this event is already recorded on chain"}
Captured from the bridge: October's rule is already on chain (HTTP 400). On success it returns the new proposal.

Record the sponsor's deposit

Console passcode
POST/api/funds/{event_id}/deposit

The sample sponsor pays the prize pool into the fund. The sponsor signs alone because it is the sponsor's money. Recorded once per event.

Parameters

event_idpath, string, required
Event id: october-2026 or december-2026.
x-ahadi-passcodeheader, string, required
The console passcode. Required on every write.
{"error":"duplicate","detail":"this event's sponsor deposit is already recorded"}
Captured from the bridge (HTTP 400). On success it returns the received line.

Attest eligible teams

Console passcode
POST/api/funds/{event_id}/attest

The organiser states how many teams qualified. This fixes the release percentage, the award pool and the reserve. Allowed once, and only after the rule is on chain.

Parameters

event_idpath, string, required
Event id: october-2026 or december-2026.
x-ahadi-passcodeheader, string, required
The console passcode. Required on every write.
eligiblebody, integer, required
Between 0 and 10,000.
{"error":"duplicate","detail":"eligibility for this event is already attested"}
Captured from the bridge (HTTP 400). On success it returns eligible, the derivation and the attested line.

Propose carrying the reserve forward

Console passcode
POST/api/funds/{event_id}/carry/{to_event}

After every payout is settled, held or rejected, proposes moving what is left to the next event's fund. The memo is the first event's rule hash.

Parameters

event_idpath, string, required
Event id: october-2026 or december-2026.
to_eventpath, string, required
The receiving event, for example december-2026.
x-ahadi-passcodeheader, string, required
The console passcode. Required on every write.
{"error":"duplicate","detail":"this event's reserve was already carried forward"}
Captured from the bridge (HTTP 400). On success it returns the carry proposal.

Agent and activity

Ahadi is a SingularityNET Omega agent. These endpoints show its state, what it has done, and what it remembers, and let the organiser talk to it.

Health

Public
GET/api/health

The network, the demo asset and its issuer, whether Omega is connected on the websocket channel, its provider, model, loop cycles and token counts, the skills it loaded, and whether the Telegram relay is on.

{
  "ok": true,
  "network": "stellar-testnet",
  "asset": "USDB",
  "issuer": "GCUKOGKXAAVFFEAEPNWNQ6WHMQ5XJUNPELDY6SDB4DL6BC34XPAF72SZ",
  "accounts_created": "2026-10-23T10:43:37+00:00",
  "omega": {
    "connected": false,
    "connected_at": null,
    "log_present": true,
    "last_cycle": 363,
    "provider": "OpenAIAPI",
    "model": "fake",
    "version": "(REQUEST: LLMRequest[messages=[LLMMessage[role='system',content='PROMPT: You are Ahadi, an Omega agent in a continuous loop. You coordinate a hackathon prize fund for an organiser: you apply the publi",
    "cycles": 363,
    "tokens_in": 20,
    "tokens_out": 20,
    "skills": [
      "ahadi-carry-forward",
      "ahadi-derive",
      "ahadi-propose",
      "ahadi-publish-rule",
      "ahadi-quote",
      "ahadi-request",
      "ahadi-reroute",
      "ahadi-status",
      "ahadi-try-alone",
      "ahadi-verify",
      "workflow-load-instructions",
      "workflow-refresh-list",
      "workflow-unload-instructions"
    ]
  },
  "telegram": {
    "enabled": false,
    "bot": null
  }
}
Captured during a development run that used Omega's OpenAI compatible provider with a scripted stand in model, which is why model reads fake.

Send the agent a message

Console passcode
POST/api/chat

Delivers a message to Omega as a user_message on its websocket channel. If Omega is not connected, the message is queued and delivered on reconnect. Replies arrive on the stream as agent_message.

Parameters

x-ahadi-passcodeheader, string, required
The console passcode. Required on every write.
textbody, string, required
1 to 2,000 characters.
sourcebody, string
console (default) or telegram.
authorbody, string
Shown to the agent as [telegram @author].
{"delivered": true, "seq": 4}
Response shape from the bridge. When Omega is offline: {"delivered": false, "seq": 4, "queued": true}.

Recent activity

Public
GET/api/activity

The last 2,000 events the bridge has seen, the same events the stream sends. Use after to page from an event id.

Parameters

afterquery, integer
Only events with a larger id. Defaults to 0.
[
  {
    "id": 82,
    "kind": "omega_log",
    "at": "2026-10-23T10:44:41.957+00:00",
    "ts": "2026-10-23 10:37:18",
    "level": "INFO",
    "module": "loop",
    "category": "decision",
    "calls": [
      {
        "name": "ahadi-status",
        "arguments": "{'event_id': 'october-2026'}"
      }
    ]
  },
  {
    "id": 83,
    "kind": "omega_log",
    "at": "2026-10-23T10:44:41.957+00:00",
    "ts": "2026-10-23 10:37:19",
    "level": "INFO",
    "module": "loop",
    "category": "result",
    "skill": "ahadi-status",
    "ok": true,
    "text": "['October Builders Hackathon (october-2026), status paying.\\nRule hash 4ea80d7c415bf35a; recorded tx f9e8d208c4fbfc05.\\nEligible teams attested: 20.\\nReceived USD 1,700.00, paid USD 0.00, held USD 0.00, carried out USD 0.00, available USD 1,700.00; chain balance USD 1,700.00; balanced True.\\nOpen proposals waiting for the organiser: 0.\\nRecent ledger: rule Inês Duarte USD 0.00 tx f9e8d208c4; received Sample Sponsor Fund USD 1,700.00 tx 745e94122f; attested Inês Duarte USD 680.00']"
  },
  {
    "id": 88,
    "kind": "omega_log",
    "at": "2026-10-23T10:44:41.957+00:00",
    "ts": "2026-10-23 10:37:20",
    "level": "INFO",
    "module": "loop",
    "category": "result",
    "skill": "ahadi-derive",
    "ok": true,
    "text": "[\\\"MeTTa derivation for october-2026 (matches the bridge's check):\\nRelease 40.00% of USD 1,700.00 (20 eligible of 50 target).\\nAward pool USD 680.00; reserve USD 1,020.00 carried to the next event.\\nPlace 1: USD 170.00\\nPlace 2: USD 136.00\\nPlace 3: USD 108.80\\nPlace 4: USD 95.20\\nPlace 5: USD 88.40\\nPlace 6: USD 81.60\\\"]"
  }
]
Three of 461 events.

What the agent remembers

Public
GET/api/agent/memory

Omega's long term memories (read only from its ChromaDB store) and the tail of its episodic history, history.metta.

{
  "long_term": [
    {
      "id": 1,
      "text": "PROPOSED october-2026 places 1-6 for organiser approval",
      "time": "2026-10-23 10:37:30"
    }
  ],
  "history_tail": "…(\"2026-10-23 10:37:19\" \n \"HUMAN_MESSAGE: \" The October fund cutoff passed. Please derive and propose the payouts for all six places. \n ((ahadi-status \"october-2026\")) \n)\n(\"2026-10-23 10:37:20\" \n ((ahadi-derive \"october-2026\")) \n)\n(\"2026-10-23 10:37:28\" \n ((ahadi-propose \"october-2026\" \"1\") (ahadi-propose \"october-2026\" \"2\") (aha…"
}
history_tail is an excerpt of the episodic history around the organiser's request.

Live stream

One connection gives you every ledger line, proposal, signature, rule decision and Omega log event the moment it happens.

Subscribe to everything

Public
SSE/api/stream

Server sent events. On connect the bridge replays recent events after the given id, then sends each new one as it happens. A ping event arrives every 15 seconds when nothing else does. Each data payload is JSON with id, kind and at.

Parameters

afterquery, integer
Replay events with a larger id. Use the last id you saw to resume.
id: 460
event: message
data: {"id": 460, "kind": "omega_log", "at": "2026-10-23T10:44:41.958+00:00", "ts": "2026-10-23 10:43:05", "level": "INFO", "module": "loop", "category": "cycle", "n": 362}

id: 461
event: message
data: {"id": 461, "kind": "omega_log", "at": "2026-10-23T10:44:41.958+00:00", "ts": "2026-10-23 10:43:06", "level": "INFO", "module": "loop", "category": "cycle", "n": 363}

Event kinds

Every event has id, kind and at (UTC, ISO 8601). The other fields depend on the kind.

KindFieldsWhen it fires
ledgerevent_id, lineA line was added to a ledger: paid, held, failed, rejected, duplicate and the rest.
proposalevent_id, proposalAhadi or the console created a proposal.
signatureproposal_id, signer, tx_hashA key signed: signer is agent at prepare, organiser at approve.
decisionevent_id, request, metta, bridge, agree, source, messageThe MeTTa decision for a request, the bridge's own decision, and whether they agree. Nothing is proposed unless both allow it.
derivationevent_id, source, eligible, award_cents, reserve_cents, places, agrees_with_bridge, stepsAmounts Omega derived in MeTTa, checked against the Python mirror.
factsevent_id, request, flags, sexprThe facts the bridge handed to MeTTa, as the ahadi-facts expression.
tooltool, args, summary or resultA skill call or a simulated NEAR dry quote.
omega_logts, level, module, category, ...Omega's own log, classified. category is cycle (n), decision (calls), result (skill, ok, text), usage (provider, model, tokens) or skill (a skill was loaded).
agent_messagetextOmega sent a message on its channel.
user_messagetext, source, author, seqA message was delivered to Omega from the console or Telegram.
telegram_inauthor, textA Telegram message reached the bridge's relay.
telegram_outtextThe relay posted Omega's reply to Telegram.
omegastatusOmega's websocket connected or disconnected.

Omega skill tools

The Omega agent reaches the world only through these endpoints. Its Python glue calls them with MeTTa's inputs and outputs. They are internal: not proxied by this site, and refused without the shared secret. They are documented here so you can see exactly what the agent can and cannot do.
EndpointCalled byBodyReturns
POST/tools/statusahadi-statusevent_idtext
POST/tools/publish-ruleahadi-publish-ruleevent_idtext
POST/tools/paramsahadi-deriveevent_idsexpr (ahadi-params …)
POST/tools/derivationahadi-deriveevent_id, eligible, award_cents, reserve_cents, placestext
POST/tools/factsahadi-propose, ahadi-requestevent_id, place or team, amount_usd, walletsexpr (ahadi-facts …)
POST/tools/proposeahadi-proposeevent_id, place, metta_decisiontext
POST/tools/requestahadi-requestevent_id, team, amount_usd, wallet, message, metta_decisiontext
POST/tools/try-aloneahadi-try-aloneproposal_idtext
POST/tools/failure-codeahadi-rerouteproposal_idsexpr (result code)
POST/tools/rerouteahadi-rerouteproposal_id, metta_routetext
POST/tools/carry-forwardahadi-carry-forwardfrom_event, to_eventtext
POST/tools/quoteahadi-quoteamount_usd, to_chaintext
POST/tools/verifyahadi-verifytx_hashtext
{"sexpr":"(ahadi-facts 1 1 1 1 13600 13600 1 0 1 0 1 1 0)"}
The facts for place 2 after it was paid: the ninth value, seen, is 1, so ahadi-decide refuses a second payout.

The Omega skills

Ahadi adds ten skills to Omega and removes the ones a payout agent should not have (shell, file writing, web search). The language model chooses which skill to call; the skill's MeTTa decides.
  • ahadi-status
    event_id

    Shows an event's fund: the published rule, eligibility, totals, recent ledger lines and open proposals. The agent calls it first for any fund question.

  • ahadi-publish-rule
    event_id

    Proposes recording the rule's SHA-256 hash on Stellar testnet. The organiser must approve.

  • ahadi-derive
    event_id

    Derives the award pool, the reserve and every place's amount in MeTTa from the rule's parameters, then reports them to the bridge, which checks them against its Python mirror.

  • ahadi-propose
    event_id place

    Fetches the facts for one place, runs ahadi-decide in MeTTa, and only if the rule allows it creates a proposal for the organiser.

  • ahadi-request
    event_id team amount_usd wallet message

    Checks a request that arrived in a message. Proposes only if the rule allows; otherwise records a duplicate or a refusal with reasons.

  • ahadi-try-alone
    proposal_id

    Submits a prepared proposal with only the agent's signature to show the two key control. Stellar must answer tx_bad_auth.

  • ahadi-reroute
    proposal_id

    Reads a failed payout's result code, asks MeTTa for the next step (ahadi-next-step), and proposes a 30 day held prize when the answer is claimable.

  • ahadi-carry-forward
    from_event to_event

    Proposes moving the remaining reserve to the next event's fund. Only after every payout is settled.

  • ahadi-quote
    amount_usd to_chain

    Gets a simulated NEAR Intents dry quote for paying a prize on another chain. No funds move.

  • ahadi-verify
    tx_hash

    Checks a Stellar testnet transaction against the ledger: the memo fingerprint and both signatures.

The money gate, in MeTTa

Every payout request becomes a fact expression and goes through ahadi-decide. Nine checks must all pass. Only then is a route chosen. The bridge runs a Python mirror of the same rule and proposes nothing unless both agree. How the agreement check works

omega/plugins/ahadi/ahadi.metta
; facts = (ahadi-facts published rule-match attested place-ok expected requested
;                      asset-ok budget-left seen place-paid active trusts other-chain)
(= (ahadi-decide (ahadi-facts $pub $match $att $pok $expected $requested $aok
                              $left $seen $paid $active $trusts $other))
   (let $checks ((ahadi-check rule-published $pub)
                 (ahadi-check rule-version $match)
                 (ahadi-check eligibility-attested $att)
                 (ahadi-check place-exists $pok)
                 (ahadi-check-amount $requested $expected)
                 (ahadi-check asset $aok)
                 (ahadi-check-budget $requested $left)
                 (ahadi-check not-duplicate $seen)
                 (ahadi-check place-unpaid $paid))
     (if (ahadi-all-passed $checks)
         (ahadi-decision allowed (ahadi-route $active $trusts $other) $checks)
         (ahadi-decision refused none $checks))))

; A wallet that cannot receive yet gets the prize held (claimable balance), never lost.
(= (ahadi-route $active $trusts $other-chain)
   (if (== (* $active $trusts) 0)
       claimable
       (if (== $other-chain 1) cross-chain-sim direct)))

Integrate in an afternoon

Two small pieces cover most integrations: show the ledger live, and check a receipt.

Show ledger lines as they happen

Subscribe once, keep the last id you saw, and resume from it after a reload. The bridge replays anything you missed.

ledger-feed.ts
// Render ledger lines as they happen, with no polling.
type LedgerLine = { seq: number; kind: string; party: string | null; amount_cents: number; tx_hash: string | null }

const usd = (c: number) => `USD ${(c / 100).toFixed(2)}`
let lastId = Number(localStorage.getItem("ahadi.lastId") ?? 0)

const es = new EventSource(`/api/stream?after=${lastId}`)

es.onmessage = (msg) => {
  const ev = JSON.parse(msg.data)
  lastId = ev.id
  localStorage.setItem("ahadi.lastId", String(lastId))  // resume here after a reload

  if (ev.kind !== "ledger") return
  const line: LedgerLine = ev.line
  render(`#${line.seq} ${line.kind} ${line.party ?? ""} ${usd(line.amount_cents)}`)
}

function render(text: string) {
  const li = document.createElement("li")
  li.textContent = text
  document.querySelector("#ledger")!.prepend(li)
}

Verify a receipt

A receipt is good when the transaction is on testnet, its memo equals the request fingerprint in the ledger, and both keys signed it.

verify-receipt.ts
// Check a receipt: is it on testnet, and does the chain match the ledger?
type Verify = {
  found_on_chain: boolean
  tx?: { hash: string; ledger: number; memo_hex: string | null; successful: boolean }
  signers?: string[]
  checks?: { name: "memo" | "two-signatures"; passed: boolean; text: string }[]
}

async function verifyReceipt(txHash: string): Promise<boolean> {
  const res = await fetch(`/api/verify/${txHash}`)
  if (!res.ok) throw new Error((await res.json()).detail)  // 400 if not 64 hex characters

  const v: Verify = await res.json()
  if (!v.found_on_chain) return false
  return (v.checks ?? []).length > 0 && v.checks!.every((c) => c.passed)
}

await verifyReceipt("ab4e8a838e9991733eb4610182bb68707d13c964121e0204b1571a4753f55a76") // true
Prefer to look by hand?
Paste any transaction hash into Verify a receipt, or open it on stellar.expert. The same checks run in both places.