# Consent receipts

> For each subscriber checkout where receipts are on, a tamper-evident record of the disclosure shown next to the pay button and the consent given — a structured snapshot, hashed, chained, signed and re-rendered as an image on demand. Evidence you can produce.

When a subscriber disputes a renewal — a chargeback, a demand letter, a class-action complaint — the question is always
the same: *what did this person see next to the button, and did they agree?* A consent receipt answers it for one
checkout: "on March 3 at 14:14 UTC, this disclosure text was rendered visibly 12px above the **Start subscription**
button, its smallest text 16px at 17:1 contrast, and the subscriber ticked this box." It records the disclosure *as
captured* by a script on the page: what was rendered, not what the person read, and only where the script ran (see
[limits](#limits)).

Receipts are **evidence you can produce**. They are not legal protection and not a legal opinion: whether a checkout
complied with a law is for your lawyer (and, eventually, a court) to decide. Checkbox does not promise that any court or
regulator will accept a receipt, and takes on no legal liability for your subscription program ([Terms](https://checkbox.site/terms)).
*(Pending attorney review.)*

## What a receipt contains {#contents}

| | |
|---|---|
| Disclosure | Its **visibly rendered** text (personal data redacted), a sanitized copy of its HTML, and the presentation that matters legally, measured over every visible text run and reporting the worst: smallest font size, lowest text/background contrast; visibility, whether it was set off, above the fold, above the button and how far from it. Text that was in the page but not visibly rendered (collapsed, off-screen, clipped, transparent, zero-sized) is left out and counted, with the flag `hidden_text` |
| Consent | The checkbox's checked state and label (the only form state captured) |
| Button | The label of the button that was pressed |
| Page | URL without query string or fragment, viewport size, language, a coarse user-agent class (`desktop-chrome`) |
| Time | Client time and Checkbox's server time |
| Subscriber | Your reference (`customer_reference`, set by your server), optionally an HMAC-SHA-256 of their email under your account's own key |
| Integrity | SHA-256 of the canonical JSON receipt, its position in the site's [hash-chained evidence vault](https://checkbox.site/docs/concepts/evidence), and Checkbox's Ed25519 signature. The signed copy holds a salted commitment to the subscriber identifiers, not the identifiers themselves ([retention](#retention)) |

It is **not** a session replay and **not** a screenshot taken in the subscriber's browser. One structured snapshot is
taken when the form is submitted (plus a layout-only measurement when the disclosure first renders, for proximity).
`GET /v1/receipts/:id/render.png` re-renders "what the subscriber saw" from that snapshot on Checkbox's servers, in an
isolated browser with no network and scripts disabled, and labels the image as a reconstruction.

### What is never captured {#privacy}

- No keystrokes, no mouse or touch movement, no scrolling history: nothing is recorded continuously.
- **No form-field contents.** Every `<input>`, `<textarea>`, `<select>` and editable region (any `contenteditable`,
  `role="textbox"` and similar) — card number, name, email, address, password, promo code — becomes an empty box of the
  same size, and so do live regions (`aria-live`, `role="status"`/`"alert"`, `<output>`). The only form state kept is
  whether the consent checkbox was ticked.
- **Echoes of typed values are masked.** If your page displays what the subscriber typed ("Hi Jane", a shipping
  address preview, a card preview, a button reading "Subscribe as Jane"), that text is replaced by `[masked]`: at
  submit the script compares page text with field values in memory and never sends the values. This catches exact
  values and their words of four or more letters; very short values (a two-letter name) can slip through, so keep
  order summaries and contact details out of the element you mark. Mark anything else private with
  `data-checkbox-ignore`.
- No cookies or storage, no iframes, images or other third-party content, no query strings. If your paths contain
  personal segments (`/account/jane-doe/checkout`), set a route template: `data-receipt-path="/account/:user/checkout"`
  on the marked element or the script tag (`receipt-path` on `<checkbox-disclosure>`).
- The server re-validates everything: text is redacted again (email addresses, card-like numbers, phone numbers), the
  subtree is rebuilt from an allowlist of tags and style properties, free-form fields are restricted to known shapes,
  and snapshots over 64 KB are refused.

## Set it up {#setup}

### Turn receipts on {#turn-on}

Receipts are **off** until you turn them on, because you must tell subscribers about them first. Add the
[privacy-policy paragraph](#privacy-policy) (or your counsel's version), then switch them on in the dashboard
(site → More → Receipts) or with `updateReceiptSettings` (`enabled: true`).

### With Checkbox Elements {#elements}

`<checkbox-disclosure>` (elements.js **1.1.0** or later) already records a [consent](https://checkbox.site/docs/elements) and sends a
receipt snapshot with it. Checkbox keeps it only once you have [turned receipts on](#turn-on) for your account; until
then the snapshot is discarded and the consent's `receipt` is `null`. Once on, each consent object carries the
`receipt` id (`rcpt_…`). To bind receipts to your checkout, add `receipt-token` (below).

### With the snippet {#snippet}

Add `data-receipts="1"` and your publishable key to the snippet tag, and mark the element that holds your
automatic-renewal disclosure:

```html
<script async src="https://app.checkbox.site/s.js" data-key="pk_6ea05a319dc611d8ce3327fe"
        data-api="https://app.checkbox.site" data-receipts="1" data-publishable-key="pk_test_..."></script>

<form action="/subscribe" method="post">
  <div data-checkbox-receipt data-receipt-token="rct_test_...">
    <p>Your Acme subscription renews automatically every month at $19.99 until you cancel.
       Cancel any time at acme.example/account.</p>
    <label><input type="checkbox" name="agree" required> I agree to the automatic renewal terms.</label>
  </div>
  <button type="submit">Start subscription</button>
</form>
```

### Mark the disclosure {#mark-the-disclosure}

`data-checkbox-receipt` (or `data-checkbox-disclosure`) tells Checkbox exactly which element is the disclosure
(`detection.method: "marked"`, confidence high). Without a mark, the snippet looks for the text block next to the
submit button that reads like a renewal disclosure and records how sure it is (`detection.method: "auto"`,
`confidence: high | medium | low`; low confidence adds the flag `low_confidence_detection`). Forms that do not look
like a checkout (sign-in, search) produce no receipt at all, and submitting a form other than the checkout (a newsletter
or search form on the same page) never records the checkout's disclosure: only a marked element inside the submitted
form counts, or one linked to it with `data-checkbox-receipt-form="<form id>"`.

Checkouts without a `<form>` (a button in a single-page app) call `window.CheckboxReceipts.capture({ submit: button })`.

### Bind receipts to real checkouts {#bind}

Anyone can send a request with your publishable key, so a receipt from a browser is a client claim. Mint a short-lived
**capture token** on your server for each checkout page and render it into the page; receipts that present it are
`binding: "bound"` and carry your `customer_reference`. Receipts without one are still recorded, flagged `unbound`,
and count against a separate (smaller) daily limit so they can never crowd out bound receipts.

A bound receipt proves that **your server** issued the token for that checkout: it is merchant-attested, not
independent proof that the snapshot is authentic. For Stripe-hosted checkouts, the receipt Checkbox reconstructs from
Stripe's own record is the independent corroboration.

```curl
curl https://app.checkbox.site/v1/receipt_tokens \
  -u "$CHECKBOX_SECRET_KEY:" \
  -H 'Content-Type: application/json' \
  -d '{ "site": "site_3f9a1c2e7b4d4e0f9a8b7c6d5e4f3a2b", "customer_reference": "cus_Q2x9", "expires_in": 3600 }'
```
```node
import Checkbox from '@checkbox-site/cli';

const checkbox = new Checkbox(process.env.CHECKBOX_SECRET_KEY);

const token = await checkbox.receiptTokens.create({
  site: 'site_3f9a1c2e7b4d4e0f9a8b7c6d5e4f3a2b',
  customer_reference: 'cus_Q2x9',
});
// Render token.html_attribute on the element marked data-checkbox-receipt
// (or receipt-token="…" on <checkbox-disclosure>).
console.log(token.html_attribute);
```
```python
import os
from checkbox import Checkbox

checkbox = Checkbox(os.environ["CHECKBOX_SECRET_KEY"])

token = checkbox.receipt_tokens.create(
    site="site_3f9a1c2e7b4d4e0f9a8b7c6d5e4f3a2b",
    customer_reference="cus_Q2x9",
)
print(token["html_attribute"])
```

The token is only good for binding one receipt of that site (`max_uses`, default 1: mint one per checkout attempt) for
an hour (`expires_in`, up to a day). The subscriber can read it in the page, so an expired or used-up token binds
nothing: such receipts are recorded `unbound` with the flag `token_used_up` / `token_expired` and carry no checkout or
customer reference. Pass `email` to store an HMAC of it under your account's key (never the address).

### Stripe-hosted Checkout and Payment Links {#stripe-hosted}

Checkbox's JavaScript cannot run on `checkout.stripe.com` or `buy.stripe.com`. For those checkouts the receipt is
built from two parts:

1. **Your page before the redirect.** The snippet takes the snapshot when the subscriber clicks through to Stripe
   (`phase: "pre_checkout"`). For Payment Links it adds `client_reference_id=ckbx_<nonce>` to the link (only when you
   have not set one) so the two records can be matched.
2. **The Checkout Session as Stripe recorded it.** When `checkout.session.completed` arrives, Checkbox reads the session
   back with your connected Stripe credentials: `consent_collection`, `consent.terms_of_service`, `custom_text`, price,
   interval, trial and timestamps. That receipt is `source: "stripe_checkout"` (or `"stripe_payment_link"`),
   `reconstructed: true`, `evidentiary_weight: "reconstructed_from_stripe"` — a lower evidentiary weight than a direct
   capture, because Checkbox could not observe the page itself. Stripe's record has no wording for its own
   terms-of-service checkbox or pay button, so the receipt claims none (`checkbox.label` and `submit_label` are `null`)
   and the image draws them as dashed *illustrative* placeholders. It links to the pre-checkout snapshot in
   `pre_checkout_receipt`: a **bound** snapshot of the same session or token taken before the session completed (the
   earliest; several are flagged `multiple_pre_checkout_candidates`), else the Payment Link nonce's snapshot from the two
   hours before the session (flagged `linked_by_client_nonce`, since the subscriber can see and change the nonce). A
   Payment Link's `client_reference_id` is kept as `client_reference`, never as the trusted `customer_reference`
   (that comes from your token, else the Stripe subscription or customer). The receipt's mode is the Stripe event's.

To link them for Checkout Sessions you create, set the token id in the session's metadata:

```node
const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  line_items: [{ price: 'price_joy_monthly', quantity: 1 }],
  consent_collection: { terms_of_service: 'required' },
  custom_text: { submit: { message: 'Renews monthly at $19.99 until you cancel. Cancel any time in your account.' } },
  metadata: { checkbox_receipt_token: token.id },
  success_url: 'https://acme.example/thanks',
});
```

## Find and verify a receipt {#verify}

```curl
curl -G https://app.checkbox.site/v1/receipts \
  -u "$CHECKBOX_SECRET_KEY:" \
  -d site=site_3f9a1c2e7b4d4e0f9a8b7c6d5e4f3a2b \
  -d customer_reference=cus_Q2x9
```
```node
import Checkbox from '@checkbox-site/cli';

const checkbox = new Checkbox(process.env.CHECKBOX_SECRET_KEY);

for await (const receipt of checkbox.receipts.list({ site: 'site_3f9a1c2e7b4d4e0f9a8b7c6d5e4f3a2b', customer_reference: 'cus_Q2x9' })) {
  const check = await checkbox.receipts.verify(receipt.id);
  console.log(receipt.id, receipt.disclosure.text, check.verified);
}
```
```python
import os
from checkbox import Checkbox

checkbox = Checkbox(os.environ["CHECKBOX_SECRET_KEY"])

for receipt in checkbox.receipts.list(site="site_3f9a1c2e7b4d4e0f9a8b7c6d5e4f3a2b", customer_reference="cus_Q2x9").auto_paging_iter():
    print(receipt["id"], checkbox.receipts.verify(receipt["id"])["verified"])
```

`verifyReceipt` re-hashes the stored receipt, checks the signature, verifies the site's evidence chain, and checks that
everything the API, dashboard and image show (binding, source, text, snapshot, flags, identifiers) matches the signed
document (`checks.index`; identifiers linked after signing, such as the Stripe session, are listed separately). To check
without trusting Checkbox's servers, download the bundle (`GET /v1/receipts/:id/export`, or **Download bundle** in the
dashboard) and run its verifier with keys you fetched yourself:

```sh
curl -sO https://app.checkbox.site/.well-known/checkbox-keys.json
unzip checkbox-receipt-rcpt_….zip -d receipt && node receipt/verify.mjs receipt --keys checkbox-keys.json
```

The bundle holds the canonical receipt JSON, `receipt.sig.json`, `subscriber.json` (the identifiers that open the
signed commitment), the signed manifest with the receipt's chain links and the SHA-256 of every other file,
`receipt.png` and `render.html`, a README for whoever receives it, and `verify.mjs` (Node.js 18+, no dependencies).
Exit code 0 means every hash, link and signature checks out against the keys you supplied; a changed, missing or added
file fails. The verifier prints the source, binding, evidentiary weight and flags **from the signed record**: rely on
those, not on the README or the image.

## Retention and erasure {#retention}

Receipts are kept **4 years (1,461 days) by default**: the limitation period of California's Unfair Competition Law
(Bus. & Prof. Code § 17208), the longest commonly pleaded alongside automatic-renewal claims. *(Pending attorney
review: your counsel may want a different period.)* Change it per account and mode with `updateReceiptSettings`
(`retention_days`, 30–3653) or in the dashboard; it applies to receipts recorded afterwards. Expired receipts are
deleted by a daily job. `updateReceiptSettings` with `enabled: false` stops collection (browser receipts then answer
`403 receipts_disabled`).

**The write-once copy.** Each receipt is also stored in the site's evidence vault, which is write-once (S3 Object Lock)
and kept for the vault's period (7 years by default, `EVIDENCE_RETENTION_DAYS`), longer than the receipt itself. That
copy holds the disclosure, its presentation, the page, the times and the flags, but **no subscriber identifier**: only
a salted SHA-256 commitment to the customer / client reference, email HMAC, Stripe session, subscription, token and
nonce. The identifiers and the salt live in the receipt's database row. When a receipt expires or is erased, the row
and its salt are deleted, and the vault copy can no longer be tied to the subscriber, while the chain and signatures
stay intact. *(Pending attorney review: whether this meets a deletion request in your jurisdiction.)*

**Erasure requests.** Delete a subscriber's receipts by your reference, email or Checkout Session:

```curl
curl https://app.checkbox.site/v1/receipts/erase \
  -u "$CHECKBOX_SECRET_KEY:" \
  -H 'Content-Type: application/json' \
  -d '{ "customer_reference": "cus_Q2x9" }'
```
```node
import Checkbox from '@checkbox-site/cli';

const checkbox = new Checkbox(process.env.CHECKBOX_SECRET_KEY);

const result = await checkbox.receipts.erase({ customer_reference: 'cus_Q2x9' });
console.log(result.erased);
```
```python
import os
from checkbox import Checkbox

checkbox = Checkbox(os.environ["CHECKBOX_SECRET_KEY"])

result = checkbox.receipts.erase(customer_reference="cus_Q2x9")
print(result["erased"])
```

## Limits {#limits}

- **Opt-in and best effort.** Receipts exist only for checkouts on pages where receipts are on and the script ran. A
  receipt sent as the page navigates away can be dropped by the browser; a malformed snapshot is refused. A missing
  receipt says nothing about what the subscriber saw.
- **Rendered, not read.** "Visibly rendered" means a box on the page with readable size and colour, not that the
  subscriber looked at it.
- **Merchant-attested binding.** See [bind](#bind).
- **Device clock.** `client_timestamp` comes from the subscriber's device; receipts whose device time is more than 5
  minutes from Checkbox's are flagged `client_clock_skew`. Checkbox's server time is authoritative.

## Your obligations {#obligations}

Receipts are personal information about your subscribers that **you** collect; Checkbox processes it for you as your
service provider (processor) under your agreement with Checkbox, and uses it for nothing else. Before turning receipts
on:

### Privacy policy {#privacy-policy}

- **Say so in your privacy policy.** For example *(pending attorney review)*:

  > When you subscribe, we record a snapshot of the subscription terms and consent checkbox shown to you at checkout —
  > the text and how it was displayed, whether you ticked the box, the page address and the time — to document your
  > consent. The contents of the form fields you fill in are not recorded; text our page displays near the terms may
  > be. This record is kept by our service provider Checkbox (checkbox.site) on our behalf for up to four years and used
  > only to document and defend the terms of your subscription. A copy that no longer identifies you may be kept in
  > tamper-evident storage for up to seven years. You can ask us to delete your record.

- **Tell Checkbox only what you need to.** Use your own subscriber reference (`customer_reference`), not names or
  email addresses; pass an email only as `email` (Checkbox stores an HMAC of it under your account's key; that hash is
  still personal information and is erased with the receipt).
- **Honour deletion requests** with `eraseReceipts` (above).
- **California wiretap law (CIPA).** Session-replay and chat tools have been sued under Cal. Penal Code § 631 for
  recording what visitors type. Receipts are designed to stay out of that pattern — one snapshot of your own disclosure
  at checkout submit, no keystrokes, no field contents, typed values masked where the page echoes them, no continuous
  recording — but your counsel should review your disclosure and setup. *(Pending attorney review.)*

## Webhook {#webhook}

`receipt.created` fires for every receipt (browser, server and reconstructed). `data.object` is the receipt.

## Reference {#reference}

`createReceiptToken`, `createReceipt`, `listReceipts`, `retrieveReceipt`, `renderReceipt`, `verifyReceipt`,
`exportReceipt`, `eraseReceipts`, `retrieveReceiptSettings`, `updateReceiptSettings`: see [Receipts](https://checkbox.site/docs/api/receipts). MCP:
`list_receipts` and `get_receipt`.
