# 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.

```js
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):

```json
// 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:

```json
// 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.

---
Source: https://send21.io/docs/draft-integrity
