On a long-lived Node process you can fire anchorAuditRecord() after the
response and trust the event loop to finish the work. On Vercel Functions
(and similar serverless runtimes) that is not true: the isolate can freeze or
exit as soon as the HTTP response is sent, and a plain fire-and-forget promise
is aborted mid-flight. The paid client still got its 200; the CID never lands.
This guide shows the pattern that keeps the Audit Trail POST alive until it
settles: hash and sign synchronously, return the response, then schedule the
anchor with waitUntil from @vercel/functions.
Confirmed against GET /api/audit/info on the public nodes:
- The node anchors a sha-256 hash (or a small JSON
record) to IPFS and returns a CID. It does not open a Stellar transaction for the anchor. - The document's
networkfield is set by which box you called (stellar:testnetonhttps://nirium-agent.fly.dev,stellar:pubnetonhttps://nirium-agent-mainnet.fly.dev). Anetworkfield in the request body is ignored. - Pricing is free during beta, rate-limited. A Nirium API key is optional for
this path; the TypeScript
Agentconstructor still requires anapiKeystring field, so passprocess.env.NIRIUM_API_KEY ?? ''(or omit the header and callPOST /api/audit/logyourself).
Anchor a hash, not personal data: IPFS content cannot be deleted.
// Fragile on Vercel: the isolate may freeze right after res.json(...).
void agent.anchorAuditRecord({ hash }).catch(console.warn);
res.status(200).json(payload);Typical failure mode: the client receives the paid body, your logs show nothing
useful, and GET on the IPFS gateway never finds a CID. The work was scheduled
but never completed.
Install:
npm install nirium @stellar/stellar-sdk@16 @vercel/functionsimport { createHash } from 'node:crypto';
import { waitUntil } from '@vercel/functions';
import { Keypair } from '@stellar/stellar-sdk';
import { Agent } from 'nirium';
const STATEMENT_PREFIX = 'nirium-audit-v1:';
function sha256Hex(text: string): string {
return createHash('sha256').update(text, 'utf8').digest('hex');
}
function scheduleAnchor(task: Promise<unknown>): void {
// Always attach a catch so a rejected anchor cannot become an unhandled
// rejection, and so failures show up in platform logs.
const tracked = task.catch((err: unknown) => {
console.warn('[audit]', err instanceof Error ? err.message : String(err));
});
// Synchronous import of waitUntil: a dynamic import() after the response
// can resolve too late on Vercel and the POST is aborted.
waitUntil(tracked);
}
export async function handler(/* req */) {
// Example paid response body — replace with your own. Hash this, not PII.
const body = {
ok: true,
generatedAt: new Date().toISOString(),
// market / route fields...
};
const hex = sha256Hex(JSON.stringify(body));
const secret = process.env.AUDIT_SIGNER_SECRET;
if (!secret) {
// Prefer failing closed on attestation rather than anchoring unsigned by
// accident. Adjust if you intentionally want unsigned anchors.
throw new Error('AUDIT_SIGNER_SECRET is required');
}
const signer = Keypair.fromSecret(secret);
const signature = Buffer.from(
signer.sign(Buffer.from(`${STATEMENT_PREFIX}${hex}`, 'utf8')),
).toString('base64');
const agent = new Agent({
apiKey: process.env.NIRIUM_API_KEY ?? '',
// Pick the box that matches the network you are operating on.
baseUrl:
process.env.NIRIUM_BASE_URL ?? 'https://nirium-agent.fly.dev',
});
// Return first. Anchoring must not delay or fail the paid response.
const response = {
statusCode: 200,
headers: {
'content-type': 'application/json',
// Optional: let the client correlate the body with the later CID.
'x-audit-hash': `sha-256:${hex}`,
},
body: JSON.stringify(body),
};
scheduleAnchor(
agent.anchorAuditRecord({
hash: `sha-256:${hex}`,
tag: process.env.AUDIT_TAG ?? 'serverless-receipt',
agent: {
key: signer.publicKey(),
signature,
id: (process.env.AUDIT_AGENT_ID ?? 'api').slice(0, 64),
},
}).then((anchor) => {
console.info('[audit] anchored', {
cid: anchor.cid,
contentSha256: anchor.contentSha256,
gatewayUrl: anchor.gatewayUrl,
});
}),
);
return response;
}| Piece | Source of truth |
|---|---|
Agent.anchorAuditRecord(options) |
packages/sdk/src/index.ts → POST /api/audit/log |
options.hash |
sha-256 hex, optionally with a sha-256: prefix |
options.tag |
free-form label |
options.agent.key / signature / id? |
ed25519 over utf8 nirium-audit-v1:<content_sha256> (64 lowercase hex, no sha-256: prefix inside the statement) |
Return: cid, contentSha256, gatewayUrl?, anchoredAt, attestedBy? |
AuditAnchor |
Invalid agent signatures are rejected with HTTP 400 and nothing is anchored.
Keep anchoring failures off the critical path:
- Wrap the promise in
.catch(...)before handing it towaitUntil. - Log a short message and the CID on success; never log the signer secret or the full signature.
- Treat HTTP 429 from the node as back-pressure (free beta is rate-limited). Do not busy-retry inside the same invocation; drop or defer that receipt.
- Treat 5xx / network timeouts as retryable once if you want; treat 400 as a bug in your hash/signature construction (do not retry blindly).
A paid x402 response should still return 200 even when the anchor fails. The
receipt is an integrity seal, not a gate on delivery.
Local Node, unit tests, and some hosts keep the process alive until the event
loop drains. On those platforms you can still schedule the same promise — just
skip waitUntil:
import { waitUntil } from '@vercel/functions';
function scheduleAnchor(task: Promise<unknown>): void {
const tracked = task.catch((err: unknown) => {
console.warn('[audit]', err instanceof Error ? err.message : String(err));
});
if (typeof waitUntil === 'function') {
waitUntil(tracked);
return;
}
// Outside Vercel: leaving the promise attached (with a catch) is enough for
// a long-lived process. Do not replace this with an empty void without a
// catch — failures become silent.
}If you are not on Vercel at all, prefer awaiting the anchor in a background worker / queue rather than pretending fire-and-forget is durable.
Verification does not need a Nirium API key. After you have the CID (from logs, or from a small hash→CID store you keep yourself):
- Inspect node limits and disclaimer:
curl -s https://nirium-agent.fly.dev/api/audit/info
# mainnet box:
curl -s https://nirium-agent-mainnet.fly.dev/api/audit/info- Fetch the IPFS document and check
content_sha256against your original hex:
curl -s "https://gateway.pinata.cloud/ipfs/<CID>"- Or use the CLI (hash-only anchors need
--hashif you want the hash checked):
npx --yes nirium-cli verify <CID> --hash <64-lowercase-hex> --jsonThe CLI recomputes the signed statement as nirium-audit-v1:<hash> and verifies
the ed25519 signature against agent.key. See also
actions/verify-audit-cid for the same model in CI.
| Variable | Purpose |
|---|---|
AUDIT_SIGNER_SECRET |
Dedicated Stellar secret used only to sign audit statements. Never reuse a pay-to / treasury key. |
NIRIUM_BASE_URL |
Audit node (https://nirium-agent.fly.dev or https://nirium-agent-mainnet.fly.dev) |
NIRIUM_API_KEY |
Optional during free beta; still accepted by Agent |
AUDIT_TAG / AUDIT_AGENT_ID |
Optional labels on the anchored document |
Never commit real secrets. Use env vars / your platform's secret store only.
- SDK Audit Trail surface:
packages/sdk/README.md - Hash-only + signature verification in CI:
actions/verify-audit-cid - x402 on Vercel (payment side, not audit):
examples/deploy-x402-vercel