Developer Docs

Build on Strait

Strait indexes every cross-chain transfer through Hemi's Bitcoin and Ethereum tunnels and serves it as a plain HTTP read API. No SDK required — integrate from any language with a normal HTTP client.

What Strait is

Hemi tunnels move assets across three chains — Bitcoin, Hemi (an OP-Stack L2), and Ethereum. Strait watches the tunnel contracts on all three and reconstructs each transfer as a single record, tracked from initiation to finality — including Bitcoin-anchored (Proof-of-Proof) finality for BTC routes.

It runs one node per network (mainnet, testnet). Each node is an indexer plus an HTTP API; all state lives in Postgres/Supabase, so the dashboard and any app you build read from the same source of truth.

This is a read API — it reports on-chain reality. It never holds funds or initiates bridges.

The data: a Transfer

Every result is a Transfer — the core resource. These are the fields you get (GraphQL camelCase; the REST API returns the same fields in snake_case).

FieldTypeMeaning
idUUIDDeterministic id (Hemi tx hash + log index). Stable — use as a key.
assetStringBTC, ETH, or an ERC-20 symbol.
directionEnumIN (into Hemi) or OUT (out of Hemi).
routeEnumBTC_TO_HEMI · HEMI_TO_BTC · ETH_TO_HEMI · HEMI_TO_ETH.
amountStringAtomic units (satoshis or wei) as a decimal string.
sender / recipientStringOrigin / destination address (EVM 0x… or Bitcoin).
statusEnumINITIATED · PROVING · FINALIZED · FAILED · REORGED.
sourceChain / sourceTxHash / sourceBlockThe initiating leg.
destChain / destTxHash / destBlockThe counterpart leg (null until it confirms).
popAnchored / popKeystoneBlock / popScoreBitcoin (PoP) anchoring fields.
initiatedAt / finalizedAtDateTimeReal on-chain block times of each milestone.
Amounts are atomic units. BTC → ÷108 (sats), ETH → ÷1018 (wei), ERC-20 → ÷10token decimals. They're strings to avoid float precision loss.

The transfer lifecycle

status is what you poll for. Each route has a different sequence of on-chain events before it reaches FINALIZED.

ETH → Hemi deposit · ~2 minutes

INITIATED ──────────────────────────────────────────► FINALIZED
  1. 1
    User calls depositETH on the L1StandardBridge

    Funds are locked on Ethereum. Strait sees ETHBridgeInitiated and records the transfer as INITIATED.

  2. 2
    OP Stack relays the deposit to Hemi (~2 min)

    The sequencer picks up the L1 deposit and finalizes it on L2. Strait sees ETHBridgeFinalized on Hemi and advances to FINALIZED.

BTC → Hemi deposit · ~1–2 hours

INITIATED ──────────────────────────────────────────► FINALIZED
  1. 1
    User sends BTC to the vault custody address

    Strait observes the Bitcoin UTXO and records the transfer as INITIATED.

  2. 2
    6 Bitcoin confirmations accumulate, hBTC is minted (~1 hour)

    An operator calls confirmDeposit on BitcoinTunnelManager, minting hBTC to the recipient. Strait sees DepositConfirmed and advances to FINALIZED — the user has their funds.

  3. 3
    PoP keystone anchors the Hemi block (async, ~90 min — optional)

    PoPPayoutsV2.PayoutRoundExecuted fires for the keystone covering the mint block. status stays FINALIZED; Strait sets popAnchored=true. This upgrades the transfer to Bitcoin-grade finality independently of FINALIZED.

    Note: PoP payouts are not yet activated on mainnet as of June 2026. popAnchored stays false, but transfers still reach FINALIZED at mint.

Hemi → ETH withdrawal · ~1 day

INITIATED ──► PROVING ─────────────────────────────► FINALIZED
              (1-day challenge window)
  1. 1
    User calls withdraw on the L2StandardBridge on Hemi

    ETH is burned on Hemi. Strait sees ETHBridgeInitiated and records the transfer as INITIATED.

  2. 2
    User calls proveWithdrawalTransaction on OptimismPortal (after output root is published, ~1 hour)

    The withdrawal is proven against the L2 output root on Ethereum. Strait sees WithdrawalProven and advances to PROVING. The 1-day challenge window begins.

  3. 3
    Challenge window elapses (~1 day after proving)

    Anyone calls finalizeWithdrawalTransaction on OptimismPortal. ETH is released on Ethereum. Strait sees ETHBridgeFinalized on L1 and advances to FINALIZED.

