Draft integrity and hashing

Every draft has a core that never changes after creation, and every fiat-priced draft has quotes that never change once recorded. send21 publishes a SHA-256 hash of each, so you can sign, store or compare a draft without reimplementing our rules.

The core never changes

The draft response has a core object and its hash coreSha256. Changing any core field is refused, by the API and by the database itself. A different payment means a new draft with a new id.

Core fields: id, network, currency, paymentMethod, receiverAddress, tokenAddress, lightningAddress, bolt11 (pasted invoices only), amountBaseUnits (drafts priced in an exact crypto amount; null when priced in fiat), fiatAmount, fiatCurrency, memo, shortfallAbsorbPercent, paymentRequestId, createdAt, expiresAt, plus schema (send21.draft.core.v1).

Everything else is state and may change: status, paidTxId, requiredConfirmations, confirmations, amountMismatch and its acceptance, updatedAt, and the current quote of a fiat-priced draft.

Quotes

A fiat-priced draft converts its fiat price at a rate locked for 15 minutes. That lock is a quote: quote in the draft response, with id, draftId, rate, rateSource, fiatAmount, fiatCurrency, cryptoAmountBaseUnits, platformFeeBaseUnits, network, currency, tokenAddress, bolt11 (Lightning-address drafts), lockedAt, lockedUntil, schema (send21.draft.quote.v1) and sha256. source and expiresAt are older names for rateSource and lockedUntil, kept for existing clients and hashed like every other member.

A quote never changes. Re-quoting after the lock (POST /drafts/{id}/requote) records a new quote with a new id and makes it current; earlier quotes stay readable at GET /drafts/{id}/quotes. Payment webhooks carry quoteId: the quote the payment was matched against.

Drafts priced in an exact crypto amount have no quote; their amount is in the core.

How to hash

  • Take the core object (or a quote without its sha256 member) exactly as the API returns it, every member included, nulls too.
  • Canonicalize it with RFC 8785 (JCS): members sorted by key, no whitespace, strings and numbers written the way JavaScript JSON.stringify writes them.
  • Hash the UTF-8 bytes with SHA-256 and compare the lowercase hex with coreSha256 or quote.sha256.
  • Timestamps are strings in one fixed form everywhere (REST, MCP, webhooks): UTC, exactly three fraction digits, Z, like 2026-10-12T08:00:00.123Z.
  • Hash base units (amountBaseUnits, cryptoAmountBaseUnits), never amountFormatted or amountBtc: those are display strings.
const jcs = (o) => JSON.stringify(Object.fromEntries(Object.keys(o).sort().map((k) => [k, o[k]])));
const sha256 = (s) => crypto.createHash('sha256').update(s, 'utf8').digest('hex');

const draft = await (await fetch('https://send21.io/api/v1/drafts/' + id, { headers })).json();
const coreOk = sha256(jcs(draft.core)) === draft.coreSha256;
const { sha256: quoteHash, ...quote } = draft.quote ?? {};
const quoteOk = !draft.quote || sha256(jcs(quote)) === quoteHash;

Test vectors

Core of a fiat-priced draft (25 EUR paid in USDC on Base):

// canonical form (JCS), one line:
{"amountBaseUnits":null,"bolt11":null,"createdAt":"2026-10-12T08:00:00.123Z","currency":"Usdc","expiresAt":"2026-10-13T08:00:00.123Z","fiatAmount":25,"fiatCurrency":"EUR","id":"3f2a1c4e-0000-4000-8000-000000000001","lightningAddress":null,"memo":"Invoice 2026-001","network":"Base","paymentMethod":"MultiOutput","paymentRequestId":null,"receiverAddress":"0x52908400098527886E0F7030069857D2E4169EE7","schema":"send21.draft.core.v1","shortfallAbsorbPercent":null,"tokenAddress":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"}
// SHA-256:
327bb193ff1cfdf2ecf25a89fb0f4a99b65aefa39524799ec43c6adab9c833d7

Quote test vector

The quote that belongs to it:

// canonical form (JCS), one line:
{"bolt11":null,"cryptoAmountBaseUnits":27982986,"currency":"Usdc","draftId":"3f2a1c4e-0000-4000-8000-000000000001","expiresAt":"2026-10-12T08:15:00.123Z","fiatAmount":25,"fiatCurrency":"EUR","id":"3f2a1c4e-0000-4000-8000-000000000002","lockedAt":"2026-10-12T08:00:00.123Z","lockedUntil":"2026-10-12T08:15:00.123Z","network":"Base","platformFeeBaseUnits":0,"rate":0.8934,"rateSource":"coinbase","schema":"send21.draft.quote.v1","source":"coinbase","tokenAddress":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"}
// SHA-256:
67de0e66b1a138b86e5b06e7b2fb63c4ad9a21d38f3ef6bb2eb47277dd1f3481

Expiry, rate locks and different amounts

  • A draft's expiry is set once, when it is created (expiryHours, 1 to 720, default 24), and never moves. A re-quote only renews the rate lock within that life.
  • The rate lock (quote) lasts 15 minutes. Pay before quote.lockedUntil, or re-quote first.
  • A transfer within 10% of the billed amount, over or under, is recorded and sent as draft.amount_mismatch. It is never confirmed on its own: the receiver accepts it with POST /drafts/{id}/accept-received, or leaves the draft unpaid. Transfers further off are not matched to the draft.

Related

Start building

Create a free account to get an API key, or try the whole flow first on test networks.

This page is also available as Markdown: /docs/draft-integrity.md. Questions: integration@send21.io.