Files
keysat-root/ZAPRITE_INTEGRATION_SPEC.md
Keysat 843ff0e5d7 Initial backup of root workspace files
Glue files not covered by subproject repos: top-level docs, logo,
keysat-design-system, and crosscheck tests. Subproject folders are
gitignored (each has its own Gitea remote).
2026-06-12 17:51:40 -05:00

502 lines
22 KiB
Markdown

# 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<CreatedInvoiceHandle>;
/// Fetch invoice state on demand (for reconciliation on startup).
async fn get_invoice_status(&self, invoice_id: &str) -> Result<InvoiceStatus>;
/// Validate a webhook delivery. Returns the parsed event or an
/// auth/parse error.
fn validate_webhook(
&self,
headers: &HeaderMap,
body: &[u8],
) -> Result<WebhookEvent>;
/// 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<PaymentReceipt>;
}
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<ServerKeypair>,
pub config: Arc<Config>,
pub self_tier: Arc<RwLock<license_self::Tier>>,
pub payment: Arc<RwLock<Option<Box<dyn PaymentProvider>>>>, // 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 <api_key>`. 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<RwLock<Option<Box<dyn PaymentProvider>>>>` | 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?