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.
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).
| Field | Type | Meaning |
|---|---|---|
| id | UUID | Deterministic id (Hemi tx hash + log index). Stable — use as a key. |
| asset | String | BTC, ETH, or an ERC-20 symbol. |
| direction | Enum | IN (into Hemi) or OUT (out of Hemi). |
| route | Enum | BTC_TO_HEMI · HEMI_TO_BTC · ETH_TO_HEMI · HEMI_TO_ETH. |
| amount | String | Atomic units (satoshis or wei) as a decimal string. |
| sender / recipient | String | Origin / destination address (EVM 0x… or Bitcoin). |
| status | Enum | INITIATED · PROVING · FINALIZED · FAILED · REORGED. |
| sourceChain / sourceTxHash / sourceBlock | — | The initiating leg. |
| destChain / destTxHash / destBlock | — | The counterpart leg (null until it confirms). |
| popAnchored / popKeystoneBlock / popScore | — | Bitcoin (PoP) anchoring fields. |
| initiatedAt / finalizedAt | DateTime | Real on-chain block times of each milestone. |
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
- 1User calls
depositETHon the L1StandardBridgeFunds are locked on Ethereum. Strait sees
ETHBridgeInitiatedand records the transfer asINITIATED. - 2OP Stack relays the deposit to Hemi (~2 min)
The sequencer picks up the L1 deposit and finalizes it on L2. Strait sees
ETHBridgeFinalizedon Hemi and advances toFINALIZED.
BTC → Hemi deposit · ~1–2 hours
INITIATED ──────────────────────────────────────────► FINALIZED
- 1User sends BTC to the vault custody address
Strait observes the Bitcoin UTXO and records the transfer as
INITIATED. - 26 Bitcoin confirmations accumulate, hBTC is minted (~1 hour)
An operator calls
confirmDepositon BitcoinTunnelManager, minting hBTC to the recipient. Strait seesDepositConfirmedand advances toFINALIZED— the user has their funds. - 3PoP keystone anchors the Hemi block (async, ~90 min — optional)
PoPPayoutsV2.PayoutRoundExecutedfires for the keystone covering the mint block.statusstaysFINALIZED; Strait setspopAnchored=true. This upgrades the transfer to Bitcoin-grade finality independently ofFINALIZED.Note: PoP payouts are not yet activated on mainnet as of June 2026.
popAnchoredstays false, but transfers still reachFINALIZEDat mint.
Hemi → ETH withdrawal · ~1 day
INITIATED ──► PROVING ─────────────────────────────► FINALIZED
(1-day challenge window)- 1User calls
withdrawon the L2StandardBridge on HemiETH is burned on Hemi. Strait sees
ETHBridgeInitiatedand records the transfer asINITIATED. - 2User calls
proveWithdrawalTransactionon OptimismPortal (after output root is published, ~1 hour)The withdrawal is proven against the L2 output root on Ethereum. Strait sees
WithdrawalProvenand advances toPROVING. The 1-day challenge window begins. - 3Challenge window elapses (~1 day after proving)
Anyone calls
finalizeWithdrawalTransactionon OptimismPortal. ETH is released on Ethereum. Strait seesETHBridgeFinalizedon L1 and advances toFINALIZED.
Hemi → BTC withdrawal · ~2–14 hours
INITIATED ──────────────────────────────────────────► FINALIZED
- 1User calls
initiateWithdrawalon BitcoinTunnelManagerhBTC is burned on Hemi. Strait sees
WithdrawalInitiatedand records the transfer asINITIATED. A uuid is embedded in the event for cross-chain matching. - 2Vault operator pays out on Bitcoin (up to ~14 hours)
The operator broadcasts a Bitcoin transaction with the uuid in an
OP_RETURNoutput. Strait matches it by uuid and advances toFINALIZED.If the operator misses the deadline, anyone can call
challengeWithdrawalonBitcoinTunnelManager. On success, the contract re-mints hBTC to the original sender and Strait marks the transferFAILED.
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 /graphql | Execute GraphQL queries |
| GET /graphql | GraphiQL playground |
| GET /transfers?limit=&offset= | REST: list transfers |
| GET /transfers/:id | REST: one transfer by UUID |
| POST /webhooks | Register a webhook subscription |
| GET · DELETE /webhooks/:id | Inspect · remove a subscription (token-gated) |
| GET /webhooks/:id/deliveries | Last 20 delivery attempts (token-gated) |
| GET /health · /health/db | Liveness · 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'
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.
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-Signature | sha256=<hex HMAC-SHA256 of the raw body under your signing_secret> |
| X-Strait-Event | transfer.created · transfer.status_changed · transfer.pop_anchored · transfer.retracted |
| X-Strait-Delivery | Unique 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.
| id | Not secret — config/env; needed for GET / DELETE /webhooks/:id |
| signing_secret | Secret — env var / secret manager; your receiver reads it to verify deliveries |
| management_token | Secret — 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).
| Contract | Network | Address |
|---|---|---|
| BitcoinTunnelManager | Hemi Mainnet | 0xEAcA824F46c000fB89403846Bb57e6b913321081 |
| BitcoinTunnelManager | Hemi Sepolia | 0x8221CFD3Eca3c5F9FA27b2AE774151642f1C449e |
| L2StandardBridge | Hemi (both) | 0x4200000000000000000000000000000000000010 |
| L1StandardBridgeProxy | Ethereum Mainnet | 0x5eaa10F99e7e6D177eF9F74E519E319aa49f191e |
| L1StandardBridgeProxy | Ethereum Sepolia | 0xc94b1BEe63A3e101FE5F71C80F912b4F4b055925 |
| BitcoinKitV1 | Hemi Mainnet | 0x7007dd1C09527B92AEcd8Ae6570B73d09E0B8F12 |
| BitcoinKit v0 | Hemi Sepolia | 0xeC9fa5daC1118963933e1A675a4EEA0009b7f215 |
| PoPPayoutsV2 | Hemi Mainnet | 0x9a23ab7cb11cfb96e577da52a6ad5211ff24434b |
| PoPPayoutsV2 | Hemi Sepolia | 0x4a3b61C586DB4CD219E85aC0697b66916c7457AB |
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.