> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hashlock.markets/llms.txt
> Use this file to discover all available pages before exploring further.

# Mint an API key with a wallet signature — no browser, no key needed (agents start here)

> Sign, with the wallet, a message that says `create an API key`, names the address and carries a nonce from `GET /v1/keys/nonce`. Optional lines `Key name: <name>` and `Scopes: read, taker` (default all three) decide the key — they are read from the signed text only. The key belongs to the account that wallet SIGNS IN to (created on first use); a wallet proved onto another account does not reach it. An account with only a wallet holds ONE live key: a new signed mint replaces the previous one (also the way to recover a lost or leaked key). An account signed up at /developers with email, Google or Telegram holds up to 10. All keys of an account share one rate budget. It expires after 90 days (`expiresAt`; mint a new one to renew), an account holds at most 10 live keys, the owner is told (in Telegram, when linked) of every new one in Telegram, and the plaintext key is returned once. Signing rules per rail are those of POST /v1/wallets/<chain>.



## OpenAPI

````yaml /api-reference/openapi.json post /v1/keys
openapi: 3.1.0
info:
  title: Hashlock Markets API
  version: 1.0.0
  description: >-
    Guides, concepts and a quickstart: https://docs.hashlock.markets


    Non-custodial cross-chain atomic swaps (BTC ↔ EVM / TRON / Solana) via
    sealed RFQ + HTLC. Makers and takers share these endpoints. Authenticate
    with an API key: `Authorization: Bearer hk_...`.


    **Wallets first.** An order that GIVES an asset requires a proven wallet on
    that asset's chain, and your key starts with only the address its account
    signed in with. `GET /v1/me` lists both what each chain's `login` address is
    and what you have `proven`; `POST /v1/wallets/{evm|tron|solana|bitcoin}`
    proves another by signature — Bitcoin included, on a BIP-322 one. A proof
    adds trading reach and nothing else: it never becomes the account's payout
    identity, which only a wallet session can change — so a key alone cannot
    redirect anyone's money. The message you sign for it must say `link this
    wallet` and carry a nonce from `GET /v1/wallets/nonce`; that nonce is not a
    login nonce, so the signature you collect is not a session for that wallet's
    account.


    **Rate limits.** Every response carries `RateLimit-Limit`,
    `RateLimit-Remaining` and `RateLimit-Reset` (seconds). Over the limit
    returns `429` with `Retry-After`.


    **Idempotency.** Send an `Idempotency-Key` header on any POST to make
    retries safe: the operation runs once and the same response is replayed
    (with `Idempotency-Replayed: true`). Reusing a key with a different body
    returns `422`. Keys are remembered for 24h — except a `504`, which is a read
    of ours running out of time: on the builders nothing happened, and on
    `/tx/broadcast` we cannot tell — so either way a retry with the same key
    really retries.


    **Custody-agnostic settlement.** The fund/claim/refund endpoints return
    UNSIGNED transactions; you sign with your own key/HSM and submit via
    `/v1/tx/broadcast`. The server never holds your keys.


    ---


    ## Maker feed (WebSocket)


    Polling `GET /v1/rfqs` will not win you flow. `wss://<host>/v1/ws` — the
    host you selected above — streams every request-for-quote you are eligible
    for, and takes your quotes back over the same socket.


    **Handshake.** Connect, then send your key as the first frame. Nothing else
    is accepted until you do:


    ```json

    { "apiKey": "hk_test_..." }

    ```


    The key needs the `maker` scope (`GET /v1/me` shows what yours has). You get
    a snapshot, then a readiness marker:


    ```json

    { "type": "snapshot", "rfqs": [ ... ] }

    { "type": "ready" }

    ```


    The snapshot is the **public** book only, and at most **100** entries,
    newest first. If you need more than that, page `GET /v1/rfqs?cursor=…` after
    connecting. An RFQ created while the snapshot was being built may appear
    both in it and in the stream that follows — frames carry the rfq id, so key
    on it and the duplicate is a no-op.


    **The stream.** Two kinds, and only two:


    ```json

    { "type": "rfq", "kind": "created",   "rfq": { ... } }

    { "type": "rfq", "kind": "cancelled", "rfq": { ... } }

    ```


    An RFQ also leaves the book when it **expires** or is **agreed** with
    another maker, and neither emits a frame. A book you maintain purely from
    the stream therefore drifts: it keeps orders nobody can take any more, and
    quoting into one is rejected. Reconcile with a fresh `snapshot` (reconnect)
    or check `GET /v1/rfqs/{id}` before committing to a price.


    **What reaches you.** Public RFQs, plus private ones addressed to an address
    on your account. Matching follows each encoding: EVM hex and bech32
    (`bc1…`/`tb1…`) compare case-insensitively, base58check (TRON, legacy
    Bitcoin) does not — there the case of a character is part of the address.
    Never your own orders — neither in the snapshot nor in the stream. Note the
    asymmetry: **private orders arrive only on the live stream.** They are
    link-only by design, so they are absent from the snapshot and from `GET
    /v1/rfqs`, and there is no endpoint that lists the ones addressed to you. A
    private order sent while your socket was down is not recoverable — if you
    trade private flow, stay connected.


    **Quoting.** Send the quote down the same socket; no HTTP round-trip, no
    second authentication. `quoteAmount` is in base units, as everywhere else in
    this API:


    ```json

    { "quote": { "rfqId": "…", "quoteAmount": "1500000000" } }

    → { "type": "quoted", "rfqId": "…", "threadId": "…" }

    ```


    The `threadId` is a negotiation thread: `GET /v1/threads/{id}` and the
    propose/accept endpoints take it from there. `POST /v1/rfqs/{id}/quotes`
    does the same thing over REST if you prefer.


    **Errors** arrive as `{ "type": "error", "error": "…" }` and do not close
    the socket.


    **Operating it.** There is no heartbeat and no resume: detect a dead socket
    yourself and reconnect. The fresh `snapshot` is your recovery for the public
    book, with the two limits above — 100 entries, and no private orders.
    Nothing is replayed, so this socket is not a settlement log. For that
    register a webhook (`POST /v1/webhooks`): the feed tells you what to quote,
    webhooks tell you what happened to your swaps.


    **Webhook signature.** `x-hashlock-signature: sha256=<hex>` is HMAC-SHA256
    over `<timestamp>.<body>` with your webhook secret; the timestamp is in
    `x-hashlock-timestamp`. Verify before trusting a delivery, and reply 2xx.


    **Webhook events.** `quote.created`, `swap.agreed`, `swap.funded`,
    `secret.revealed`, `swap.settled`, `swap.refunded` — subscribe to none and
    you get all of them. `POST /v1/webhooks/{id}/ping` also delivers a `ping`
    event, so handle an unknown `type` by returning 2xx rather than erroring.


    **Webhook delivery is best-effort, not durable.** A non-2xx or a timeout is
    retried after 15s, 30s, 60s, 120s and 240s — **6 attempts over 7m45s** — and
    is then dropped; there is no redelivery endpoint and no delivery log beyond
    `lastStatus` on `GET /v1/webhooks`. If your receiver is down longer than
    that you will lose events, so reconcile against `GET /v1/swaps/{id}` — that
    is the authoritative state — rather than treating the webhook as the record.