Hemi → BTC withdrawal · ~2–14 hours

INITIATED ──────────────────────────────────────────► FINALIZED
  1. 1
    User calls initiateWithdrawal on BitcoinTunnelManager

    hBTC is burned on Hemi. Strait sees WithdrawalInitiated and records the transfer as INITIATED. A uuid is embedded in the event for cross-chain matching.

  2. 2
    Vault operator pays out on Bitcoin (up to ~14 hours)

    The operator broadcasts a Bitcoin transaction with the uuid in an OP_RETURN output. Strait matches it by uuid and advances to FINALIZED.

    If the operator misses the deadline, anyone can call challengeWithdrawal on BitcoinTunnelManager. On success, the contract re-mints hBTC to the original sender and Strait marks the transfer FAILED.

FAILED — a terminal error (e.g. challenge succeeded, vault defaulted). No further transitions.

REORGED — a chain reorg retracted the source event. Strait rolls back the transfer. A duplicate may re-appear if the tx is re-included.

A robust integration treats FINALIZED as "done" and polls on any other non-terminal status.

Using the API

The node serves on API_HOST:API_PORT (default :8080). Open /graphql in a browser for the interactive GraphiQL playground.

Endpoints

POST /graphqlExecute GraphQL queries
GET /graphqlGraphiQL playground
GET /transfers?limit=&offset=REST: list transfers
GET /transfers/:idREST: one transfer by UUID
POST /webhooksRegister a webhook subscription
GET · DELETE /webhooks/:idInspect · remove a subscription (token-gated)
GET /webhooks/:id/deliveriesLast 20 delivery attempts (token-gated)
GET /health · /health/dbLiveness · DB connectivity

GraphQL queries

# Recent transfers (newest first; limit clamped 1–500, default 50)
{ transfers(limit: 20) { id route asset amount status initiatedAt finalizedAt } }

# A single transfer by id
{ transfer(id: "a2ce3b2d-…") { id route status destChain destTxHash recipient } }

# All transfers for a recipient address (a wallet view)
{ transfersByRecipient(recipient: "0x64ea…951f", limit: 50) { id route amount status } }

# Search by address / tx hash / id, with optional status & route filters
{ searchTransfers(query: "bc1qwql2…", status: "FINALIZED", route: "HEMI_TO_BTC") {
    id amount status finalizedAt } }

# Aggregate stats — optionally scoped to a window
{ stats { totalTransfers finalized failed } }
{ stats(window: LAST_24H) { totalTransfers finalized } }

# Time-bucketed analytics: count + volume per route/asset.
# window: LAST_24H | LAST_7D | LAST_30D | ALL_TIME · granularity: DAY | WEEK | MONTH
# volume is atomic units (sats/wei) per asset — convert client-side.
{ analyticsSeries(window: LAST_30D, granularity: DAY) {
    bucketStart route asset transferCount volume } }

# Which route dominates a window (share is 0–1 of total transfers)
{ routeBreakdown(window: LAST_7D) { route transferCount share } }

Examples

// TypeScript — search via GraphQL
const res = await fetch("http://localhost:8080/graphql", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    query: `query($r:String!){ transfersByRecipient(recipient:$r){ id route status } }`,
    variables: { r: recipient },
  }),
});
const { data } = await res.json();
# curl — REST
curl 'http://localhost:8080/transfers?limit=20'
curl 'http://localhost:8080/transfers/a2ce3b2d-7110-520c-8999-d21d1f88d1e5'
Watching for finality: prefer a webhook — Strait pushes the change to you the moment it lands. If you'd rather poll: you know your source tx hash (you just submitted it), so use searchTransfers(query: "0xyourtxhash") every ~10–15s rather than matching by amount/route/time — it finds your transfer directly instead of guessing. Fall back to transfersByRecipientonly if you don't have a tx hash yet. Stop when status === "FINALIZED" (or FAILED / REORGED).

