@enruta/sdk
v0.3.0
Published
TypeScript client for the Enruta API: agents (payment requests, clarifications, identity, merchant checks, economics, recurrences, reversals, settlements, reconciliation, disputes, consents), sellers (orders, PSP connections, storefronts) and platforms (c
Maintainers
Readme
@enruta/sdk
TypeScript client for the Enruta API (v0). fetch only, no runtime dependencies, works in Node 18+ and any runtime with a global fetch. Version 0.3 covers agents (requests, clarifications, identity, merchant checks, the money split, recurrences, mandates, records and the objects around a payment), sellers (the seller API, PSP connections, hosted storefronts) and platforms (connected accounts, keys, feeds, brand, usage), plus billing, webhook signature verification and the UCP conformance runner. A Python client with the same surface is enruta in packages/sdk-python.
import { Enruta } from "@enruta/sdk";
const enruta = new Enruta(process.env.ENRUTA_AGENT_KEY!);
const r = await enruta.paymentRequests.create({
agentId: "agt_procurement_02",
payee: { name: "Ream & Rule", domain: "reamandrule.com" },
amount: { value: "168.40", currency: "USD" },
purpose: "20 reams A4 paper, 1 stapler",
checkout: { protocol: "ucp", reference: "chk_8f3…" },
intent: { text: "restock the print room, under $200", slots: { category: "office_supplies", max_total: "200.00", currency: "USD" } },
}, { idempotencyKey: "a3c9…" });
if (r.decision.result === "clarify") {
// Ask the user every question, then send their answers; the request is decided again.
const answered = await enruta.clarifications.answer(r.id, { answers: r.decision.questions!.map((q) => ({ question_id: q.id, answer: ask(q.question) })) });
}
if (r.decision.result === "allow" || r.decision.result === "observe") pay(r.mandate!.credential);Surface
| Method | Endpoint |
| --- | --- |
| paymentRequests.create(body, { idempotencyKey }) | POST /v0/payment-requests (body may carry intent, catalog_snapshot_hash, policy_snapshot_hash, deviations) |
| paymentRequests.get(id, { wait? }) | GET /v0/payment-requests/{id}?wait= (gains risk, reversals, settlement, reconciliation, disputes, fulfillment) |
| paymentRequests.handOff(id, { continue_url, order? }) | POST /v0/payment-requests/{id}/handoff |
| approvals.get(id) | GET /v0/approvals/{id} |
| mandates.list({ agent_id?, state? }), mandates.get(id) | GET /v0/mandates, GET /v0/mandates/{id} |
| records.list({ agent_id?, from?, to?, kind?, cursor?, limit? }) | GET /v0/records (kind filters payment and linked records) |
| records.get(id) → { id, text, record, kind } | GET /v0/records/{id} (canonical text, parsed object, record kind) |
| records.bundle(id), records.verify(id) | GET /v0/records/{id}/bundle, GET /v0/records/{id}/verify |
| records.linked(id) | GET /v0/records/{id}/linked (the records chained to one) |
| exports.create({ from, to, principal_login_id?, kinds?, consents? }), exports.get(id), exports.download(id) → Uint8Array | POST /v0/exports, GET /v0/exports/{id}, GET /v0/exports/{id}/download |
| reversals.create(body, { idempotencyKey? }), .get(id), .list(q), .adjustments(id, protocol) | POST /v0/reversals, GET /v0/reversals/{id}, GET /v0/reversals, GET /v0/reversals/{id}/adjustments?protocol= |
| settlements.get(recordId) | GET /v0/settlements/{record_id} |
| reconciliation.lines(q), .line(mandateId), .report(period), .buildReport(period), .signReport(period) | GET /v0/reconciliation/lines, …/lines/{mandate_id}, …/reports/{period}, POST …/reports/{period}/build, POST …/reports/{period}/sign (link signature) |
| disputes.list(q), .get(id), .packet(recordId, { regenerate? }), .representmentPdf(recordId) → Uint8Array, .recordOutcome(id, body) | GET /v0/disputes, GET /v0/disputes/{id}, GET|POST /v0/records/{id}/dispute-packet, GET /v0/records/{id}/representment.pdf, POST /v0/disputes/{id}/outcome |
| consents.create(body), .list(q), .get(id), .revoke(id, { effective_at?, cascades_scheduled? }) | POST /v0/consents, GET /v0/consents, GET /v0/consents/{id}, POST /v0/consents/{id}/revoke |
| attestations.create(body), .list(q) | POST /v0/attestations, GET /v0/attestations |
| agents.pause(id, reason), .resume(id) | POST /v0/agents/{id}/pause, POST /v0/agents/{id}/resume |
| sandbox.createKeys(), createSandboxKeys({ baseUrl? }) | POST /v0/sandbox/keys (no key needed) |
| sellers.orders.list(q), .get(id), .observe(body) | GET /v0/sellers/orders, GET /v0/sellers/orders/{id}, POST /v0/sellers/orders |
| sellers.verify(body) | POST /v0/sellers/verify |
| sellers.adjustments.report(body), sellers.reversals.request(body) | POST /v0/sellers/adjustments, POST /v0/sellers/reversals |
| sellers.fulfillments.confirm(body) | POST /v0/sellers/fulfillments |
| sellers.disputes.list(q), sellers.packet(recordId), sellers.representmentPdf(recordId) | GET /v0/sellers/disputes, GET /v0/sellers/records/{id}/dispute-packet, GET /v0/sellers/records/{id}/representment.pdf |
| sellers.reconciliation(period \| { period?, order_id? }) | GET /v0/sellers/reconciliation |
| sellers.conformance.run(endpoint, { domain? }), .list() | POST /v0/sellers/conformance, GET /v0/sellers/conformance |
| sellers.middleware(), sellers.domains.list(), .add(domain) | GET /v0/sellers/middleware, GET /v0/sellers/domains, POST /v0/sellers/domains |
| clarifications.answer(requestId, { answers, answered_by? }) | POST /v0/payment-requests/{id}/clarifications |
| merchants.identity(domain) | GET /v0/merchants/identity?domain= |
| attestations.nonce({ agent_id? }), attestations.create({ agent_id, anchor: { type, … } }) | POST /v0/attestations/nonce, POST /v0/attestations (identity anchors; the 0.2 registry body still works) |
| agents.identity(id) | GET /v0/agents/{id}/identity |
| economics.forPayment(requestId), sellers.orders.economics(orderId) | GET /v0/payment-requests/{id}/economics, GET /v0/sellers/orders/{id}/economics |
| recurrences.create, .list, .get, .update(id, { action }), .pause, .resume, .end, .occurrences | POST|GET /v0/recurrences, GET|PATCH /v0/recurrences/{id}, GET /v0/recurrences/{id}/occurrences |
| records.pspMetadata(id, psp) | GET /v0/records/{id}/psp-metadata?psp= |
| disputes.deliver(id, { target, connection_id? }) | POST /v0/disputes/{id}/deliveries |
| sellers.pspConnections.create, .list, .delete, .test | POST|GET /v0/sellers/psp-connections, DELETE /v0/sellers/psp-connections/{id}, POST …/{id}/test |
| storefronts.create, .list, .get, .update, .refreshCatalog, .orders | POST|GET /v0/storefronts, GET|PATCH /v0/storefronts/{id}, POST …/{id}/catalog/refresh, GET …/{id}/orders |
| platform.accounts.create, .list, .get, .update, .createKey | POST|GET /v0/platform/accounts, GET|PATCH /v0/platform/accounts/{id}, POST …/{id}/keys |
| platform.usage({ period }), platform.brand.get(), .update(body) | GET /v0/platform/usage?period=, GET|PUT /v0/platform/brand |
| platform.feeds.create, .list, .delete, .events(id, { after, limit }), .replay(id, { from }) | POST|GET /v0/platform/feeds, DELETE /v0/platform/feeds/{id}, GET …/{id}/events, POST …/{id}/replay |
| billing.subscription(), .plans(), .usage({ period }), .invoices.list(), .invoices.get(period) | GET /v0/billing/subscription, /plans, /usage, /invoices, /invoices/{period} |
| approvalDelegations.create, .list, .revoke | POST|GET /v0/approval-delegations, DELETE /v0/approval-delegations/{id} |
| org.settings.get(), .update(body), org.verification.start(body), .get() | GET|PATCH /v0/org/settings, POST|GET /v0/org/verification |
| policies.list(), policies.version(id, n) | GET /v0/policies, GET /v0/policies/{id}/versions/{n} |
| rails.list() | GET /v0/rails |
| me() → { agent_id, org_id, role } | GET /v0/me |
Options: new Enruta(key, { baseUrl = "https://api.enruta.ai", fetch, platform? }). Every request carries Authorization: Bearer <key>, User-Agent: enruta-sdk/0.3.0 and, when there is a body, Content-Type: application/json. Keys: enruta_agt_… (agent), enruta_dev_… (developer), enruta_aud_… (auditor), enruta_slr_… (seller), enruta_plt_… (platform).
Errors
Every non-2xx response throws EnrutaError { status, code, message, requestId?, details?, retryAfter? }, built from the { error: { code, message } } envelope (invalid_request, unauthenticated, forbidden, not_found, idempotency_conflict, rate_limited). A failed connection throws with status: 0 and code: "network_error".
A denied payment is not an error: create resolves with decision.result: "deny" and the reasons, and the denial has a record. The same holds for a denied reversal: reversals.create resolves with status: "denied" and decision.reasons.
Every decision says whether the same payment may be requested again: decision.retryable is true only when every reason that ended the request is remediable, and decision.retry_after says from when (over_budget: the next budget window), otherwise null. Never retry with another amount, payee or wording; a policy's retry_policy refuses attempts beyond its limit with retry_limit_reached.
Idempotency
paymentRequests.create requires idempotencyKey. Repeating a key returns the original decision with idempotent_replay added to decision.reasons; the same key with a different body is a 409 idempotency_conflict. reversals.create accepts an optional key the same way; the platform also dedupes reversals on dedupe_key.
Decisions in shadow mode
A policy with shadow: true answers decision.result: "observe" where it would have stepped up or denied: the payment goes through as an allow, and decision.shadow_result ({ result, reasons }) says what the policy would have done. Records carry both. decision.risk is the risk assessment made at decision time (action, score, checks).
Clarification
When the policy needs a detail the request did not give (how many, the most to spend, by when), create resolves with decision.result: "clarify", state: "awaiting_clarification" and decision.questions ([{ id, slot, question }]). Ask the user, then clarifications.answer(id, { answers: [{ question_id, answer }] }): the answers are recorded (answered_by: "user" unless you say otherwise) and the request is decided again. Unanswered past the policy's time limit, the attempt is sealed as expired with clarification_expired. Send the person's words in intent.text and what the agent understood in intent.slots to avoid most questions.
Identity
agents.identity(id) returns the agent's assurance level (A0 nothing verified, A1 a verified agent key, A2 plus a verified workload identity or hardware attestation, A3 plus a verified legal entity, or collateral on a crypto rail) and its anchors. Anchors are attested with attestations.create({ agent_id, anchor }): agent_key { jwk, proof } (a compact JWS by that key over { agent_id, nonce, iat }, with a nonce from attestations.nonce()), workload_identity { token } (an OIDC token for the audience https://api.enruta.ai; enruta attest workload does this inside GitHub Actions), code_digest, hardware_attestation, legal_entity, collateral. merchants.identity(domain) is the other direction: the check of a merchant before paying it.
Who earns what
economics.forPayment(requestId) (and sellers.orders.economics(orderId) for a seller) returns the money split: parties[] (user, agent platform, merchant, PSP, card network, issuer, Enruta) with pays, receives, net, how_it_earns and lines[], and flows[]. Every line has a basis: actual, estimate (with source and source_date) or plan. Estimates are never labelled actual.
Platforms: acting for a connected account
A platform key (enruta_plt_…) manages connected accounts with platform.accounts.* and acts for one with the platform option, which sets Enruta-Account (and Enruta-Agent):
const platform = new Enruta(process.env.ENRUTA_PLATFORM_KEY!);
const { account, agents } = await platform.platform.accounts.create({ kind: "agents", name: "Globex buyer", external_ref: "cust_9", agents: [{ name: "Buyer" }] });
const buyer = platform.forAccount("cust_9", { agent: agents[0]!.id }); // or new Enruta(key, { platform: { account: "cust_9", agent } })
await buyer.paymentRequests.create({ … }, { idempotencyKey });An unknown or suspended account answers 403 account_not_connected. Records made this way name the platform as agent.host.
Webhooks and feeds
Deliveries carry Enruta-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + body)>. await verifyWebhookSignature(rawBody, header, secret, { toleranceSeconds: 300 }) returns true or false (WebCrypto, constant-time, any v1 may match during a secret rotation); await constructWebhookEvent(rawBody, header, secret) returns the parsed event or throws EnrutaSignatureError. Verify the raw body before parsing it.
Reversals
reversals.create({ record_id, kind, amount?, reason_code, refund_to? }) asks for a void, cancel, refund, return, credit, price_adjustment or chargeback_credit against a payment record. A reversal is decided like a payment (decision.result allow, step_up or deny, with checks on the original amount, the stage, the finality class, the window and cancellable_until); the reply carries stage, mandate_effect (none, refill, refill_partial, refill_pending_settlement), status (requested, pending, completed, failed, denied) and fund_change. reversals.adjustments(id, "ucp" | "acp" | "adyen" | "shopify") renders it as that protocol's adjustments[] entry.
Linked records
Every supporting object (reversal, settlement record, reconciliation line and report, dispute packet and outcome, consent record, attestation, agent pause, seller-side agent order) is its own Evidence Record on the same chain, with kind and chain: { relation, of, of_hash } pointing at the record it supports. records.list({ kind }) filters by kind, records.linked(id) lists the records chained to one, and records.get(id).kind names what came back. exports.create({ kinds: "all", consents: true }) includes them in the zip.
Completing the checkout through Enruta
Pass complete_checkout: true with a UCP checkout and Enruta executes the checkout itself after allow, presenting the mandate and the single-use credential to the merchant; the credential stays with Enruta. The response then carries order ({ id, status, checkout_id, completed_at, permalink_url? }) and completion ({ state: "completed" | "failed" | "pending" | "not_requested", error? }); on failed the mandate stays issued.
Handing the checkout to a person
Some merchants accept no credential from an agent: Shopify completes a checkout only on its own page. Once the mandate is issued, call paymentRequests.handOff(id, { continue_url }) with the merchant's checkout link (https): the request moves to handed_off, the mandate to void, and the budget reservation is kept. When the person has paid, call it again with order: { id, status?, permalink_url?, total? }: the order becomes the receipt (self-asserted), outcome.status becomes completed and the record seals; the reservation is spent when total is given, released otherwise. Without an order the record seals 15 minutes after the hand-off. Every PaymentRequest carries outcome and handoff ({ continue_url, handed_off_at, order }); state gains handed_off.
Conformance runner
import { runConformance, CONFORMANCE_TESTS } from "@enruta/sdk";
const run = await runConformance("https://store.example.com", {
domain: "store.example.com",
platformProfileUrl: "https://app.enruta.ai/.well-known/agent-profile.json", // enables the two AP2 vectors
});
console.log(run.passed, "of", run.total, run.ok ? "green" : "failing");
for (const r of run.results) console.log(r.ok ? "ok " : "FAIL", r.name, r.detail);runConformance(endpoint, { domain?, fetch?, timeoutMs?, platformProfileUrl?, profileUrl?, productId?, cleanup? }) runs the 19 vectors of CONFORMANCE_TESTS against a UCP REST endpoint: discovery (profile, keys, capabilities, REST service), lifecycle (create, get, update with a shipping address and option, complete with the mock handler's success_token, order fetch), protocol (envelope version, version negotiation, error shape), idempotency (replay, conflict), mandate (AP2 negotiated, mandate_required without ap2.checkout_mandate; skipped unless platformProfileUrl is given), signature (ap2.merchant_authorization verifies with a profile key), receipt (payment_reference, X-Receipt-Signature when the merchant signs). Results are { name, ok, detail, skipped? }[] with passed, total (skipped vectors excluded) and ok. The runner presents a data: platform profile without AP2 so the mock handler can complete; merchants that only accept https profile URLs get profileUrl. One order is placed with the mock handler (no money moves); the other sessions are canceled. The platform's POST /v0/sellers/conformance runs the same suite and issues the badge.
CLI output schemas
CLI_OUTPUT_SCHEMAS (also @enruta/sdk/schemas) holds the JSON Schema (draft 2020-12) of what every enruta command prints with --json; enruta <command> --schema prints it.
Apache-2.0.
