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' }),
})- It works from any origin, so it keeps working when your wink runs inside an X card or a sandboxed frame (which sends
Origin: null). The response carriesAccess-Control-Allow-Origin: *, so your code can read it. - Browser posts are rate limited more tightly than server ones — 20 a minute by default, counted per key and per IP, since anyone who reads your bundle holds the key.
wk_live_keys are deliberately not usable this way: no CORS headers come back on that path at all, so a secret key accidentally moved into a bundle fails loudly instead of working in development and leaking in production.
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.
- The chain overwrites your claims. Once a transaction is found and mined,
from,to,value,statusandblockNumberare taken from the chain, not from your post. You do not have to report a confirmation for the status to become correct — though you still can, and it is faster. - A contradiction removes the row. If you assert a
from,toor non-zerovaluethat the chain disagrees with, the transaction is dropped from the feed — from the list, from the stream, and from any browser already showing it. A field you left out is not an assertion and is never held against you. - A hash the chain has never seen is dropped after ten minutes. Until then it is simply waiting: an unmined transaction is not a wrong one. Post the hash the moment you have it; do not invent one.
- None of this is visible on the feed. There is no “unverified” badge — a transaction is either shown or it is not.
What gets rejected
Every rule below answers 400 with the offending field named in issues. The first one catches people out most often.
- Unknown fields are refused, not ignored. The body is validated strictly, so a stray
gasUsed,nonceortxHashtypo is a400rather than a silently dropped key. Put anything extra insidemetadata— bearing in mind it is public. metadatamust be a JSON object serialising to at most 8 KB of UTF-8.actionis at most 120 characters.occurredAtis ISO 8601 with an offset and no more than 5 minutes in the future — enough for clock skew, not enough to post-date. It defaults to the time we receive the first post.valueis a non-negative integer string of at most 78 digits;chainIdis exactly 30 or 31;statusispending,successorfailed.
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
201— transaction created200— existing transaction updated400— validation failed; the body lists each bad field inissues401— missing or unrecognised key403— this wink is deactivated429— over 60 posts a minute for awk_live_key, or 20 for awk_pub_one; seeRetry-After