Webhooks

Push notifications for transfer lifecycle events: register a URL and Strait POSTs an HMAC-signed JSON payload to it whenever a matching transfer changes. Deliveries are backed by a durable outbox — a node restart never drops one — and failed POSTs retry with exponential backoff (10s → 24h, 8 attempts). Delivery is at-least-once: dedupe on the X-Strait-Delivery header.

Registering

curl -X POST http://localhost:8080/webhooks \
  -H 'content-type: application/json' \
  -d '{
    "url": "https://example.com/strait-hook",
    "routes":   ["HEMI_TO_BTC", "HEMI_TO_ETH"],
    "assets":   ["BTC", "ETH"],
    "statuses": ["FINALIZED", "FAILED"]
  }'

Filters are optional — omit a dimension to match everything on it. The URL must be public http(s); loopback and private-network hosts are rejected.

The response contains two credentials, shown exactly once. signing_secret is the HMAC key every delivery to you is signed with; management_token is required to inspect or delete the subscription later. Store both immediately — the API never discloses them again. Lose them and you re-register.

Deliveries

X-Strait-Signaturesha256=<hex HMAC-SHA256 of the raw body under your signing_secret>
X-Strait-Eventtransfer.created · transfer.status_changed · transfer.pop_anchored · transfer.retracted
X-Strait-DeliveryUnique delivery id — your dedupe key

The body is { "event", "timestamp", "transfer": { … } } where transfer has the same snake_case shape as GET /transfers rows. Respond with any 2xx within 10 seconds to acknowledge — anything else (or a timeout) schedules a retry.

Verifying signatures

Always verify before trusting a payload — anyone who discovers your endpoint URL can POST fake events to it; only Strait knows your signing_secret. Verify over the raw request bytes: re-serializing parsed JSON can reorder keys and break the digest.

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody /* Buffer */, signatureHeader, secret) {
  const expected =
    "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  return timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expected));
}

What those two calls do (node:crypto ships with Node — no npm package): createHmac recomputes the HMAC-SHA256 signature Strait attached — only a holder of your signing_secret can produce it, and changing one byte of the body changes it completely, so a match proves the payload is genuinely from Strait and untampered. timingSafeEqual compares the signatures in constant time: a plain === returns faster the earlier the first mismatch is, and that timing difference — measured over many forged requests — can leak a valid signature byte by byte. Constant-time comparison closes that side channel.

Credentials & subscriptions

One subscription per service, not per end-user. Strait doesn't know about your users — it notifies you about transfers. A wallet with 10,000 users runs one subscription (per environment) pointed at its backend; when a delivery arrives, match transfer.recipient (or sender) against your own users table to decide who to notify.

idNot secret — config/env; needed for GET / DELETE /webhooks/:id
signing_secretSecret — env var / secret manager; your receiver reads it to verify deliveries
management_tokenSecret — secret manager; only needed to inspect or delete the subscription

Register separately for staging and production, each with its own URL and secrets. To rotate, register a new subscription, accept both secrets during the cutover, then delete the old one. The API never re-discloses secrets — if you lose the management token, keep returning 2xx(and ignore the events) so retries don't pile up, and ask the operator to remove the row.

Integrating with your backend

Two rules for every receiver: verify the signature over the raw request bytes (parse JSON only after the check — body-parsing middleware that re-serializes breaks the digest), and acknowledge fast — return 2xx, then do your real work asynchronously. A handler slower than 10s looks like a failure and gets retried, which you'll then process twice.

Express:

import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const app = express();
const SECRET = process.env.STRAIT_SIGNING_SECRET;

// express.raw (NOT express.json) so we verify the exact bytes.
app.post("/strait-hook", express.raw({ type: "application/json" }), (req, res) => {
  const sig = req.get("X-Strait-Signature") ?? "";
  const expected = "sha256=" + createHmac("sha256", SECRET).update(req.body).digest("hex");
  if (sig.length !== expected.length ||
      !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.status(401).send("bad signature");
  }

  res.status(200).send("ok"); // ack first — work after

  const deliveryId = req.get("X-Strait-Delivery"); // your dedupe key
  const { event, transfer } = JSON.parse(req.body);
  if (event === "transfer.status_changed" && transfer.status === "FINALIZED") {
    // mark the user's bridge complete, send a push notification…
  }
});

