Digital products
Build product pages, free and paid checkout, the buyer's download page and purchase library, and manage products and sales through the Operator API.
A
JBuild the routes a board needs to sell digital products: a product list, product pages with reviews, free claims and embedded Stripe checkout, the buyer's download page, and the signed-in buyer's purchases. The last section covers the Operator API for products, files, sales and webhooks.
Operators create products in the dashboard (Sell digital products) or through the Operator API. Your frontend only reads and sells them. Method signatures are in the products and purchases references.
Prerequisites
- A shared
boardclient from Install as package. @stripe/stripe-jsin the browser that mounts paid checkout.- A server-owned session for
board.purchases.mine()(the purchases tab). Everything else works without one.
Routes
| Route | Path helper | Calls |
|---|---|---|
| Product list | BOARD_PATHS.products (/products) | board.products.list |
| Product page | productPath(slug) (/products/<slug>) | board.products.get, board.products.reviews, board.products.checkout, board.products.getCheckout |
| Download page | purchasePath(token) (/purchases/<token>) | board.purchases.* |
| Buyer's purchases | BOARD_PATHS.mePurchases (/me/purchases) | board.purchases.mine |
The helpers come from @cavuno/board/paths. Use the downloadUrl the API returns rather than building the download page path yourself: it already follows your board's route manifest. Show a Products item in the header only when board.products.list() returns at least one product.
List products
Each card has name, summary, coverImageUrl, price, rating (null when ratings are off), soldOut and url, the product page path. Only listed products appear. Product lists and product pages are public and can be cached like jobs.
Render a product page
price.kind is free, fixed or pay_what_you_want. For pay what you want, amountCents is the minimum and suggestedCents the amount to prefill. contents lists each file's type, size and page count, and facts holds the counted totals (fileCount, linkCount, fileTypes, pageCount, totalSizeBytes). summary is the seller's one-line "you'll get" text and details the rows of the product's facts box: each row is { kind: 'text', label, value } or a yes/no row { kind: 'check', label, checked }. Render a check row as a tick when checked is true and a cross when it is false, with "Yes" or "No" as its accessible text. The buy button's text is your site's own copy, like the rest of its interface text: for example "Buy now", or "Get it free" when price.kind is free.
descriptionHtml is the seller's formatted description as sanitized HTML (headings, lists, quotes, dividers, links and images uploaded to the board); render it in your prose styles, and fall back to the plain-text description when it is null. salesCount is set only when the seller shows the number of sales; otherwise it is null.
A product with isListed: false is link-only: render the page for anyone with the link, but keep it out of your product list, sitemap and search index (noindex). An unknown slug, an archived product and a product with no ready file are all products_not_found (404).
Start a checkout
Collect the buyer's email and the answers to product.questions in your own form, then call checkout from your server. The same call handles free claims and paid products.
status: 'complete'is a free claim (a free product, oramountCents: 0on a pay-what-you-want product with a minimum of 0). Send the buyer todownloadUrl. They are also emailed the link.status: 'open'is a paid checkout. Mount it in the browser with the kit'spublishableKey,stripeAccountIdandclientSecret. The session lasts 30 minutes.
Checkout questions are text (string answer), checkbox (boolean) or dropdown (one of options). Send one answer per question id; required questions must be answered.
When the request carries a signed-in board user's token, the purchase is also linked to that account.
Mount paid checkout
Confirm a paid checkout
Stripe sends the buyer back to returnPath with session_id. Poll getCheckout for a few seconds: it reads awaiting_payment until the payment lands, then paid with downloadUrl.
The purchase email goes out when the payment lands, so a buyer who closes the tab still gets their link.
Checkout errors
| Code | Status | Show |
|---|---|---|
product_sold_out | 409 | The product reached its sales limit. |
product_email_invalid | 400 | Ask for a valid email. |
product_email_blocked | 403 | This email cannot buy from the board. |
product_amount_below_minimum | 400 | The amount is below details.minimumCents. |
product_amount_invalid | 400 | The amount is not a whole number of cents, or is above the largest amount allowed. |
product_answers_invalid | 400 | Point at details.questionId. |
stripe_not_connected | 409 | The board cannot take payments yet. |
products_not_found | 404 | Your 404. |
product_checkout_not_found | 404 | getCheckout with an unknown session. |
rate_limited | 429 | Wait Retry-After seconds. |
rate_limit_unavailable | 503 | Try again shortly. |
Build the download page
The download page lives at the downloadUrl path. Its token is the buyer's credential: never put it in a sitemap, analytics event or shared cache key, and render the page with noindex.
Pass the shared-link check
The buyer's first open of the link, and a request with the buyer's signed-in session, go straight through. Anyone else opening a forwarded link gets purchase_email_confirmation_required (403) and must type the purchase email.
When your frontend calls the API from its server, the API's own confirmation cookie never reaches the browser. Keep the confirmationToken the API returns in your own cookie for that purchase, and send it back on every call with the confirmationToken option, which sets the PURCHASE_CONFIRMATION_HEADER (X-Cavuno-Purchase-Confirmation) header.
A wrong email is purchase_email_mismatch (403). Confirmation attempts are throttled per link.
Render files and downloads
purchase.files lists each file and link in the operator's order. A file has state (ready, preparing while the buyer's stamped PDF is made, limit_reached), downloadsLeft, sizeBytes, pageCount and stamped. A link has url; open it directly. The page also carries product, downloadCap, stamping (off, preparing, ready), receiptUrl (null for free claims), refundPolicy and the buyer's own review.
Downloads are POST requests that count one download each, so start them from a button, never on page load:
The signed url downloads as an attachment from private storage. It stays valid long enough for the file's size (10 minutes to 3 hours), so a slow connection can finish a large file. Download all streams one ZIP of every ready file and counts one download of each. Show its button only while the page's downloadAllAvailable is true: the ready files must total 500 MB or less, and above that the buyer downloads them one by one.
| Code | Status | Meaning |
|---|---|---|
purchase_file_preparing | 409 | The buyer's stamped copy is being made. Nothing was counted; the buyer is emailed when it is ready. |
purchase_download_limit_reached | 409 | No downloads left for this file. Not a rate limit: do not retry. The board can reset the buyer's downloads. |
purchase_download_all_unavailable | 409, 503 | 409: the files total more than 500 MB. 503: the ZIP is not available right now. Either way nothing was counted; offer the files one by one. |
purchase_awaiting_payment | 409 | The payment has not arrived yet. |
purchase_access_removed | 410 | The board ended this buyer's access. |
purchase_unavailable | 410 | The product can no longer be delivered. |
purchase_file_not_found | 404 | The file id is not on this purchase. |
purchases_not_found | 404 | Unknown token. |
Purchase-token routes skip the board password and the board's plan checks: a buyer always reaches what they paid for.
Reviews
When purchase.ratingsEnabled is true, show a review form. One review per purchase: writing again replaces it.
Omit displayName to stay anonymous. The result's counted says whether the stars count toward the rating. Ratings off is product_reviews_disabled (404), a refunded purchase is purchase_refunded (409), and invalid stars or a comment over 2,000 characters is purchase_review_invalid (400).
Resend the download link
"Already bought it? Get your download link" on the product page posts the email to resend. It always answers 202, so it never reveals whether that email bought anything.
Show the buyer's purchases
It lists purchases made while signed in and purchases made with the user's verified email, each with its downloadUrl. Render it as a tab in your account area.
Manage products with the Operator API
The Operator API (https://api.cavuno.com/v1, API key) manages products, files and sales for automations and AI agents. Products are plans of purpose digital_product.
| Task | Operation | Permission |
|---|---|---|
| Create a product | POST /plans with purpose: "digital_product", kind (free or one_time) and digitalProduct.pricing | plans.manage |
| Edit product fields, list or unlist | PATCH /plans/{id} (digitalProduct, isPublic, isArchived) | plans.manage |
| List or read products | GET /plans, GET /plans/{id} (digitalProduct holds the fields) | plans.read |
| List files and links | GET /plans/{id}/files | plans.read |
| Upload a file | POST /plans/{id}/files/uploads, then POST /plans/{id}/files/uploads/{uploadId}/complete | plans.manage |
| Add a link | POST /plans/{id}/files | plans.manage |
| Rename, reorder, disable, replace | PATCH /plans/{id}/files/{fileId} | plans.manage |
| Delete a file or link | DELETE /plans/{id}/files/{fileId} (204) | plans.manage |
| List sales | GET /plans/{id}/purchases | billing.read |
| Correct a buyer's email | PATCH /plans/{id}/purchases/{purchaseId} | plans.manage and billing.read |
| Reset downloads | POST /plans/{id}/purchases/{purchaseId}/downloads/reset | plans.manage and billing.read |
| Resend the download link | POST /plans/{id}/purchases/{purchaseId}/resend (202) | plans.manage and billing.read |
| List reviews, reply or hide | GET /plans/{id}/reviews, PATCH /plans/{id}/reviews/{reviewId} | plans.read / plans.manage |
New products start unlisted. List one with PATCH /plans/{id} and isPublic: true once it has a ready file or link. Full schemas are in the API reference.
Create a product
pricing is { "kind": "free" }, { "kind": "fixed", "currency", "amountCents" } or { "kind": "pay_what_you_want", "currency", "minimumCents", "suggestedCents" }. A paid product needs the board's Stripe connection only at checkout.
Upload a file
POST /plans/{id}/files/uploadswithfileName, the exactsizeBytes(up to 500,000,000) and an optionaldisplayName(the name buyers see;fileNamewhen omitted). The response reserves a file (fileId, stateuploading) and returnstransfer: one presignedurlfor a small file (method: "single"), orpartswith one URL per part (method: "multipart", every part but the last exactlypartSizeBytes).PUTthe bytes to each URL. The URLs expire atexpiresAt; an upload not completed within a day is stopped.POST /plans/{id}/files/uploads/{uploadId}/complete. The file moves tochecking, then toready,flagged(flagReasonthreatorunscannable) orblocked(blockedReason). PollGET /plans/{id}/files.
To replace a file, start an upload, pass its id to PATCH /plans/{id}/files/{fileId} as replaceWithUploadId (with notifyBuyers: true to email past buyers once it is live), then send the bytes and complete it. Buyers keep the current version until the new one passes its checks. The file keeps its displayName, the name buyers see and download under; fileName becomes the new upload's name.
A product holds up to 50 files and links. disabled: true on PATCH hides a file from new buyers while earlier buyers keep it; DELETE removes it for everyone.
Operator API errors
| Code | Status | Meaning |
|---|---|---|
digital_product_not_found | 404 | Not a digital product (or sale) of this board. |
digital_product_file_not_found | 404 | Unknown file. |
digital_product_upload_not_found | 404 | Unknown upload. |
digital_product_invalid | 400 | A field is invalid for this product. |
digital_product_email_invalid | 400 | Not an email address. |
digital_product_upload_invalid | 400 | Invalid upload request. |
digital_product_upload_too_large | 400 | Over 500 MB. |
digital_product_slug_taken | 409 | Another product uses the slug. |
digital_product_no_ready_deliverable | 409 | Listing a product with no ready file or link. |
digital_product_taken_down | 409 | Cavuno took the product down. |
digital_product_file_limit | 409 | The product already has 50 files and links. |
digital_product_purchase_paid | 409 | Ending or restoring access on a paid sale; use its transaction instead. |
digital_product_file_state | 409 | The file's state refuses the change. |
digital_product_last_deliverable | 409 | Removing a listed product's last ready file or link. Unlist it first. |
digital_product_purchase_inactive | 409 | Resending while unpaid or after access was removed. |
digital_product_upload_state | 409 | The upload is already finished or stopped. |
digital_product_upload_in_progress | 409 | The file already has an upload in progress. |
digital_product_upload_mismatch | 409 | The bytes sent do not match the declared size. |
Sales, refunds and webhooks
GET /plans/{id}/purchases lists each sale as a plan_purchase: email, status (awaiting_payment, paid, free, access_removed), amountCents, currency, answers, downloadsUsed with a per-file breakdown, and transactionId, the sale on GET /v1/transactions (null for a free claim). The three sale actions name the sale by its purchase id and return it.
Refunds and ending access are also on the Transactions page of the dashboard; see Sell digital products. In the Operator API, act on a paid sale by its transaction (the purchase's transactionId):
POST /v1/transactions/{id}/refunds(refundTransaction) refunds a sale in full or in part.POST /v1/transactions/{id}/remove-access(removeTransactionAccess) andPOST /v1/transactions/{id}/restore-access(restoreTransactionAccess) end or give back the buyer's access without moving money.
A free claim has no transaction, so use the purchase instead: POST /v1/plans/{id}/purchases/{purchaseId}/remove-access (removePlanPurchaseAccess) and POST /v1/plans/{id}/purchases/{purchaseId}/restore-access (restorePlanPurchaseAccess). A paid purchase answers digital_product_purchase_paid; use its transaction instead.
All five need the plans.manage and billing.read permissions. These routes ship with the board sale refunds release.
Subscribe a webhook endpoint to digital_product.purchased and digital_product.refunded (they need billing.read). Each carries a digital_purchase object: id, product_id, product_slug, product_name, email, amount_cents, currency, refunded_amount_cents, status, purchased_at and revision. Treat refunded_amount_cents as the current total: it goes up with each refund and down when a refund fails. See Events.
Verify the result
- The product list shows only listed products, and a link-only product page renders with
noindex. - A free claim lands on the download page and the buyer receives the purchase email.
- A paid test checkout mounts, returns with
session_id, andgetCheckoutreachespaid. - A forwarded download link asks for the email, and the confirmation survives a reload.
- A file at its limit shows no download button, and
purchase_download_limit_reachedis not retried. - Download pages, checkout status and
/me/purchasesare never cached publicly.
Continue with Frontend infrastructure to make these routes canonical and indexable.