@spoolis/receipt-verifier
v0.1.9
Published
Verify signed Spoolis Outcome Receipts offline. Spoolis is Outcome infrastructure for machine commerce.
Maintainers
Readme
Verify a Spoolis outcome receipt
import { verifyOutcomeReceipt } from '@spoolis/receipt-verifier'
const result = await verifyOutcomeReceipt(receipt, { trustSet })
console.log(result.valid)
console.log(receipt.amounts.earned)Install
npm install @spoolis/receipt-verifierNo Spoolis account is required to verify a receipt. The package has no runtime dependencies and uses WebCrypto in Node 20 or later, browsers, workers, and agent sandboxes.
Require an outcome
requireOutcome verifies the full receipt before applying the consumer's policy. This example acts only on a valid Outcome with a passing result, at least 100 accepted units, and fully satisfied evidence requirements:
import { requireOutcome } from '@spoolis/receipt-verifier'
const required = await requireOutcome(receipt, {
minimum_status: 'pass',
min_accepted_units: 100,
evidence_requirements: 'satisfied',
}, { trustSet })
if (!required.ok) throw new Error(required.reasons.join(', '))A partial batch is result: 'partial', not pass, so minimum_status: 'pass' refuses an 8-of-10 job even though value was earned. To act on partial batches, gate on the units and the money instead: set minimum_status: 'partial' (or omit it) and require min_accepted_units or min_earned. The receipt's amounts.earned already excludes rejected and uncertain units.
Three more policy keys cover common consumer rules:
required_conditions: ['deploy_gate']requires each listed condition id to appear incondition_resultswithresult: 'pass'. A failing condition addsrequired_condition_failed:<id>; a condition the receipt never evaluated addsrequired_condition_missing:<id>.max_uncertain_units: 0refuses a unitized receipt whoseunits.uncertainexceeds the cap (too_many_uncertain_units). Zero is the no-uncertain gate. A receipt without units addsunits_absent.min_accepted_ratio: 0.8requiresunits.accepted / units.totalto reach the ratio (a decimal between 0 and 1), elseaccepted_ratio_below_minimum. A receipt without units addsunits_absent.
The policy deliberately stops there: no boolean expressions, no cross-receipt logic, and no predicates over evidence content. Consumers with richer rules read the receipt fields directly and decide in their own code.
The optional third argument accepts the same verification options as verifyOutcomeReceipt, including trustSet, environment, statusSource, and now. The issuer policy matches the verified receipt.signing_key_id. Spoolis supplies the signed fact; the consumer decides the rule and whether to act. Forward supersession cannot be determined from an offline receipt, so consumers that need current correction or revocation state should provide the hosted status response through statusSource.
Verification remains offline by default. Set require_current: true only when the consumer must fail closed without current status, and pass a statusSource fetched from GET /api/v1/receipts/{receipt_id}/status after mapping its state to the verifier status shape.
Reading partial, unitized results
A partial delivery is not "nothing happened." An 8-of-10 job has
result: 'partial', eight accepted units, and real earned value.
Helpers read a receipt you have ALREADY verified:
import { acceptedUnits, rejectedUnits, uncertainUnits, earnedAmount } from '@spoolis/receipt-verifier'
const accepted = acceptedUnits(receipt) // published unit rows with verdict pass, or null
const retry = rejectedUnits(receipt) // rows to retry, or null
const unknown = uncertainUnits(receipt) // rows excluded from earned value, or null
const earned = earnedAmount(receipt) // decimal string, or nullThe per-unit helpers return null when the receipt does not publish
per-unit rows; null means "not published," never "zero units."
Counts are always available on receipt.units directly.
A common integration trap: gating unitized work with
requireOutcome(receipt, { minimum_status: 'pass' }) refuses every
partial batch even though value was earned and accepted units exist.
A workflow that should act on partial results inspects the units and
the money instead: set minimum_status: 'partial' (or omit it), then
gate with min_accepted_units, min_accepted_ratio, or min_earned,
and route rejected units to retry. Aggregate status, the accepted
unit set, earned value, and your downstream policy are four different
reads; do not collapse them into one boolean.
Acting on an Outcome
These helpers shape data from a receipt you have already verified. They do not execute an action or call a payment rail.
import { acceptedUnitsExport, holdList, retryList, settlementInstruction } from '@spoolis/receipt-verifier'
const continueWith = acceptedUnitsExport(receipt)
const retry = retryList(receipt)
const hold = holdList(receipt)
const settlement = settlementInstruction(receipt, statusSource)Continue from accepted units, retry rejected units, and hold uncertain units for escalation. settlementInstruction passes through the receipt's earned decimal string and refuses demo receipts or a provided status that is not active. The consumer owns action and payment policy.
Additive fields within @1
payment_binding(optional, added 2026-09-03):{ "kind": "x402_payment_identifier", "id": "<16-128 chars, [A-Za-z0-9_-]>" }. Binds the receipt to the buyer-minted x402 payment-identifier it was purchased under; the seller copies the id it actually charged against into the signed payload. A binding-aware consumer verifies the signature first, then matchespayment_binding.idagainst the id it paid with; consumers that do not read the field see no change. Golden vectors:payment-binding.json,payment-binding-tampered.json.
Schema compatibility policy
The receipt schema (spoolis/outcome-receipt@1) follows these rules
so consumers can wire several systems to an Outcome without fearing
shape changes:
- Additive-only within
@1: existing field names, types, and semantics do not change; new OPTIONAL fields may be added. - Verifiers ignore unknown fields; never fail on a field you do not recognize.
- The stable core is:
schema,id,environment,agreement,verification,evidence_root,condition_resultsrow shape,result,unitscounts andearning_rule,amounts,actors,issued_at,nonce,algorithm,signing_key_id,signature, andsupersedes. - Every schema addition ships in the same change as new golden vectors in the public corpus, and this package's README documents the addition.
- A breaking change means a new schema identifier (
@2), a documented migration note, and a support window in which this verifier accepts both.
Trust material
Spoolis publishes receipt trust material at https://spoolis.com/.well-known/spoolis-keys.json. Pin the receipt entries you accept and pass that array as trustSet for offline verification. fetchTrustSet(url, environment) is an online convenience that returns only the selected environment's receipt trust entries. The environment defaults to production; pass demo explicitly for sandbox receipts. Fetching at verification time is not required or preferred for a pinned deployment.
Offline verification and online status
Signature validity is offline and permanent when a verifier has the receipt and a pinned trust set. It does not depend on Spoolis uptime. Online status is optional:
import { fetchReceiptStatus, verifyOutcomeReceipt } from '@spoolis/receipt-verifier'
const offline = await verifyOutcomeReceipt(receipt, { trustSet })
if (!offline.valid) throw new Error(offline.reasons.join(', '))
const statusSource = await fetchReceiptStatus('https://spoolis.com', receipt.id)
const result = await verifyOutcomeReceipt(receipt, { trustSet, statusSource })GET /api/receipts/{id}/status currently returns active for a stored receipt. The response reserves corrected, revoked, advisory entries, and superseding_receipt_id for future workflows. Without a status source, verification stays offline-only and includes the status_source_unavailable advisory. A receipt can remain cryptographically valid even if a future online status marks it superseded.
A later resolution never mutates the original receipt. It produces a new signed Outcome that supersedes it. The supersedes block identifies the original receipt and may use dispute_resolution when agreed dispute handling produced the successor.
Reasons
| Reason | Meaning |
| --- | --- |
| unknown_schema | The receipt schema version is not supported. |
| environment_mismatch | The receipt environment does not match the requested environment. |
| invalid_evidence_root | The evidence root is not a lowercase SHA-256 digest. |
| invalid_run_digest | The verification run digest is malformed. |
| invalid_agreement_hash | The agreement hash is malformed. |
| receipt_id_mismatch | The content-derived receipt ID does not match the payload. |
| earned_amount_mismatch | Per-unit arithmetic does not equal the earned amount. |
| aggregation_inconsistent | A recognized aggregation policy does not produce the stated result. |
| corrected | The online status source marks this receipt corrected. |
| revoked | The online status source marks this receipt revoked. |
| untrusted_signing_key | No recognized, non-revoked trust entry matches the signing key. |
| signing_key_id_mismatch | The trust entry public key does not hash to the signing key ID. |
| invalid_signature | The Ed25519 signature does not verify. |
| webcrypto_unavailable | The runtime has no WebCrypto (globalThis.crypto.subtle). Use Node 20 or later, or supply a WebCrypto polyfill. |
| malformed_receipt | A required structure or value is malformed. |
Advisories
| Advisory | Meaning |
| --- | --- |
| unknown_aggregation_policy | The verifier does not know how to recompute this policy. |
| past_execute_by | The optional execution deadline has passed. This does not expire the receipt. |
| status_source_unavailable | No corrected or revoked status source was supplied. |
| key_retiring | The receipt uses a trusted key marked as retiring or retired. |
| key_outside_validity_window | The receipt's issue time falls before the trusted key's valid_from or after its valid_to. This does not invalidate the receipt. |
Security model
A valid result proves that the receipt payload was signed by a key in the supplied trust set. It proves that the content-derived ID matches the signed content. It checks digest formats, recognized aggregation rules, and unit arithmetic. It also enforces the selected production or demo environment. Verification does not prove that referenced evidence is true or available. It does not resolve actor identities. It does not grant payment authority. It does not prove authorization, capture, settlement, or finality on any payment rail. Consumers must enforce their own authorization and consume-once rules before taking economic action. Pin trust material through a trusted channel and review key rotation policy. Use an online status source when corrected or revoked state matters.
Golden vectors
The conformance corpus is maintained at tests/fixtures/receipt-vectors/ in the Spoolis repository. The package test runs every declared vector against the standalone implementation.
