Everything below uses your project's base URL. Running it yourself, that's:
BASE=http://127.0.0.1:4000/projects/myproject
REST
curl $BASE/v1/status # cursor, lag, whether a rebuild is running
curl $BASE/v1/tables # every table: kind, key, columns
curl $BASE/v1/tables/balances # rows
Rows come back with the query that produced them:
{
"rows": [ { "user": "0xabc…", "balance": "7530", "_version": "6003184262" } ],
"limit": 50,
"offset": 0,
"count": null
}
Narrowing it down
| Parameter | Meaning |
|---|---|
limit |
how many rows, default 50, max 1000 |
offset |
skip this many |
order |
?order=balance.desc — a column and a direction |
count=exact |
also return the total, at the cost of a second query |
<column>=<value> |
keep rows where the column equals that |
curl "$BASE/v1/tables/balances?balance>1000&order=balance.desc&limit=10"
Two things about every row
Wide integers are strings. u64, u128 and u256 don't fit a JavaScript number,
so they're returned as decimal strings. Parse them with BigInt, never Number — the
loss above 2⁵³ is silent.
_version says when the row last changed. It's the transaction that last wrote it,
and it's what lets you tell a stale copy from a current one.
The change feed
Server-Sent Events, in commit order:
curl -N "$BASE/v1/changes"
event: change
id: 11292175483.0
data: {"version":"11292175483","seq":0,"table":"balances","op":"update","key":{"user":"0xabc…"},"row":{…}}
op is insert, update or delete. The id is version.seq, and it is a position
you can come back to:
?after=11292175483.0 |
resume just after that change |
?after=beginning |
replay everything still kept |
?tables=balances,volume |
only these tables |
A browser resumes on its own — EventSource sends the last id it saw as
Last-Event-ID when it reconnects, so a dropped connection costs nothing.
const feed = new EventSource(`${BASE}/v1/changes?apikey=nvk_…`);
feed.addEventListener("change", (e) => apply(JSON.parse(e.data)));
EventSource can't set headers, which is why the key goes in the query string here.
Get one from Settings → API keys.
A reset event means the tables were rebuilt and swapped in. Your cached copy is
from the old build; reload it.
Webhooks
Declared in your config, so they're version-controlled with everything else:
webhooks:
my_backend:
url: https://myapp.example/hooks/nineveh
on: [balances.changed]
rows: true
on takes <table>.changed, .inserted, .updated or .deleted. Each POST carries
up to 100 changes in order:
{
"project": "myproject",
"endpoint": "my_backend",
"changes": [
{ "table": "balances", "op": "update", "version": "…", "seq": 0,
"key": {…}, "row": {…} }
]
}
Check the signature
Every request carries X-Nineveh-Signature: t=<timestamp>,v1=<hex>, where v1 is
HMAC-SHA256(secret, "<timestamp>.<raw body>"). Verify against the raw body, before
any JSON parsing:
const [t, v1] = sig.split(",").map((p) => p.split("=")[1]);
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
if (!timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) throw new Error("bad signature");
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) throw new Error("too old");
The timestamp check is what stops someone replaying a request they captured earlier. Secrets live in Settings → Webhooks and can be rotated whenever you like.
Delivery is at-least-once
A batch is retried until your endpoint answers 2xx, so you will occasionally see the same change twice. Two ways to be safe:
rows: truesends the row. Compareversionandseqagainst what you've already applied and ignore anything older.rows: falsesends only the key — this row changed, come and look. Then fetch it. Duplicates stop mattering, because a fetch always returns what's current.
The second is less code and harder to get wrong. Use it unless you need the row in the delivery itself.