# Zaprite integration spec Multi-provider payments for Keysat — adding Zaprite alongside BTCPay so operators can optionally accept card and broader-rail payments. This spec captures architecture, the Zaprite API shape (insofar as it can be verified from public sources), trade-offs, and a phased build plan. ## Why this matters Keysat today only sells to people who have or will set up a Bitcoin wallet. That's a deliberate sovereignty choice but it caps the addressable market. Zaprite is a payment platform that sits one layer above payment processors and accepts: - Bitcoin on-chain - Lightning (via the operator's own LN node, Strike, Voltage, etc.) - Card payments (Visa/Mastercard) via Stripe or Square - Liquid Bitcoin For a Keysat operator, plugging Zaprite in means anyone with a credit card can buy their software. The operator still chooses what they get paid in (BTC settled to their wallet, USD settled to their bank account, etc.). Sovereignty cost: Zaprite is a SaaS. Card payments require KYC with a processor. Customer PII flows through Zaprite. We mark this clearly in the operator-facing UI; sovereignty stays the default, fiat is opt-in. ## Architecture: PaymentProvider abstraction Today the daemon hard-codes BTCPay assumptions throughout invoice creation, webhook handling, and reconciliation. The right shape is a trait that BTCPay and Zaprite both implement: ```rust #[async_trait] pub trait PaymentProvider: Send + Sync { /// What kind of provider this is — for logs, audit, and admin UI. fn kind(&self) -> ProviderKind; /// Create a hosted-checkout session. Returns the public checkout URL /// that the buyer is redirected to, plus a provider-side invoice id /// the daemon stores in the `invoices` table. async fn create_invoice( &self, params: CreateInvoiceParams<'_>, ) -> Result; /// Fetch invoice state on demand (for reconciliation on startup). async fn get_invoice_status(&self, invoice_id: &str) -> Result; /// Validate a webhook delivery. Returns the parsed event or an /// auth/parse error. fn validate_webhook( &self, headers: &HeaderMap, body: &[u8], ) -> Result; /// Pay a Lightning invoice — used by the tip-recipient flow when the /// provider has an outgoing-LN capability. Optional; some providers /// won't support it (return ProviderCapabilityNotSupported). async fn pay_lightning_invoice(&self, bolt11: &str) -> Result; } pub enum ProviderKind { Btcpay, Zaprite } pub struct CreateInvoiceParams<'a> { pub amount: Money, // {value, currency} — sats or USD or whatever pub redirect_url: &'a str, pub metadata: &'a serde_json::Value, pub external_order_id: &'a str,// Keysat's invoice.id — passed back in webhooks pub idempotency_key: &'a str, pub buyer_email: Option<&'a str>, } pub enum WebhookEvent { InvoiceSettled { external_order_id: String, paid_amount: Money, .. }, InvoiceExpired { external_order_id: String }, InvoiceRefunded { external_order_id: String, refunded_amount: Money }, Other { kind: String, body: serde_json::Value }, } ``` The rest of Keysat (license issuance, audit logging, admin UI) becomes provider-agnostic. The chosen provider is held in `AppState`: ```rust pub struct AppState { pub db: SqlitePool, pub keypair: Arc, pub config: Arc, pub self_tier: Arc>, pub payment: Arc>>>, // was: btcpay } ``` Per-operator, not per-product. v1: an operator picks ONE provider during setup. All products go through it. Switching is an explicit Disconnect → Connect operation. Per-product provider selection is deferred. ## What I learned about Zaprite's API (and what's still unknown) Zaprite egress is blocked from this Cowork sandbox so I couldn't read api.zaprite.com directly. The information below is reconstructed from their open-source WooCommerce plugin ([github.com/ZapriteApp/zaprite-for-woocommerce](https://github.com/ZapriteApp/zaprite-for-woocommerce)) and their official integration patterns. **Confirm everything before writing production code; I'm flagging which pieces need verification.** ### What I'm confident about The plugin's `zaprite_api.php` shows the create-order shape clearly: ```php $data = array( 'apiKey' => $this->api_key, 'amount' => $amount, 'currency' => $currency, // 'USD', 'BTC', 'SAT', 'EUR' 'orderUpdateCallback' => $callback_url, // webhook URL 'redirectUrl' => $complete_url, 'externalOrderId' => $order_id, 'externalUniqId' => $idempotency_key, ); ``` Notable: **the API key is passed in the JSON body, not in a header.** This is unusual and worth confirming — the public `/v1/order` endpoint (used by the api-demo repo, separate from the WooCommerce-specific endpoint) likely uses a header instead. Zaprite has been beta-API for a while; the conventions may have settled differently for `/v1/*`. Endpoints, confirmed-by-source (WooCommerce plugin): - `POST {base}/api/public/woo/create-order` — plugin-specific - `POST {base}/api/public/woo/check-order` — plugin-specific Endpoints, confirmed-by-mention (api-demo repo): - `POST {base}/v1/order` — general public API - `GET {base}/v1/order/:id` — general public API Webhook delivery: Zaprite POSTs to the URL the operator passed in `orderUpdateCallback`. The body includes order state. The plugin code I saw does NOT verify a signature — it relies on the secret-ness of `externalUniqId` plus matching it against the locally-tracked order. That's not great security; the public `/v1/*` API may have a proper HMAC scheme. Currencies: at least `USD`, `BTC`, `SAT`, `EUR`. The plugin lets the merchant configure store currency in WooCommerce and passes it through. Settlement model: from blog content I saw, the operator chooses how each payment rail settles. BTC payments settle to a wallet they configure (Strike, Unchained, BTCPay, lightning node, etc.). Card payments settle to their connected Stripe or Square account in fiat. Zaprite doesn't custody funds. Recurring billing: confirmed in marketing copy ("recurring invoices"). API specifics not visible; almost certainly distinct endpoints from one-shot orders. Pricing: $300/year subscription includes $300 of transaction fees (monthly subscriptions $25/mo include $25/mo of transaction fees). Above that, Stripe's standard 2.9% + 30¢ on cards plus Zaprite's own fee (percentage unclear). BTC/Lightning payments through the operator's own node are free of Zaprite-side fees on transactions; the subscription pays for the platform itself. ### Open questions — resolved (May 2026) Resolved by reading Zaprite's actual OpenAPI spec at `https://api.zaprite.com/openapi.json` and the LLM-friendly summary at `https://api.zaprite.com/llms.txt`. Six of the original seven questions are now answered; one remains. 1. **Auth on `/v1/orders`** — Bearer token. ✅ `Authorization: Bearer `. One key per Zaprite organization. Keys created/rotated/revoked at `app.zaprite.com/org/default/settings/api`. 2. **Webhook signature scheme** — ❌ STILL UNKNOWN. Zaprite's public docs confirm webhooks exist and that endpoints should return 200 (else Zaprite retries), but the signature scheme isn't published. Three plausible options based on the operator-feedback pattern: (a) HMAC-SHA256 of the raw body in an `X-Zaprite-Signature` header — most common shape, what Stripe/BTCPay/GitHub use; (b) a JWT signed with a per-webhook secret; (c) no signature, in which case operators are expected to verify via the `externalUniqId` + a private-URL convention. Resolution path: when the operator creates a webhook in their Zaprite dashboard, the form likely shows a signing secret; the tooltip / docs link from THAT page should describe the exact scheme. We implement the impl as a small trait-internal function that's swappable once the truth is known. Initial implementation: HMAC-SHA256 in `X-Zaprite-Signature`, constant-time compare, fall through to a "log but accept" mode in dev for the first integration test. 3. **Currency code for sats** — `BTC`. ✅ Zaprite's currency enum has `BTC` (also `LBTC` for Liquid). Amounts are in the smallest indivisible unit per currency, so `currency: "BTC"` + `amount: 50000` means 50,000 sats. There's no separate `SAT` code; `BTC` covers both display denominations cleanly. 4. **Recurring billing** — PARTIAL. ✅ (with caveat) Zaprite has NO native subscription endpoints (no auto-renewing schedules). What it offers: - `allowSavePaymentProfile: true` on order creation — buyer can save their card during checkout - `Contact.paymentProfiles[]` — saved profiles per contact - `POST /v1/orders/charge` with `paymentProfileId` — programmatic charge against a saved profile So Keysat's recurring-subscription scheduler (per `RECURRING_SUBSCRIPTIONS_DESIGN.md`) drives the cycle on its side: at each renewal, the daemon's renewal worker creates a new order against the buyer's saved Zaprite payment profile. This is actually CLEANER than relying on Zaprite-managed subscriptions because Keysat keeps the source of truth on when to bill. Trade-off: the buyer's save-card flow happens on the FIRST purchase; subsequent cycles are charge-without- redirect. 5. **Sandbox / test mode** — ✅ Two options: - **Sandbox organization** — operators can spin up a separate org marked as sandbox. Same API endpoints; org context determines real-money vs test. Recommended for staging. - **Test Payment plugin** — within a normal org, activate "Test Payment" at `app.zaprite.com/org/default/connections/testPayment` to trigger PENDING/CONFIRMED states without real funds. Useful for one-off integration tests. 6. **Refund endpoint** — ❌ Not in the public API. Operators handle refunds via the Zaprite dashboard. A refund produces some webhook event (presumably `order.refunded` or similar — see resolution path on Q2). For Keysat's purposes this is fine: we listen for the refund event and revoke the license; we don't need to initiate refunds programmatically from Keysat. 7. **Idempotency** — `externalUniqId` is reconciliation, not deduplication. ✅ Sending the same `externalUniqId` twice DOES create two orders. Keysat's purchase flow already mints a UUID on invoice-row creation BEFORE calling create_invoice, and we guard against double-call client-side via the existing reservation pattern. No change needed. ### Endpoints we'll use ``` POST /v1/orders — create order (one-shot or recurring cycle) GET /v1/orders/{id} — get order status (id can be Zaprite id OR externalUniqId) GET /v1/orders — list (paginated, 100/page) POST /v1/orders/charge — charge a saved paymentProfile (recurring cycles) POST /v1/webhooks — operator manages webhook endpoints ``` Errors come back as `{ code, message, issues: [{ message }] }` with HTTP status codes `400`/`401`/`403`/`404`/`500`. Our ZapriteProvider impl wraps these into `AppError::Upstream` for consistency with the BtcpayProvider error surface. ### What we still need from Grant Just one thing, when convenient: 1. **Create an API key** at the URL in your screenshot. Label it `keysat-test`. **Keep the secret on your machine** — paste it into Keysat via a future "Connect Zaprite" admin action when the impl is ready; don't share it in chat. 2. **Create a sandbox webhook** (or any webhook) and screenshot the form. Specifically: when you click "Add Webhook" in the API tab, the form likely shows a "Signing Secret" field and either a tooltip or a docs link explaining how Zaprite signs payloads. That's the last bit we need. The implementation can proceed today on points 1, 3-7 (which is most of it). Point 2 (webhook signing) gets stubbed with a best-guess HMAC-SHA256 + `X-Zaprite-Signature` impl that we correct the moment your screenshot reveals the actual scheme. ## Data model changes ### Multi-currency on products ```sql ALTER TABLE products ADD COLUMN price_currency TEXT NOT NULL DEFAULT 'SAT'; ALTER TABLE products ADD COLUMN price_value INTEGER NOT NULL DEFAULT 0; -- old price_sats becomes a derived/legacy view ``` Currencies live as ISO-style strings: `SAT`, `USD`, `EUR`, `BTC`. UI displays "$30 USD" or "50,000 sats" with a toggle. We do NOT do exchange-rate conversion server-side — operators denominate in one currency, buyers pay in whatever the chosen provider supports for that currency. Future: optional dual-display ("$30 / ~52,300 sats at current rate") but defer. ### Provider tag on invoices ```sql ALTER TABLE invoices ADD COLUMN provider TEXT NOT NULL DEFAULT 'btcpay'; ALTER TABLE invoices ADD COLUMN provider_invoice_id TEXT; -- already exists as btcpay_invoice_id; rename ``` Each invoice records which provider handled it. Critical for refund flows, chargeback handling, and historical reporting once both providers are in use. ### Refund / chargeback support Card payments mean chargebacks. We need a `license_revoked_due_to_refund` flow: ```sql ALTER TABLE licenses ADD COLUMN revoked_reason TEXT; ALTER TABLE licenses ADD COLUMN revoked_at TEXT; ``` When Zaprite delivers an `InvoiceRefunded` webhook, the daemon revokes the corresponding license, records the reason, fires a webhook subscriber notification (`license.revoked` event with reason `payment_refunded`), and the operator's records update. ## Phased build plan ### Phase 1 — PaymentProvider abstraction (v0.2) Refactor the existing BTCPay code into the first impl of the trait. No user-visible change. Validates the abstraction. | Task | Effort | |---|---| | Define `PaymentProvider` trait, `Money`, `WebhookEvent`, `CreateInvoiceParams` types | S | | Move existing `BtcpayClient` behind a `BtcpayProvider: PaymentProvider` impl | M | | Replace `state.btcpay` with `state.payment: Arc>>>` | S | | Update webhook handler to dispatch on `provider` field of the matching invoice row | S | | Update reconcile loop to call provider-agnostic `get_invoice_status` | S | | Audit: every existing reference to "btcpay" in user-facing copy stays unchanged in v0.2 (provider chooser ships in v0.3) | XS | **Net: ~2 days. Lands as v0.2.0:0.** ### Phase 2 — Multi-currency data model (v0.2 or v0.3) | Task | Effort | |---|---| | Migration 0007 adds `price_currency`, `price_value` to products; `provider`, `provider_invoice_id` to invoices; `revoked_reason`, `revoked_at` to licenses | S | | Update Product / Invoice / License models | S | | Admin UI: currency selector on create-product form; rendering shows currency-correct units everywhere | M | | Marketing-page integration: purchase URL displays correct currency | S | **Net: ~1.5 days. Can ride with Phase 1 in v0.2 if there's headroom; otherwise lands in v0.3.** ### Phase 3 — ZapriteProvider implementation (v0.3) | Task | Effort | |---|---| | Verify the open Zaprite API questions (auth scheme, webhook signature, currency codes) — Grant + me reading the docs together | S | | Build `ZapriteProvider` impl: create_invoice, get_invoice_status, validate_webhook | M-L (depends on signature scheme; if HMAC, ~1 day; if no signature + state-matching, ~1.5 days) | | Map `WebhookEvent::InvoiceRefunded` → license revoke flow | S | | Test with sandbox keys + a real low-value card transaction | S | **Net: 3-5 days. Lands in v0.3 alongside recurring billing.** ### Phase 4 — StartOS actions for Zaprite (v0.3) | Task | Effort | |---|---| | `Connect Zaprite` action — operator pastes API key, daemon validates by hitting Zaprite's `/v1/order` test endpoint, persists, sets as active provider | S | | `Check Zaprite connection` action | XS | | `Disconnect Zaprite` action — clears stored API key, drops back to "no provider" state | XS | | `Switch provider` action — when operator has both BTCPay and Zaprite connected, lets them flip the active provider | S | **Net: ~1 day. Lands in v0.3 with Phase 3.** ### Phase 5 — Admin UI updates (v0.3) | Task | Effort | |---|---| | Sidebar BTCPay status indicator becomes "Payment provider" indicator with provider-kind badge | XS | | Create-product form: currency selector | S | | Invoices list: show provider column | XS | | New "Refunds" section in admin UI: lists `license.revoked` audit entries with `reason: payment_refunded` | S | | Provider chooser in onboarding wizard ("Connect a payment provider — BTCPay or Zaprite or skip for now") | M | **Net: ~1.5 days. Lands in v0.3.** ### Phase 6 — Recurring billing via Zaprite (v0.3) This is the headline v0.3 feature anyway; sequencing Zaprite work here is intentional. | Task | Effort | |---|---| | `subscriptions` table migration | S | | Subscription lifecycle: created → trialing → active → past_due → canceled | M | | Provider-side recurring-invoice setup via Zaprite's recurring API (assuming it exists; verify in Phase 3) | M-L | | Auto-renewal webhook handling — settled webhook on a recurring invoice extends license expiry | S | | Customer-facing "manage subscription" link in receipt emails | S | | Admin UI subscription views | M | **Net: 4-6 days. Lands in v0.3.** ### Phase 7 — Documentation (v0.3) | Task | Effort | |---|---| | Operator runbook: signing up for Zaprite, getting API access, setting up payment rails (Strike / Unchained / Stripe / Square) | S | | KYC implications callout | XS | | Refund + chargeback flow doc | XS | | Fee breakdown comparison table (BTCPay-Lightning vs Zaprite-card vs Zaprite-LN-via-Strike etc.) | S | | Update integration guide to mention multi-currency | XS | | Update marketing page to mention "or accept card payments via Zaprite" once Phase 3 ships | S | **Net: ~1.5 days.** ### Phase 8 — End-to-end testing (v0.3) | Task | Effort | |---|---| | Real sandbox or low-value tests for: Zaprite create-order → buyer pays card → webhook arrives → license issued | S | | Test the recurring-renewal path with at least two cycles | S | | Test refund: refund in Zaprite → webhook arrives → license revoked | S | | Test provider switching: BTCPay active → switch to Zaprite → existing pending BTCPay invoices still reconcile correctly | S | | Test currency mixing: products in USD, products in SAT, both work in their respective providers | S | **Net: 1-2 days.** ## Total effort 7-13 days of focused build work, plus testing. Realistic target: 10 days. That's a v0.2.0 (Phase 1, possibly Phase 2) → v0.3.0 (Phases 3-8) arc, not a single release. ## Trade-offs and gotchas to be loud about in operator docs - **Stripe/Zaprite fees stack on card payments.** A $30 card payment: $0.87 to Stripe + ~$0.20-0.50 to Zaprite + 30¢ processor fee ≈ $1.40, about 4.6%. BTCPay-Lightning is near-zero. Operators should know this when pricing. - **Card payments mean KYC with Stripe.** The operator becomes a merchant of record. Identity verified, bank account on file, business address required. This is genuinely incompatible with the privacy-preserving operator model some Keysat users want; we should be loud about it in the UI and in docs. - **Customer PII flows through Zaprite and Stripe.** With BTCPay-only, buyer email + npub + invoice id all live on the operator's Start9. Zaprite path adds buyer name, card metadata, billing address, IP, and potentially more in Zaprite's and Stripe's databases. Privacy policy needs to reflect this. - **Refund flow is provider-specific.** BTC: operator manually sends sats back. Cards: Stripe-initiated refund via Zaprite's UI (or API), webhook arrives, license auto-revokes. Two different UX paths, both documented. - **Subscription cancellation.** With recurring billing, buyers can cancel via their card issuer (chargeback) or via a self-service portal we'd build. Both paths need to fire `subscription.canceled` and update license expiry semantics. ## What this enables longer term The PaymentProvider abstraction is defensive even if Zaprite never ships — it's the right shape for adding any future provider: - **Strike API directly.** Skip Zaprite entirely; integrate Strike's API for sat-denominated payments without going through their checkout. - **Coinbase Commerce.** Easy add-on for operators who already have a Coinbase Commerce account. - **OpenNode.** Another Bitcoin-native processor. - **PayPal / direct Square.** For operators willing to skip Zaprite. Each is a new `PaymentProvider` impl. ~3-5 days each once the abstraction is in. ## Sources - [Zaprite developers page](https://zaprite.com/developers) (documented but inaccessible from this sandbox; Grant should read directly) - [Zaprite API & webhooks blog post](https://blog.zaprite.com/opening-zaprites-api-webhooks/) - [Zaprite WooCommerce plugin source — `zaprite_api.php`](https://github.com/ZapriteApp/zaprite-for-woocommerce/blob/main/zaprite-payment-gateway/includes/zaprite_api.php) - [Zaprite api-demo repo](https://github.com/zapriteapp/zaprite-api-demo) - [Zaprite pricing page](https://zaprite.com/pricing) - [Zaprite API reference](https://api.zaprite.com/) (egress-blocked from this sandbox — Grant has direct access) ## My next step recommendations 1. **Grant signs up for a free Zaprite account, requests API access at Settings → API, and pastes back what api.zaprite.com/v1 actually shows for create-order and webhooks.** That answers the seven open questions above and lets me write production-grade code. 2. **Phase 1 (PaymentProvider abstraction) can start now without any Zaprite specifics** — it's a refactor of existing BTCPay code. I can do this autonomously and have it ready for v0.2.0:0 alongside the web UI auth hardening already queued. 3. **Phases 2-8 wait on the Zaprite API confirmation.** No point coding against a guessed-at API. Want me to start Phase 1 now while you go grab the Zaprite docs?