Skip to content
Payvol

Reference

Reason codes

Every refusal names one of these. They are stable across a profile version, so an implementation in another language can branch on them and a conformance vector can pin them.

The codes

CodeWhat it means
MISSING_RECIPIENTA transfer with no recipient. Also what a URI with no path decodes to.
MISSING_INSTRUMENTA transfer that does not say what is being asked for.
MISSING_URLA dynamic request with no endpoint.
BAD_AMOUNTAn amount that is not a clean decimal string.
BAD_URL_SCHEMEA dynamic url that does not use https.
EXPIREDEvaluated after the request deadline.
UNKNOWN_TYPENeither transfer nor transaction.
UNKNOWN_VERSIONA profile this implementation does not implement.
BAD_SIGNATUREA signature present but not standard base64. Shape only.
DOMAIN_MISMATCHThe artifact’s own url host is absent from its own domains list.
INPUT_TOO_LARGEPast the transport bound, on the way in or on the way out.
MALFORMEDStructurally wrong: bad grammar, bad percent-encoding, an unreadable date, invalid Unicode.

What triggers each

Run against the published packages, with one input per code:

// Every reason code the packages can return, and one input that triggers it.
import { decode, validate, encode, PayvolReasonCode } from '@payvol/core';

const base = {
  version: '0.1',
  type: 'transfer',
  recipient: 'payee::1220aabbcc',
  instrument: 'DSO::1220be58/Amulet',
};
const show = (label, run) => {
  try {
    const r = run();
    console.log(label.padEnd(26), r && r.valid === false ? r.code : 'accepted');
  } catch (error) {
    console.log(label.padEnd(26), error.code ?? error.constructor.name);
  }
};

console.log('codes in the package:', Object.keys(PayvolReasonCode).length);
show('no recipient', () => validate({ ...base, recipient: undefined }));
show('no instrument', () => validate({ ...base, instrument: undefined }));
show('bad amount', () => validate({ ...base, amount: '12.5.0' }));
show('hour 24 is not a time', () => validate({ ...base, expiry: '2027-01-01T24:00:00Z' }));
show('url without a host', () =>
  validate({ version: '0.1', type: 'transaction', url: 'https://' }),
);
show('unknown type', () => validate({ ...base, type: 'refund' }));
show('bad uri grammar', () => decode('canton:'));
show('over the transport bound', () => encode({ ...base, message: '漢'.repeat(1000) }));
Output, captured from a real run
codes in the package: 12
no recipient               MISSING_RECIPIENT
no instrument              MISSING_INSTRUMENT
bad amount                 BAD_AMOUNT
hour 24 is not a time      MALFORMED
url without a host         MALFORMED
unknown type               UNKNOWN_TYPE
bad uri grammar            MISSING_RECIPIENT
over the transport bound   INPUT_TOO_LARGE

Output from @payvol/core version 0.4.0 and its sibling packages, installed from npm, on 2026-10-18.

Two lines in that output are worth reading twice. decode('canton:') answers MISSING_RECIPIENT rather than MALFORMED: the grammar parsed, and what was missing was the recipient. And an over-long request fails at encode with INPUT_TOO_LARGE, not at decode: a request can validate and still not fit the URI transport, which are separate questions with separate answers.

Why they are stable

A reason code is part of the artifact contract, not an implementation detail. Changing what a code means, or which one an input produces, changes what a conforming implementation must do, so it is a profile change rather than a patch. That is why the codes are enumerated in the package and pinned by vectors instead of being strings assembled at the point of failure.