# BitUnlock developer and agent guide BitUnlock is a non-custodial payment-verification and buyer-private delivery service for Nostr stores. Its payment, authorization, entitlement, and delivery core is deterministic; optional execution products can invoke a tenant-configured AI/API provider after payment. ## Production identity - API endpoint: https://api.bitunlock.network - Service pubkey (hex): 66be61cf5b8d286391f7b1a72ffa7c799ec2dc49eff0830c40df1cc964e6787d - Service npub: npub1v6lxrn6m355x8y0hkxnjl7nu0x0v9hzfalcgxrzqmuwvje8x0p7sha5mt4 - Policy coordinate: 30078:66be61cf5b8d286391f7b1a72ffa7c799ec2dc49eff0830c40df1cc964e6787d:bitunlock:service-policy:v1 - Discovery: https://api.bitunlock.network/.well-known/bitunlock.json - OpenAPI: https://api.bitunlock.network/openapi.json - Maturity: limited mainnet beta; use small amounts and a dedicated seller wallet (restricted NWC preferred) Always fetch and cryptographically verify signed discovery. Pin the hex service pubkey above in production. ## Public integration resources - Developer guide: https://bitunlock.network/developers - Browser ESM SDK: https://bitunlock.network/assets/bitunlock-sdk.js - Complete static/direct-split storefront example: https://bitunlock.network/examples/complete-store-integration.js - Seller portal: https://bitunlock.network/portal - Operator console (configured super admin only): https://bitunlock.network/admin - Product-event JSON Schema: https://bitunlock.network/schemas/product-event-v1.json - Signed product-event test vector: https://bitunlock.network/test-vectors/product-event-v1.json - Entitlement-document JSON Schema: https://bitunlock.network/schemas/entitlement-v1.json - Signed entitlement test vector: https://bitunlock.network/test-vectors/entitlement-v1.json - Security and trust model: https://bitunlock.network/trust The npm package @bitunlock/sdk and the source repository are not public yet. Do not instruct users to install the npm package. Use the browser ESM bundle or generate an HTTP client from OpenAPI. There is not yet a public hosted fake-sats sandbox. Local sandbox support exists in the project, but production uses real Lightning payments. Do not substitute a placeholder sandbox URL. ## Store neutrality BitUnlock works with any Nostr storefront that implements the public contract. Event content is storefront-owned public metadata: BitUnlock does not require, parse, or assign meaning to store-specific fields such as BitRoad's soft-gate metadata. BitRoad's soft gate is a separate BitRoad feature for products that do not use BitUnlock. It is not part of the BitUnlock protocol. BitUnlock uses br:claim-epoch only to bind registered server-held payload revisions and revocations. ## Signed product event Publish a seller-signed kind 30078 event to at least one relay listed by verified service discovery. BitUnlock resolves the event by seller pubkey plus d tag. Required exact tags: - ["d", "<1-to-256-character identifier>"] - ["price", "", "SAT"] - ["status", "active"] - ["br:delivery", "seller-unlock-service-v1"] - ["br:unlock-service", "https://api.bitunlock.network"] - ["br:unlock-service-pubkey", "66be61cf5b8d286391f7b1a72ffa7c799ec2dc49eff0830c40df1cc964e6787d"] - ["br:unlock-policy", "30078:66be61cf5b8d286391f7b1a72ffa7c799ec2dc49eff0830c40df1cc964e6787d:bitunlock:service-policy:v1"] - ["br:claim-epoch", ""] Event content may use the storefront's own public metadata format. BitUnlock treats it as opaque. Delivery plaintext, claim URLs, license keys, NWC URIs, and private keys must never appear in the public event. Marketplace zap split tags are not part of the BitUnlock v1 payment contract. A `direct-split` product (the default) creates exactly two payment legs: seller and BitUnlock service fee. A `single-recipient` product (see Payment modes below) creates exactly one. Never hard-code a leg count; render and pay whatever the quote actually returns. ## Seller flow 1. Verify signed service discovery and pin the service pubkey. 2. NIP-98 sign POST /v1/tenants and accept the policy with a seller fee cap. 3. Register an NWC URI using POST /v1/wallet-connections. It must support make_invoice, lookup_invoice, and get_info; broader provider links are accepted but should use a dedicated low-balance wallet. 4. Build, sign, and publish the kind 30078 product event. 5. NIP-44 encrypt the delivery plaintext to the service pubkey. 6. Register the coordinate plus ciphertext with POST /v1/products. Omit accessTermSeconds to sell perpetual (one-time, never-expiring) access — the default. Set it to a positive integer number of seconds to sell time-boxed access instead (a rental or manually renewed term); a buyer who completes another purchase renews access for another term. BitUnlock does not automatically or recurringly charge the buyer. ## Payment modes - `direct-split` (default): two payment legs, seller and BitUnlock, per the fee policy. - `single-recipient`: one payment leg for the full price, paid straight to the seller's own wallet, with no separate BitUnlock fee leg. Set `paymentMode: "single-recipient"` on `POST /v1/products`. Gated: only a tenant an operator has explicitly granted the capability for (out of band, no public API) may register a product this way — for a first-party seller who is also the BitUnlock operator, not a general marketplace primitive. ## Execution-mode products (metered provider calls) A product does not have to be a static payload. Set `deliveryMode: "execution"` on `POST /v1/products` to sell a bounded, server-run call to a third-party execution provider through the Routstr adapter. The connection may use Routstr conventions or an OpenAI-compatible API such as OpenRouter — e.g. "pay, then get a real AI analysis of this photo," billed per completed purchase. The product event's `br:delivery-mode` tag carries the mode alongside the unchanged `br:delivery` tag; the order lifecycle, quote/refresh/unlock contract, and entitlement schema are identical to a static product. Gated the same way as `single-recipient`: only a tenant an operator has explicitly granted the `execution_products` capability for may register one (403 otherwise) — request it, this is not self-service yet. Seller setup: 1. Register a provider connection: `POST /v1/provider-connections` with `{provider: "routstr", providerKind: "routstr" | "openai-compatible" | "openai-images", label, baseUrl, apiKey, idempotencyKey}`. `providerKind` is optional and defaults to `routstr`. `openai-images` routes IMAGE execution to the OpenAI Images API (`POST /images/generations`, `data[0].b64_json`) instead of chat completions with image modalities — required for providers (NanoGPT and friends) that refuse image models on `/chat/completions`; text products cannot use it. `baseUrl` is HTTPS and may include a path such as `https://openrouter.ai/api/v1`; query, fragment, credentials, and private-network hosts are forbidden. The API key is validated live against the provider's derived models endpoint before being accepted, then encrypted at rest and never returned by any read. `GET /v1/provider-connections` lists a tenant's connections; `POST /v1/provider-connections/{id}/rotate` (re-validates a new key) and `/disable` round out the lifecycle. `GET /v1/provider-connections/{id}/health?windowSeconds=` returns a seller-facing summary derived from that connection's own executions — success/failure counts, failures by error code, a per-model breakdown, credential staleness, and a `balanceEmptyAlert` flag (set when the provider balance looks exhausted) — so you can spot a drained balance or a failing provider before it becomes a run of buyer-visible failures. 2. Register the product with `deliveryMode: "execution"` and an `execution` object: `{provider, providerKind, providerConnectionId, model, inputSchema, outputSchema, systemPrompt, maxImageBytes, maxNoteBytes, maxOutputTokens, timeoutSeconds, maximumAttempts}`. `providerKind` is optional and defaults to `routstr`; when supplied, it must match the referenced connection. `maximumAttempts` (1-3) is a RATE, not a lifetime cap: it bounds attempts within a rolling one-hour window, and a paid order has no unlock deadline, so a buyer whose attempts run out during an upstream outage can simply come back later. A separate lifetime budget (24 attempts) is the abuse backstop. `inputSchema`/`outputSchema` name a validator BitUnlock IMPLEMENTS, and are not free-form. The platform parses and bounds every execution input and output itself, so a schema it does not know is refused at quote time with `input_schema_unknown` — a product registered against an unrecognised name can never take a quote. Registered today: | inputSchema | outputSchema | shape | |---|---|---| | `satisfied.food-analysis.input.v1` | `satisfied.food-analysis.output.v2` | image blob → JSON | | `satisfied.meal-art.input.v1` | `satisfied.meal-art.output.v1` | text → image, reduced to JSON | | `satisfied.meal-text.input.v1` | `satisfied.food-analysis.output.v2` | text → JSON | | `satisfied.coach.input.v1` | `satisfied.coach.output.v1` | text → prose | Adding one is a small platform change, not a product field, and it is deliberate rather than an oversight: those validators are what bound a payload BEFORE an invoice exists, which is what keeps a bad input a free `422` instead of a paid order that fails. Ask for a schema before you build against it. `systemPrompt` is encrypted at rest and never returned to anyone, including the buyer. Buyer flow adds one step: `POST /v1/quotes` takes an additional `executionInput` object — `{schema, ciphertextSha256, serviceEnvelope}` — where `serviceEnvelope` is a NIP-44 payload (buyer to the pinned service pubkey) whose plaintext matches `inputSchema` and encodes whatever the product needs. **A text-only input carries no blob at all.** `satisfied.meal-art.input.v1`, `satisfied.meal-text.input.v1` and `satisfied.coach.input.v1` are text in, so there is nothing to upload, nothing to mirror, and the quote-time blob fetch described below does not happen — the envelope IS the input. The only limits are the schema's own field bounds and the envelope ceiling of 65,536 ciphertext bytes; NIP-44 runs roughly 1.4x its plaintext, so budget against the ciphertext, not what you typed. `blob_unreachable`, `blob_content_type_invalid` and `blob_hash_mismatch` cannot occur for these. For an image-bearing input, the established shape nests it as `image: {url, ciphertext_sha256, ciphertext_size_bytes, content_type, aes_key, aes_iv, mirror_urls?}` — the actual bytes live at a plain HTTPS URL, AES-256-GCM encrypted, never uploaded to BitUnlock directly; only the pointer and the decryption key (itself NIP-44-encrypted inside serviceEnvelope) are sent. `mirror_urls` is optional (at most two additional HTTPS URLs serving identical bytes) and is strongly recommended: BitUnlock fetches the blob AFTER settlement, so a host that is healthy at upload time and unreachable a minute later is a problem you have already paid for. Mirroring makes one host's outage a non-event. **Quotes are refused rather than sold when the input cannot be used.** `POST /v1/quotes` decrypts the envelope, validates it against `inputSchema`, checks the product's size limits, and actually fetches the blob — before any invoice exists. `ciphertextSha256` must equal `sha256(serviceEnvelope)`; beyond that, an unreachable host, a disallowed content type, a hash mismatch, an oversized image or note, an envelope that will not decrypt, or an input naming a schema the platform does not implement all return `422` with the specific cause (`blob_unreachable`, `blob_content_type_invalid`, `blob_hash_mismatch`, `input_invalid`, `input_not_json`, `input_schema_unknown`, `seller_envelope_invalid`). Distinguish them: `blob_unreachable` means upload somewhere else and re-quote, while `input_invalid` means the request itself is wrong. All of this is free to fail. What remains after payment is what could not be known before it: a provider error or timeout, or a host that went down between quote and unlock. Those still collapse to a generic `execution_failed_permanently` (retryable failures return `execution_retryable_failure`, and a spent attempt budget returns `execution_retry_window_exhausted` — both `503` with a `retry-after` header, and neither is terminal) — the specific internal cause is never exposed to the buyer. Recovering a permanent failure without a second payment: - **Buyer, bad-input case** (a corrupt/unreadable image, a malformed envelope): the buyer submits a corrected input with `POST /v1/orders/{id}/execution-input` — body `{executionInput: {schema, ciphertextSha256, serviceEnvelope}, idempotencyKey}`, same envelope shape as the quote's `executionInput`. It resets the execution and the next `unlock` attempts the new input. Offered for any failure the buyer can fix from their side — a malformed envelope, and every `blob_*` cause including `blob_unreachable`, since re-uploading to a working host is entirely in the buyer's hands. A provider- or credential-side failure returns `execution_replacement_not_eligible`. - **Seller/operator, provider-side case** (an exhausted provider balance, a transient outage that became permanent): once the seller fixes the underlying issue, they call `POST /v1/orders/{id}/execution-retry` — body `{idempotencyKey}` — which retries the buyer's existing, unchanged input. This is seller-authenticated (the tenant that owns the order), not buyer-callable. - **Buyer, when the seller never acts**: the buyer may call `POST /v1/orders/{id}/execution-reset` — body `{idempotencyKey}` — once per order, which does the same reset without waiting on the seller. Refused with `execution_replacement_available` when the failure is one a corrected input could fix, since that path is free and unlimited by comparison. All three return `202` with `state: "not_started"`; the buyer then calls `unlock` again as usual. ### Image-output products (registered output transforms) An execution product whose provider returns an IMAGE rather than text works through a registered output transform. The product's `outputSchema` selects a transform registered in platform code; having one is exactly what makes the product an image product. At unlock, BitUnlock calls the provider's chat-completions endpoint with `modalities: ["image","text"]`, accepts any `data:image/*` data URL back, and dispatches decoding on the payload's actual magic bytes (PNG and JPEG both decode; the declared subtype is not trusted — image models routinely label one format and deliver another). The transform then reduces the image server-side to a compact JSON payload — the registered example quantizes to a 48x48 palette-indexed pixel-art object (run-length encoded, ~3KB) — because the NIP-44 fulfillment envelope has a hard 64KB plaintext ceiling a raw image could never transit. The buyer's `executionInput` for such a product is text-only (no image pointer), so nothing the buyer uploads ever reaches the image provider. Transforms are registered in platform code per `outputSchema`, not self-service: registering one is an operator conversation, like the `execution_products` capability itself. ### Companion stages (one invoice, a bundled second result) A product can bundle a second, image-producing execution into the SAME order: one quote, one invoice at the primary product's price, one fulfillment envelope. A registry in platform code keys on the primary product's coordinate and names a companion product (same tenant); after the primary output validates, BitUnlock derives a short text input from that output, runs the companion product's own image execution (its own model, encrypted systemPrompt, and provider connection), applies its output transform, and attaches the result as an extra field on the primary payload. Companion stages are best-effort BY CONTRACT: the buyer paid for the primary result, so a companion failure of any kind never fails the order — the payload simply arrives without the extra field, and buyers must treat its absence as normal. The seller prices the companion's provider cost into the primary product. Registration is operator-mediated, like transforms. ## Platform fees for micro-ticket tenants (accrual billing) The launch fee policy is 100bps with a 10-sat minimum (launch-v2; the floor was 25 before 2026-07-30). For direct-split products the fee is its own payment leg. For micro-ticket tenants a per-order fee leg is the wrong tool -- sub-10-sat Lightning invoices are unpayable-to-hostile and a second invoice doubles the buyer's work -- so operator-granted `accrual` billing records the fee as a LEDGER entry per paid order instead, priced from that order's own fee snapshot (a later policy change never reprices history). Buyers keep paying exactly one invoice. - Settlement: when the unsettled balance reaches 1,000 sats or the oldest entry is 7 days old, the service mints ONE Lightning invoice for the total. The debt is durable; an expired invoice is re-minted on the same settlement. - `GET /v1/fee-ledger` (NIP-98 seller auth) returns the balance, recent entries, settlement history and the billing parameters -- and performs settlement maintenance as a side effect, so reading your bill is enough to mint, refresh or confirm it. A daily cron does the same for tenants who never look. - Enforcement: an open settlement older than 14 days suspends QUOTING with `platform_fees_overdue` (403). Unlock, refresh and recovery of paid orders are never suspended -- the money at risk there is the buyer's. - Sandbox orders and `exempt` tenants (the default) never accrue. - When a settlement opens, the service DMs the tenant (kind-4, from the pinned service pubkey) with the amount, the deadline and the invoice. The seller pubkey is always notified; `GET/PUT /v1/notification-settings` manages up to 5 additional recipient pubkeys (hex or npub) -- the humans behind an app identity. - Auto-payment is deliberately TENANT-side: BitUnlock's NWC allowlist has no `pay_invoice`, so the platform cannot spend anyone's money even with permission. The reference pattern (a daily cron in the tenant's own worker paying the open settlement with a dedicated, budget-capped NWC connection, guarded by an amount cap that treats zero as a kill switch) is implemented in the Satisfied repo at `art-worker/src/autopay.ts`. ## Tenant secrets (write-only configuration vault) A tenant can store named secrets with BitUnlock using the Cloudflare-Workers-secrets model: write, list names, never read back. This is a generic vault primitive for future tenant features; no production runtime feature consumes these values yet. Provider API keys used by execution products belong in Provider Connections, not here. - `PUT /v1/tenant-secrets/{name}` (NIP-98 seller auth) — body exactly `{"value": ""}`, value at most 8,192 bytes, name a lowercase slug (letters, digits, `-`, `_`; no dots). Creates or rotates. - `GET /v1/tenant-secrets` — lists names and timestamps ONLY. No endpoint ever returns a value. - `DELETE /v1/tenant-secrets/{name}` — removes one. - At most 64 secrets per tenant. Values are AEAD-encrypted at rest under the service KEK with the tenant id and secret name bound into the encryption context, so a ciphertext cannot be replayed across tenants or renamed. ## Seller and operator control planes - `GET /v1/store` returns the authenticated seller's onboarding and receiving-wallet status. - `GET /v1/tenant/overview` and `GET /v1/tenant/orders` power the seller portal with exact, tenant-scoped financial and operational reporting. - `/portal` lets a seller inspect metrics and orders, manage Provider Connections, and manage the separate generic Secrets vault. Every API read or mutation receives a fresh NIP-98 signature. - `/admin` is restricted to the configured `SUPER_ADMIN_PUBKEY`. Its `/v1/admin/*` routes provide sanitized service-wide overview, tenant detail, order support views, and seller-allowlist management. Super-admin authorization never substitutes for seller or buyer ownership on commerce routes. ## Buyer flow 1. NIP-98 sign POST /v1/quotes with productCoordinate, buyerPubkey, and an idempotency key (add executionInput for an execution-mode product; see above). 2. Render and pay every returned payment leg; do not hard-code a leg count for future adapters. 3. Recover with GET /v1/orders/{id}; refresh only expired unpaid legs with POST /refresh. 4. After every leg is settled, call POST /unlock. 5. NIP-44 decrypt buyerEnvelope from the pinned service pubkey and verify orderId, productCoordinate, buyerPubkey, and claimEpoch before presenting the payload. Every mutation retry reuses its idempotency key but creates a fresh NIP-98 signature. ## Entitlements: recovery, time-boxed access, and revocation Every completed purchase also produces an entitlement: a service-signed Nostr event (kind 30078, tag `["d", "bitunlock:entitlement:v1:"]`) asserting that a buyer pubkey owns a specific completed purchase, bound to the settled payment hashes. It verifies offline against the pinned service pubkey — a relying party (the seller's own backend, or an unrelated third-party service such as a chat room, game server, or API gateway) does not need to call BitUnlock to check a perpetual entitlement's signature and content. - GET /v1/access-entitlements (NIP-98 buyer auth) returns every entitlement the authenticated buyer owns: the signed event plus `accessPolicy` and an effective `status`. - `accessPolicy` is either the constant `"perpetual-v1"` or an object `{ "kind": "term-v1", "expiresAt": , "renewalOfFulfillmentId"?: "" }` matching the product's accessTermSeconds setting at the time of purchase. - `status` is `"active"`, `"expired"` (past a term entitlement's expiresAt), or `"revoked"`. Expiry is derivable from the signed document alone. Revocation is NOT embedded in the signed document — mutating signed content would invalidate its own signature — so a relying party that needs to honor seller-initiated revocation for a term product must check current `status` from this endpoint (or the seller's own backend) rather than trusting a cached copy of the signed event indefinitely. A perpetual entitlement has no revocation surface and stays valid to verify offline forever. - POST /v1/entitlements/{fulfillmentId}/revoke (NIP-98 seller auth, idempotent) revokes a specific buyer's entitlement. Only the seller who owns the underlying product may revoke it. - Entitlement-document JSON Schema and signed test vector are linked above; validate against them before trusting a parsed document's shape.