Integrate your wink

Post each Rootstock transaction to one endpoint and it appears in the live feed immediately. There is no SDK to install.

You get two keys. Only one of them is a secret.

wk_live_… is a server secret. Anything in a browser bundle is public, and anyone holding this key can post as your wink, so keep it in an environment variable that is never prefixed with NEXT_PUBLIC_ and call the API from your own server.

wk_pub_… is designed to be shipped in a browser bundle — which is the only option if your app is a static export with no server to hold a secret. It works from any page — your own site, an X card, a sandboxed frame — and it buys nothing on its own, because every transaction posted with either key is checked against the Rootstock chain before this feed will keep it.

Everything you put in metadata is published.

It is stored as you send it and served back verbatim on GET /api/v1/transactions and GET /api/v1/stream — both public, unauthenticated, and readable from any origin. A user id, wallet label, email address or internal account reference put in there is public the moment you post it, and it stays public. Send only what you would be happy to see on the feed: pool names, slippage, route hops, your own opaque ids.

1. Get your keys

Ask the Winks team for keys for your app. You are handed both at once — a wk_live_… secret and a wk_pub_… publishable key — and they are shown once, because both are hashed at rest.

2. Post a transaction

curl -X POST http://localhost:3000/api/v1/transactions \
  -H "x-api-key: $WINKS_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "txHash": "0x3f1a...9c",
    "chainId": 30,
    "from": "0xabc...",
    "to": "0xdef...",
    "value": "1000000000000000000",
    "status": "pending",
    "action": "Swap RBTC to RIF",
    "metadata": { "pool": "rbtc-rif" }
  }'

Required: txHash and chainId (30 mainnet, 31 testnet). That is all. Everything else — from included — is a hint we confirm against the chain, so send what you know and leave out what you do not. An app whose backend wallet signs, and which is only handed a hash, can report with those two fields alone and the chain fills in the rest. value is wei as a string — a number would lose precision.

Posting from the browser

Use the wk_pub_… key. There is no server route in this version and nothing to deploy alongside your static export.

// in your component, right after the user signs.
// No server route, no secret: this is the whole integration for a static export.
const hash = await walletClient.sendTransaction(request)

await fetch('http://localhost:3000/api/v1/transactions', {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    // Safe in the bundle. This is the only key that ever should be.
    'x-api-key': process.env.NEXT_PUBLIC_WINKS_PUBLISHABLE_KEY!,
  },
  // txHash and chainId are the only required fields. Send what you know;
  // the chain supplies the rest.
  body: JSON.stringify({ txHash: hash, chainId: 30, action: 'Swap RBTC to RIF' }),
})

What the chain does to what you send

Every transaction, posted with either key, is looked up on a Rootstock node — eth_getTransactionByHash and eth_getTransactionReceipt — shortly after you post it. Your row appears in the feed immediately; the check follows within minutes.

What gets rejected

Every rule below answers 400 with the offending field named in issues. The first one catches people out most often.

3. Report it twice (from a server)

Post pending the instant the user signs, then post the same txHash again with success or failed once you have a receipt. The second post updates the same row instead of creating a new one, so retries are safe.

A second post only writes the fields it carries. Omit value, action or metadata — or send them as null — and the stored values are left alone, so a confirmation can be just the txHash, chainId, from and what changed. occurredAt is fixed by the first post and never moves.

// app/api/report-tx/route.ts  — runs on YOUR server
export async function POST(request: Request) {
  const tx = await request.json()

  await fetch('http://localhost:3000/api/v1/transactions', {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      // Server-only. Never NEXT_PUBLIC_.
      'x-api-key': process.env.WINKS_API_KEY!,
    },
    // Keys you leave out are left alone, so the same route handles both the
    // first post and the confirmation. (undefined values drop out of JSON.)
    body: JSON.stringify({
      txHash: tx.hash,
      chainId: 30,
      from: tx.from,
      to: tx.to,
      value: tx.value,          // wei, as a string
      status: tx.status ?? 'pending',
      action: 'Swap RBTC to RIF',
      blockNumber: tx.blockNumber,
    }),
  })

  return Response.json({ ok: true })
}
// in your component, right after the user signs
const hash = await walletClient.sendTransaction(request)

await fetch('/api/report-tx', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  // value is a bigint in viem — JSON.stringify throws on one, so send the
  // decimal string. That is the wire format here anyway.
  body: JSON.stringify({ hash, from, to, value: value.toString() }),
})

// then, once it is mined, report it again with the same hash.
// Only the fields you send are written, so this can carry just what changed.
const receipt = await publicClient.waitForTransactionReceipt({ hash })

await fetch('/api/report-tx', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    hash,
    from,
    status: receipt.status === 'success' ? 'success' : 'failed',
    blockNumber: Number(receipt.blockNumber),
  }),
})

Read the feed

Both endpoints are public, need no key, and are open to any origin. Both are limited to 120 requests a minute per IP — for the stream that is counted per connection attempt — and answer 429 with Retry-After beyond it. Both filter on wink and chainId; the list endpoint also takes status.

The stream does not take a status filter, and refuses one with a 400 rather than quietly breaking. A stream exists to deliver updates, and a status-filtered stream stops matching a row at the exact moment its status changes — the update would never be sent and the row would sit in your client showing pending forever. Subscribe without it and filter by status in your client, on the rows the stream sends you; that is what this site's own feed does.

GET /api/v1/transactions?limit=50&wink=swap-wink&status=success&chainId=30
  → { "data": [ … ], "nextCursor": "1041" }

# page backwards with the cursor you were handed; null means you reached the end
GET /api/v1/transactions?limit=50&cursor=1041

GET /api/v1/stream?wink=swap-wink&chainId=30   # server-sent events, one "tx" event
  id: 1041                                     # per new or updated transaction
  event: tx
  data: {"id":"…","seq":1041,"txHash":"0x…","status":"success", …}

GET /api/v1/stream?status=pending              # 400 — see below

  event: tx-removed                            # this row is gone: drop it
  data: {"id":"…"}                             # (no SSE id — see below)

Paging. limit defaults to 50 and caps at 200. Each response carries a nextCursor; pass it back as cursor for the next page, newest first, and stop when it comes back null.

Streaming. A fresh connection is sent the 50 most recent transactions, then live ones as they arrive. Every event carries the transaction's seq as its SSE id, so a reconnect that sends Last-Event-ID resumes from there instead of replaying the backfill — browsers do this for you. A : ping comment every 15 seconds keeps intermediaries from closing the connection, and updates arrive as the same tx event as new rows: upsert by id.

Handle tx-removed. A transaction the chain later contradicts is withdrawn from the feed, and a client that was already sent it has to be told — otherwise it keeps showing a row nobody else can see. The event carries only { id }: delete that row. It is re-announced for five minutes, so a reconnect inside that window catches a withdrawal that happened while you were away, and repeats are safe to ignore. It carries no SSE id: on purpose — a withdrawal is not a position in the feed, and giving it one would drag your Last-Event-ID backwards.

Responses