servers:
  - url: https://api.hashlock.markets
    description: production
security:
  - ApiKey: []
paths:
  /v1/keys:
    post:
      summary: >-
        Mint an API key with a wallet signature — no browser, no key needed
        (agents start here)
      description: >-
        Sign, with the wallet, a message that says `create an API key`, names
        the address and carries a nonce from `GET /v1/keys/nonce`. Optional
        lines `Key name: <name>` and `Scopes: read, taker` (default all three)
        decide the key — they are read from the signed text only. The key
        belongs to the account that wallet SIGNS IN to (created on first use); a
        wallet proved onto another account does not reach it. An account with
        only a wallet holds ONE live key: a new signed mint replaces the
        previous one (also the way to recover a lost or leaked key). An account
        signed up at /developers with email, Google or Telegram holds up to 10.
        All keys of an account share one rate budget. It expires after 90 days
        (`expiresAt`; mint a new one to renew), an account holds at most 10 live
        keys, the owner is told (in Telegram, when linked) of every new one in
        Telegram, and the plaintext key is returned once. Signing rules per rail
        are those of POST /v1/wallets/<chain>.
      operationId: postKeys
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - rail
                - address
                - message
                - signature
              properties:
                rail:
                  type: string
                  enum:
                    - evm
                    - tron
                    - solana
                    - bitcoin
                address:
                  type: string
                message:
                  type: string
                signature:
                  type: string
            example:
              rail: evm
              address: 0xYourAddress
              message: |-
                Hashlock Markets — create an API key.

                Address: 0xYourAddress
                Key name: my-agent
                Scopes: read, taker, maker
                Nonce: <GET /v1/keys/nonce>
                Issued At: 2026-09-30T12:00:00Z
              signature: 0x…
      responses:
        '200':
          description: the key — shown once
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                  key:
                    type: string
                  prefix:
                    type: string
                  scopes:
                    type: array
                    items:
                      type: string
                  name:
                    type:
                      - string
                      - 'null'
                  expiresAt:
                    type: string
                    format: date-time
        '400':
          description: >-
            bad signature, spent nonce, or the account already holds 10 live
            keys
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      security: []
components:
  schemas:
    Error:
      type: object
      properties:
        error:
          type: string
      required:
        - error
  securitySchemes:
    ApiKey:
      type: http
      scheme: bearer
      description: An API key (hk_live_… / hk_test_…) with read/taker/maker scopes.

````