Skip to content

TypeScript SDK

@halvorai/ingest is the TypeScript and JavaScript client. It is open source (Apache-2.0) and works without a HalvorAI account if you give it a sink. It needs Node.js 20 or later.

Terminal window
npm install @halvorai/ingest
  • Never in the payment path. emit() returns at once with an event id, never throws, and never waits on the network.
  • Fail quietly, report loudly. Delivery problems are retried, optionally kept on disk, and reported to onError and stats(). They never reach your payment code.
  • Nothing sensitive leaves the machine. Card numbers, CVCs, secret keys and private keys are stripped from text and JSON evidence, and instruction text is hashed locally.
import { HalvorClient } from '@halvorai/ingest';
const halvor = new HalvorClient({
apiKey: process.env.HALVOR_API_KEY, // hk_test_... or hk_live_...
sourceKey: process.env.HALVOR_SOURCE_KEY, // the private JWK that `init` created
onError: (error) => logger.warn({ error }, 'halvorai'),
});

Create one client per process and reuse it. With no options, the client reads HALVOR_API_KEY and HALVOR_SOURCE_KEY from the environment.

OptionDefaultPurpose
apiKeyHALVOR_API_KEYThe bearer key. Its prefix sets the environment and the default endpoint.
sourceKeyHALVOR_SOURCE_KEYPrivate Ed25519 JWK, as a JSON string or an object. Its kid is your source id.
environmentfrom the keytest or live. Needed when you use a sink without a key.
endpointfrom the keyOverride the API base URL.
sinknoneReceive signed envelopes yourself instead of sending them.
spoolDirnoneDirectory for batches that could not be delivered.
onErrornoneCalled with a structured error. See below.
redactnone{ extraPatterns } to remove more text, for example employee ids.
batchSize100Events per request. The API accepts at most 100.
flushIntervalMs1000How long events wait to be batched.
retryDelaysMs[1000, 2000, 4000, 8000, 16000, 32000]Backoff between retries.
exitHooktrueFlush for up to two seconds when the process is about to exit.
fetchglobal fetchA custom fetch, for proxies and tests.

A missing or malformed key never throws. The client reports a config error once, logs a warning, and drops events quietly so your application keeps running.

const id = halvor.emit({
type: 'payment.settled',
payment: {
payment_id: 'pi_3QxAbCdEfGhIjKlM',
rail: 'stripe',
amount: { value: '41200', unit: 'minor', currency: 'USD' },
status: 'settled',
rail_ref: { payment_intent: 'pi_3QxAbCdEfGhIjKlM' },
},
payee: { type: 'merchant', name: 'Example Airways' },
});

emit fills in ape_version, id, occurred_at, environment (from the key), source, and the agent, owner and instruction from the agent context. Pass any of them to override. Then it copies your input, and in the background redacts it, validates it against the schema, signs it, and queues it.

An invalid event is not sent. It is reported to onError as invalid_event with the failing paths, and counted in droppedInvalid.

import { withAgentContext, currentContext } from '@halvorai/ingest';
await withAgentContext({ agent: { id: 'agt_1' }, owner: { id: 'own_1', type: 'business' } }, async () => {
halvor.emit({ /* ... */ });
});

See attribute payments to agents.

The client sends events in batches of up to 100, at most once a second or as soon as a batch is full.

ResponseWhat happens
Network error, 5xx, 408, 429Retried with backoff, up to six retries, which covers an outage of about a minute.
413The batch is split in half and each half is sent.
400, 401, 403, 422The batch is dropped and reported. Retrying cannot help.
202 with a rejected itemThat event is reported to onError with the server’s code. It is never retried.

If delivery still fails after the retries, a client with spoolDir writes the signed batch to a file there and replays it the next time it can, including after a restart. Without spoolDir, the batch is dropped and reported. Use a spool directory in any service where losing a few events during a long outage matters.

Call flush() before a short-lived process ends, such as a serverless function or a script. It resolves when the queue is empty or the time cap passes, and tells you which.

const { drained } = await halvor.flush({ timeoutMs: 5000 });
await halvor.close(); // stops timers; later emits are ignored and counted

onError receives { code, message, eventId?, serverCode?, details? }. Codes: invalid_event, rejected, unauthorized, delivery_failed, spool and config.

halvor.stats() returns counters you can export to your metrics: emitted, queued, accepted, duplicates, rejected, dropped, droppedInvalid, droppedDisabled, droppedClosed, retries, spooled and replayed. Alert on dropped and rejected going up.

const halvor = new HalvorClient({
sourceKey: process.env.HALVOR_SOURCE_KEY,
environment: 'test',
sink: async (envelopes) => {
await myStore.insert(envelopes); // each is { event, sig }
},
});

The sink gets the same signed envelopes the API would. Failures are retried like an outage. You can store APE events anywhere and still produce a verifiable record.

Adapters live under their own paths, so you only load what you use: @halvorai/ingest/stripe, /x402, /mpp, /mcp and /evidence.

Next: the guide for your rail, Stripe, x402, MPP or MCP.