@rwa-registry/sdk
v0.1.0
Published
TypeScript SDK for the RWA-Registry API — permanent, verifiable file publishing.
Maintainers
Readme
@rwa-registry/sdk
RWA-Registry publishes the evidence behind tokenized real-world assets — deeds, valuations, attestations — to permanent, content-addressed storage on Arweave, and keeps a verifiable record of what was published, when, and by whom. A workspace holds the records; an API key, scoped to exactly that workspace, is how a system outside the console reads or writes them.
@rwa-registry/sdk is the official TypeScript client for the public v1
API. It exists so an integration doesn't have to hand-roll the parts that are
easy to get subtly wrong: retrying only what is actually safe to retry,
mapping server errors onto types you can branch on, and verifying a webhook
signature in constant time against the exact bytes that were sent.
Zero dependencies. Ships ESM and CommonJS with types for both. Node 18+.
npm install @rwa-registry/sdkpnpm add @rwa-registry/sdkyarn add @rwa-registry/sdkQuick start
import { RWARegistry } from "@rwa-registry/sdk";
const registry = new RWARegistry({ apiKey: process.env.RWA_REGISTRY_API_KEY });
const file = await registry.files.uploadAndPublish({
name: "title-deed.pdf",
data: await readFile("title-deed.pdf"),
contentType: "application/pdf",
labels: ["q4-2026"],
});
console.log(file.fileID, file.status); // → file_… publishingIssue a key in the console under Settings → API access. The plaintext is shown once, at creation, and cannot be retrieved afterwards.
Keys are secrets. Use them from a server. Never ship one in a browser bundle or a mobile app.
One key, one workspace
Every key belongs to exactly one workspace, and the binding is enforced server
side. A key issued in workspace A can only ever read, write, and spend inside
workspace A — there is no account-wide view to reach for by mistake.
That is why SDK calls never ask for a workspaceID: the key is the workspace
selector as well as the credential.
Keys carry a scope, set when they are issued:
| Scope | Can do |
| --------- | -------------------------------------------------------------------------- |
| read | List and read files and folders |
| write | Also create folders and uploads, and edit metadata. Spends nothing |
| publish | Everything above, plus publishing — the only thing that spends the balance |
A call beyond a key's scope fails with PermissionError.
Publishing
Publishing is permanent and costs money. Check the price first if that matters to you:
const quote = await registry.files.calculatePrice(bytes.byteLength);
console.log(`${quote.price} ${quote.currency}`); // → 0.04 USDuploadAndPublish is the three underlying steps in one call. Run them yourself
when you want to keep the rID. The server treats that ID as the operation's
idempotency key: repeating an accepted publish returns the original operation
without charging again.
const target = await registry.files.createUploadTarget();
const rID = await registry.files.upload(target, { name: "deed.pdf", data });
const file = await registry.files.publish({ name: "deed.pdf", rID });The upload PUT goes straight to storage on a presigned URL, so it carries no API key. The reservation can be published once, and the URL expires after ten minutes; do not reuse it.
Listing
list() returns one page. iterate() walks all of them, fetching lazily — so
breaking out of the loop stops the requests too:
for await (const file of registry.files.iterate({ labels: ["q4-2026"] })) {
if (file.onChain) console.log(file.permaLink);
}Webhooks
Endpoints belong to the workspace, not to the key that registered them. Every publish in the workspace is delivered to all of them — whoever made the publish, and whether it came through the API or the console.
await registry.webhooks.create("https://example.com/hooks/registry");
const endpoints = await registry.webhooks.list();Events
| eventType | |
| ---------------- | ------------------------------------------------------------ |
| file.published | The file anchored successfully. permaLink is now set — read it back with registry.files.get(fileID). |
| file.failed | The publish attempt failed and the charge was refunded in full. registry.files.get(fileID) returns status: "failed"; retry with a new upload. |
Both carry the same shape (fileID, eventType, uploadId, publishedBy,
webhookID) — branch on event.eventType, and register one endpoint for
both rather than one per event.
Each delivery carries two headers:
| Header | |
| --------------------- | ------------------------------------------------ |
| x-webhook-signature | HMAC-SHA256 digest, lowercase hex, no prefix |
| x-webhook-timestamp | Milliseconds since the epoch |
The signature covers {timestamp}.{raw body}, so the timestamp is inside the
digest and cannot be tampered with. That is what makes replay rejection safe.
Why the raw body matters
This is the one thing that trips everybody up, so it is worth being precise.
The sender computes the digest over the exact bytes it puts on the wire. To get
the same digest you have to hash those same bytes. Once your framework has
parsed the JSON you no longer have them — you have an object, and
JSON.stringify() of that object is not guaranteed to reproduce the original.
The trap is that it often does. A round-trip through JSON.parse →
JSON.stringify is byte-identical for a minified payload with ordinary keys,
so a re-serialising handler passes every test you write against today's
payload — and then breaks later, in production, on a delivery like these:
| Bytes on the wire | After a round-trip | |
| ------------------- | ------------------- | --------------------------- |
| {"n":1.0} | {"n":1} | number formatting |
| {"n":1e3} | {"n":1000} | exponent expanded |
| {"s":"é"} | {"s":"é"} | escape resolved |
| {"2":"b","1":"a"} | {"1":"a","2":"b"} | integer-like keys reordered |
| {\n "a": 1\n} | {"a":1} | whitespace dropped |
Any one of those changes the digest, and a perfectly genuine delivery fails verification. Nothing warns you: the endpoint just starts returning 400, and it reads like a secret problem rather than a body-parsing problem.
So capture the body as a Buffer or a string before any JSON middleware
touches it, and hand that to the SDK. verifyWebhookSignature and
constructWebhookEvent accept a Buffer, a Uint8Array, or a string.
Rule of thumb: if your handler receives
req.bodyas an object, the bytes are already gone. Go back one layer.
Express
express.raw() on the webhook route only — leave express.json() for the
rest of the app:
import express from "express";
import { constructWebhookEvent, readWebhookHeaders } from "@rwa-registry/sdk";
const app = express();
app.post(
"/hooks/registry",
express.raw({ type: "application/json" }), // NOT express.json()
(req, res) => {
const { signature, timestamp } = readWebhookHeaders(req.headers);
try {
const event = constructWebhookEvent({
payload: req.body, // a Buffer, because of express.raw
signature,
timestamp,
secret: process.env.RWA_REGISTRY_WEBHOOK_SECRET!,
});
res.sendStatus(200); // answer first — the sender waits 10s
queueMicrotask(() => process(event));
} catch {
res.sendStatus(400);
}
},
);
// Everything else still gets parsed JSON.
app.use(express.json());NestJS
Nest applies express.json() globally, so the route-level trick above does not
help. Capture the bytes in json()'s verify hook instead, which runs with the
raw buffer in hand, and stash it on the request.
src/common/middleware/rawBody.middleware.ts
import { Response } from "express";
import { json } from "body-parser";
import type RequestWithRawBody from "./requestWithRawBody.interface";
/** Paths whose exact bytes must survive parsing, for signature verification. */
const RAW_BODY_PATHS = ["/hooks/registry"];
export default function rawBodyMiddleware() {
return json({
limit: "10mb",
// `verify` runs before the JSON is parsed and is handed the raw buffer.
// Copying it here is the only chance to keep the exact bytes.
verify: (
request: RequestWithRawBody,
_response: Response,
buffer: Buffer,
) => {
if (RAW_BODY_PATHS.includes(request.url) && Buffer.isBuffer(buffer)) {
request.rawBody = Buffer.from(buffer);
}
return true;
},
});
}src/common/middleware/requestWithRawBody.interface.ts
import { Request } from "express";
export default interface RequestWithRawBody extends Request {
rawBody: Buffer;
}src/main.ts — order is everything:
// MUST come before express.json(), or the bytes are gone by the time
// `verify` would have run.
app.use(rawBodyMiddleware());
app.use(express.json({ limit: "10mb" }));src/webhooks/webhooks.controller.ts
import {
BadRequestException,
Controller,
Headers,
HttpCode,
Logger,
Post,
Req,
} from "@nestjs/common";
import {
SignatureVerificationError,
constructWebhookEvent,
} from "@rwa-registry/sdk";
import type RequestWithRawBody from "../common/middleware/requestWithRawBody.interface";
@Controller("hooks")
export class WebhooksController {
private readonly logger = new Logger(WebhooksController.name);
// A webhook is authenticated by its signature, not by your session or a
// CSRF token — exempt it from both, or the request never reaches you.
@Public()
@SkipCsrf()
@HttpCode(200)
@Post("registry")
async handle(
@Headers("x-webhook-signature") signature: string,
@Headers("x-webhook-timestamp") timestamp: string,
@Req() request: RequestWithRawBody,
): Promise<void> {
// Missing rawBody means middleware order, not a bad delivery. Say so —
// it is otherwise indistinguishable from a signature failure.
if (!request.rawBody) {
this.logger.error(`rawBody missing for ${request.url}`);
throw new BadRequestException(
"Webhook payload not captured. Check middleware order.",
);
}
try {
const event = constructWebhookEvent({
payload: request.rawBody,
signature,
timestamp,
secret: process.env.RWA_REGISTRY_WEBHOOK_SECRET!,
});
this.logger.log(`${event.eventType} ${event.fileID}`);
// Deliveries are retried, so key your work on fileID and make it
// idempotent. Hand anything slow to a queue and return now.
} catch (error) {
if (error instanceof SignatureVerificationError) {
throw new BadRequestException("Invalid webhook signature.");
}
throw error;
}
}
}If your Nest app has global
JwtAuthGuard/CsrfGuardproviders, the@Public()and@SkipCsrf()decorators are what let the delivery through. Without them every webhook gets a 401 or 403 before your handler runs.
Runnable versions of all three files are in
examples/nestjs/.
Next.js, Fastify, Hono
| Framework | Getting the raw bytes |
| ---------------------- | ------------------------------------------------------------------------------------------------------------- |
| Next.js (App Router) | await request.text() in the route handler — do not call request.json() first |
| Next.js (Pages Router) | export const config = { api: { bodyParser: false } }, then read the stream |
| Fastify | addContentTypeParser('application/json', { parseAs: 'buffer' }, …), or rawBody via fastify-raw-body |
| Hono / Workers | await c.req.text() |
| Node http | Concatenate the data chunks yourself — see examples/webhook-receiver.ts |
Rotating the secret
The signing secret is per workspace, on the console API access page. Deliveries
older than five minutes are rejected as replays — pass toleranceMs to widen
that. Return 200 within 10 seconds; failures are retried for about 17 hours.
After a rotation the previous secret keeps verifying for 24 hours, so retries already in flight still check out. Pass both for that window:
constructWebhookEvent({
payload: rawBody,
signature,
timestamp,
secret: [
process.env.RWA_REGISTRY_WEBHOOK_SECRET!,
process.env.RWA_REGISTRY_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean) as string[],
});Errors
Every failure is a typed subclass of RWARegistryError, carrying status,
path, and the server's message:
import {
NotFoundError,
RateLimitError,
PermissionError,
} from "@rwa-registry/sdk";
try {
await registry.files.get(fileID);
} catch (error) {
if (error instanceof NotFoundError) return null;
if (error instanceof PermissionError) alertOps("key scope too narrow");
throw error;
}| Class | Status |
| ---------------------------- | -------------------------------------- |
| BadRequestError | 400 |
| AuthenticationError | 401 |
| PermissionError | 403 |
| NotFoundError | 404 |
| ConflictError | 409 |
| RateLimitError | 429 — carries retryAfterMs |
| ServerError | 5xx |
| ConnectionError | never reached the server, or timed out |
| SignatureVerificationError | a webhook failed verification |
Retries
Rate limits are retried automatically, honouring Retry-After. Transient
network and 5xx failures are retried only on GETs.
A state-changing call is not replayed after an ambiguous network or server
failure. A timed-out publish may still have been accepted, so inspect the file
list before starting a new upload. Reusing the same accepted rID is
idempotent, but the SDK does not guess how your workflow should recover.
new RWARegistry({
apiKey,
maxRetries: 5,
onRetry: ({ attempt, delayMs, path }) =>
logger.warn({ attempt, delayMs, path }, "retrying"),
});Configuration
| Option | Default | |
| ------------ | ---------------------------------- | ---------------------------------------- |
| apiKey | process.env.RWA_REGISTRY_API_KEY | Required |
| baseUrl | https://rwa-api.a-registry.org | For staging or self-hosted |
| timeoutMs | 60000 | Per request |
| maxRetries | 2 | |
| headers | {} | Added to every request |
| fetch | global fetch | Inject a proxy agent, or a stub in tests |
| onRetry | — | Called before each retry |
Per-call overrides go in a trailing options argument on any method:
await registry.files.get(fileID, {
timeoutMs: 5_000,
signal: controller.signal,
});API
workspaces — get(), update(name)
files — calculatePrice(bytes), createUploadTarget(), upload(target, input),
publish(params), uploadAndPublish(params), get(id), list(params?),
iterate(params?), update(id, params), move(id, folderID?)
folders — list(params?), iterate(params?), create(params),
update(id, name), move(id, parentID?), delete(id)
webhooks — list(), create(url), delete(webhookID)
Rate limits
Set per endpoint by the server. The heaviest is publishing, at 10/minute.
Reads are 60/minute and most writes 30/minute. A 429 comes with Retry-After,
which the client waits out for you.
Licence
MIT