Next.js (App Router route handler):

// app/api/strait-hook/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export async function POST(req: Request) {
  const raw = Buffer.from(await req.arrayBuffer()); // raw bytes, not req.json()
  const sig = req.headers.get("x-strait-signature") ?? "";
  const expected = "sha256=" +
    createHmac("sha256", process.env.STRAIT_SIGNING_SECRET!).update(raw).digest("hex");
  if (sig.length !== expected.length ||
      !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return new Response("bad signature", { status: 401 });
  }

  const deliveryId = req.headers.get("x-strait-delivery"); // your dedupe key
  const { event, transfer } = JSON.parse(raw.toString());
  // handle the event (keep it quick, or hand off to a queue)…
  return new Response("ok");
}

Recommended pattern — webhook + poll reconciliation. On submit, store a pending row in your DB keyed by the source tx hash. On webhook, match transfer.source_tx_hash to that row, update it, notify the user. Then reconcile: webhooks are at-least-once, but if your endpoint is down longer than the retry window (~1.5 days) a delivery can permanently fail — so sweep your still-pending rows every ~10 minutes and resolve them by polling:

async function fetchByTxHash(txHash) {
  const res = await fetch("http://localhost:8080/graphql", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      query: `query($q: String) {
        searchTransfers(query: $q, limit: 1) {
          id status route asset amount destTxHash finalizedAt
        }
      }`,
      variables: { q: txHash },
    }),
  });
  const { data } = await res.json();
  return data?.searchTransfers?.[0] ?? null;
}

The webhook gives you low latency; the sweep guarantees you never miss a terminal state. Both read the same records, so they can share handling code.

Managing a subscription

The Webhooks page does all of this in the browser — register, inspect delivery history, delete. The same operations over curl:

# Inspect (metadata only — secrets are never returned)
curl http://localhost:8080/webhooks/<id> -H 'X-Management-Token: <token>'

# Last 20 delivery attempts: event, status, attempt count, response time, error
curl http://localhost:8080/webhooks/<id>/deliveries -H 'X-Management-Token: <token>'

# Unsubscribe (pending deliveries are removed with it)
curl -X DELETE http://localhost:8080/webhooks/<id> -H 'X-Management-Token: <token>'

Contracts indexed

Strait watches these tunnel contracts. ETH/ERC-20 routes flow through the OP-Stack StandardBridge; BTC routes through the BitcoinTunnelManager, with Bitcoin state read via the BitcoinKit precompile on Hemi (no separate Bitcoin node required).

ContractNetworkAddress
BitcoinTunnelManagerHemi Mainnet0xEAcA824F46c000fB89403846Bb57e6b913321081
BitcoinTunnelManagerHemi Sepolia0x8221CFD3Eca3c5F9FA27b2AE774151642f1C449e
L2StandardBridgeHemi (both)0x4200000000000000000000000000000000000010
L1StandardBridgeProxyEthereum Mainnet0x5eaa10F99e7e6D177eF9F74E519E319aa49f191e
L1StandardBridgeProxyEthereum Sepolia0xc94b1BEe63A3e101FE5F71C80F912b4F4b055925
BitcoinKitV1Hemi Mainnet0x7007dd1C09527B92AEcd8Ae6570B73d09E0B8F12
BitcoinKit v0Hemi Sepolia0xeC9fa5daC1118963933e1A675a4EEA0009b7f215
PoPPayoutsV2Hemi Mainnet0x9a23ab7cb11cfb96e577da52a6ad5211ff24434b
PoPPayoutsV2Hemi Sepolia0x4a3b61C586DB4CD219E85aC0697b66916c7457AB
PoP anchoring status (June 2026): The PoPPayoutsV2 contracts are deployed on mainnet but mintPoPRewards() has not yet been called — lastBlockRewarded = 0 on both deployments. BTC_TO_HEMI deposits already reach FINALIZED at the Hemi mint — PoP anchoring is tracked separately via popAnchored. Strait will set popAnchored=true automatically once PayoutRoundExecuted events start firing.