Overview
zcash-payment-service hands your application a fresh Zcash address for each checkout and posts the amount it later observes at that address.
You run it on your own host. Use it as a small HTTP service, or embed the Rust crate in a process you already have. The service adds no fee: the total_received value it reports is the sum of the eligible incoming note values, converted from zatoshis, with nothing withheld. There is no account to open with a payment processor, and the code has no usage meter.
The only wallet key the server accepts is a unified incoming viewing key, the UIVK in ZCASH_UIVK. From that key it derives each payment address and builds the Sapling, Orchard, and Ironwood scanning keys it uses locally. You keep the spending key in the wallet that can spend. The service stores the UIVK in its own SQLite database on the host you run.
Two ways to run the same code:
- Rust crate. Depend on
zcash-payment-servicefrom this Git repository and callrun, or mount the router yourself. - Docker container. Build the image in this repository, or pull
ghcr.io/nerdcash/zcash-payment-serviceafter CI publishes it frommain.
What a payment looks like
- Your application asks
POST /payment-sessionfor an address when a checkout starts. - You show the returned
addressand a QR code ofqr_textto the payer, or send them to the service’s own pay page. - The service downloads compact blocks and mempool transactions from the lightwalletd URL you configure, and decrypts them on your host with keys derived from the UIVK.
- When
ZCASH_WEBHOOK_URLandZCASH_WEBHOOK_SECRETare both set, a change in the eligible total for that address becomes a signed webhook to your application.
The HTTP API is GET /health, POST /payment-session, and the public pay page routes GET /pay/{payment_id} and GET /payments/{payment_id}. Payment totals leave the process as webhooks. Your application owns prices, invoices, and the decision that a payment is enough.
What the service keeps track of
The current code does all of the following:
- Stores its own state in SQLite, separate from the wallet database.
- Checks on startup that the configured UIVK, network, and birthday height match the identity it already stored.
- Derives a new unified address from the canonical UIVK for every payment session. Each address has Orchard and Sapling receivers. An Ironwood receiver is recorded too, using the same receiver bytes as Orchard, so Ironwood notes can be attributed after NU6.3.
- Scans compact blocks, and the mempool when
WEBHOOK_REPORT_CONFIRMATIONSis0. - Rescans a recent window so a short reorg can correct a total, and advances receipt maturity as the chain tip moves.
- Falls back to polling when the lightwalletd mempool stream is unavailable.
- Signs outbound webhooks with HMAC-SHA256 and retries them with a capped exponential backoff.
- Writes a daily JSON log file plus a compact log on standard output.
A UIVK that also carries a transparent component still decodes. Issued addresses omit a transparent receiver, and the scanner builds Sapling, Orchard, and Ironwood keys only.
The router has no authentication middleware. Put the service on a private network, the way docker-compose.yml publishes it on 127.0.0.1. To use the pay page, publish only its two routes through a reverse proxy, as Payment links shows.
Continue with Getting started to run it, or Integration guide to wire a checkout.
Getting started
You need three values before the service can scan:
| Variable | Role |
|---|---|
ZCASH_UIVK | Unified incoming viewing key for the network you select. It must include Orchard and Sapling receivers. |
ZCASH_BIRTHDAY_HEIGHT | Block height to treat as the wallet birthday. A u32. |
LIGHTWALLETD_URL | Lightwalletd endpoint the service calls for compact blocks, tree state, and mempool data. |
ZCASH_NETWORK defaults to testnet. Set it to mainnet when the UIVK is a mainnet key. The two must match: the service decodes the UIVK with the network you name.
Create a .env file next to docker-compose.yml if you use Compose. Compose substitutes ${ZCASH_UIVK} and the other variables in that file from the shell or from .env. Export the same variables in the shell when you use cargo run.
$env:ZCASH_NETWORK = "testnet"
$env:ZCASH_UIVK = "<your unified incoming viewing key>"
$env:ZCASH_BIRTHDAY_HEIGHT = "<birthday height>"
$env:LIGHTWALLETD_URL = "<lightwalletd url>"
export ZCASH_NETWORK=testnet
export ZCASH_UIVK='<your unified incoming viewing key>'
export ZCASH_BIRTHDAY_HEIGHT='<birthday height>'
export LIGHTWALLETD_URL='<lightwalletd url>'
On the first start, the service refuses to come up when ZCASH_UIVK or ZCASH_BIRTHDAY_HEIGHT is missing, unless the wallet database already contains a canonical account it can read. After the app database has stored an identity, a later start can omit those two variables and still pass the identity check. The scan loop is separate: each iteration builds scanning keys from the ZCASH_UIVK environment variable. Leave it unset and GET /health reports current_sync_state as blocked_scanning_key_material. Leave LIGHTWALLETD_URL unset while the UIVK is set, and the state is blocked_lightwalletd_endpoint.
Optional webhook settings are covered in the integration guide. The full variable list is in the API reference.
Run from source
CI builds with stable Rust. From the repository root:
cargo run
The binary listens on 0.0.0.0:8787 unless you set LISTEN_ADDR or PORT. LISTEN_ADDR wins when both are set. PORT alone becomes 0.0.0.0:<port>.
Data and logs default to data/zcash-payment/ relative to the working directory (app.db, wallet.db, and logs/). That directory is gitignored.
Run with Docker Compose
From the repository root:
docker compose up --build
The Compose file builds the image, publishes 127.0.0.1:8787 to container port 8787, sets APP_DATA_DIR to /data/zcash-payment, and mounts ./data there. The health check calls GET /health with curl inside the container.
Check that it is up
curl.exe -s http://127.0.0.1:8787/health
Before the sync worker records a status, the metadata row inserted at startup looks like this:
{
"ok": true,
"current_sync_state": "starting",
"detected_tip_height": null,
"last_scanned_height": null
}
ok means the HTTP handler could read that row. Heights are JSON null while the stored integers are still 0. Read current_sync_state to see whether scanning is running. The sync worker then records one of catching_up, synced, blocked_scanning_key_material, or blocked_lightwalletd_endpoint. The two blocked states also leave both heights at 0.
cargo run and the container process load configuration only from the environment. A missing or mismatched UIVK fails startup with startup integrity check failed: ... and the process exits before it listens. The API reference lists those checks.
Logs go to standard output and to a daily JSON file, zcash-payment.log, under LOG_DIR. The default filter is info. Set RUST_LOG to change it.
The binary stops when it receives Ctrl-C.
Integration guide
Your application owns the checkout. The service owns Zcash addresses and the observation of what arrived at them.
Your application:
- Prices the order and creates the invoice.
- Calls
POST /payment-sessionwhen a payer is ready to pay. - Shows the address and a QR code.
- Receives the webhook, checks
X-Signature, and applies it when itsstate_versionis newer than the last version stored for that address. - Decides when
total_receivedcovers the invoice.
The service stores the requested amount, memo, message, and label beside the address. It does not compare the amount it observes with the amount you requested. total_received is the cumulative eligible incoming total for that one address, in ZEC. It is not a wallet balance.
On Node.js, the Node.js client (@nerdcash/zcash-payment-client) creates payment sessions and verifies webhook signatures for you. The rest of this page describes the wire contract it implements, for other languages.
The router does not authenticate callers. Keep POST /payment-session on a network only your application can reach.
Creating a payment request
POST /payment-session with Content-Type: application/json.
payment_source is required and must be the string zcash. label, memo, message, and amount are optional strings. amount is copied into the QR text as you sent it. The service does not parse it as a number.
curl.exe -s -X POST http://127.0.0.1:8787/payment-session `
-H "Content-Type: application/json" `
-d '{"payment_source":"zcash","label":"Pro annual plan","memo":"Invoice INV-1001","message":"Thanks for your purchase.","amount":"0.12345678"}'
A successful call returns 200 with JSON:
{
"address": "<unified address>",
"qr_text": "zcash:<unified address>?amount=0.12345678&memo=SW52b2ljZSBJTlYtMTAwMQ&message=Thanks%20for%20your%20purchase."
}
Every successful call mints a new unified address from the canonical UIVK. Calling twice returns two different addresses. The address string starts with u.
Save address on your invoice. That string is what the webhook sends back. The response also carries payment_id and pay_url for the hosted pay page, which you can use instead of rendering the QR code yourself.
payment_source set to anything else returns 400 and this body:
{"error":"invalid request: payment_source must be 'zcash'"}
Other request failures are described in the API reference.
Showing the address and the QR code
Show address as the pay-to string. Render qr_text as a QR image in your application. The service returns the text. It does not return a PNG. On Node.js or in the browser, the qrcode package can render it. See Node.js client.
build_qr_text builds that string:
- The prefix is
zcash:plus the address. - A non-blank
amount, thenmemo, thenmessage, are appended as a query string, in that order.memois unpadded base64url of its UTF-8 bytes, as ZIP-321 requires; the others are percent-encoded. - A field that is missing or only whitespace is left out.
- Bytes other than letters, digits, and
- _ . ~are percent-encoded with uppercase hex. A space becomes%20.
The unit test in src/service.rs locks the encoding:
zcash:uaddr
zcash:uaddr?amount=0.12345678&memo=SW52b2ljZSBJTlYtMTAwMQ&message=Thanks%20for%20your%20purchase.
The live address is the unified address from the response, in the same pattern. Wallets that understand a Zcash URI read the amount, memo, and message from those query parameters.
Learning that a payment arrived
Set both of these on the service or it will scan without posting anything:
| Variable | Role |
|---|---|
ZCASH_WEBHOOK_URL | Absolute http or https URL of your endpoint, including the host. |
ZCASH_WEBHOOK_SECRET | Shared secret used as the HMAC key. |
If either one is empty, spawn_webhook_delivery_worker logs a warning and returns without starting the worker. Startup still succeeds.
When both are set, the worker POSTs to your URL whenever an issued address is dirty and its eligible total differs from the last total that was successfully delivered. Catch-up scanning queues those deliveries as each batch updates a total, so a webhook can arrive before current_sync_state is synced. A later change, including a reorg or an expired mempool note, queues another delivery with a new event_id.
The body is compact JSON produced by serde_json::to_string. The signed bytes are that string, with the fields in this order. The numbers in this example only illustrate the shape. 42 and v3 stand in for the address id and state_version. The timestamp is the whole-second RFC 3339 value used in the crate tests. A live observed_at is OffsetDateTime::now_utc() formatted as RFC 3339, so it can include a fractional second.
{"event_id":"zcash:42:v3","payment_source":"zcash","address":"<unified address>","total_received":"0.12345678","observed_at":"2026-01-01T00:00:00Z"}
| Field | Meaning |
|---|---|
event_id | zcash:<address_id>:v<state_version>. address_id is the integer id of the issued address. state_version increments by one when the eligible total changes. The column is unique, and the insert is INSERT OR IGNORE, so the same id is stored once. |
payment_source | Always zcash. |
address | The unified address from POST /payment-session. |
total_received | Eligible total in ZEC. Zatoshis divided by 100_000_000, always with 8 fractional digits (1.00000000, 0.00000001). |
observed_at | RFC 3339 UTC timestamp taken when the delivery row was queued. |
received_by_confirmations | Present only when the service sets REQUIRED_CONFIRMATIONS. See Waiting for confirmations. |
What counts toward the total
WEBHOOK_REPORT_CONFIRMATIONS defaults to 0.
- A mined note’s confirmation depth is
tip - mined_height + 1when the tip is at or above the mined height, and0otherwise. The note is eligible when that depth is at least the configured threshold. At the default of0, a mined note is eligible as soon as it is recorded. - At threshold
0, a mempool note is eligible while itsfirst_observed_atis later than 24 hours before the check. After that it stops counting. If the transaction leaves the mempool, the service keeps the provisional receipt until it has scanned the next block. When that block contains the transaction, the receipt is promoted in place. When the scanned tip does not contain it, the receipt is revoked. FINALITY_CONFIRMATIONSdefaults to100. It is the depth used for checkpointing. It does not gatetotal_received.
total_received can go up or down. A reorg or an expired mempool note lowers it and still gets a higher state_version.
Apply a webhook only when its state_version is greater than the last version you stored for that address. claim_ready_webhook_delivery claims the oldest row that is queued, or retry_wait whose next_attempt_at has arrived. A newer queued delivery can succeed while an older one is still waiting to retry, and that older event_id can arrive later. Return 2xx for the stale event and leave the invoice unchanged, so the service stops retrying it. The same event_id is also safe to acknowledge again.
Waiting for confirmations
Set REQUIRED_CONFIRMATIONS=N on the service (it needs WEBHOOK_REPORT_CONFIRMATIONS=0) and every webhook, and GET /payments/{payment_id}, also carries received_by_confirmations: an array of N+1 amounts in the same format as total_received. Element i is the total received with at least i confirmations. Element 0 includes unconfirmed mempool receipts and always equals total_received.
For N=3 and a 0.1 ZEC payment, successive webhooks carry:
"received_by_confirmations":["0.10000000","0.00000000","0.00000000","0.00000000"]
"received_by_confirmations":["0.10000000","0.10000000","0.00000000","0.00000000"]
"received_by_confirmations":["0.10000000","0.10000000","0.10000000","0.00000000"]
"received_by_confirmations":["0.10000000","0.10000000","0.10000000","0.10000000"]
An address with nothing received reports N+1 zeros. Each new block that deepens a receipt sends an event, with a new state_version. Once every element equals element 0, events stop until something changes again: new funds, a reorg, or a mempool note expiring. A reorg or expiry lowers exactly the elements it affects, so elements can go down as well as up. The same “apply only a higher state_version” rule applies.
A typical store treats the order as payment received, pending confirmation when element 0 covers the price, and fulfils it only when element N does. When the field is absent (an older service, or the setting is off), fall back to total_received.
Changing REQUIRED_CONFIRMATIONS between starts is allowed. Arrays are rebuilt silently from stored receipts, and the next event for each address uses the new length.
Checking the signature
The worker sets:
Content-Type: application/jsonX-Signature: lowercase hex HMAC-SHA256 of the raw body
The HMAC key is the UTF-8 bytes of ZCASH_WEBHOOK_SECRET. The message is the raw body bytes, before you parse JSON. Compare the hex digest to X-Signature in constant time.
On Node.js, verifyWebhook in the Node.js client does this check for you.
This is the same construction as sign_body in src/webhook_delivery.rs:
fn sign_body(secret: &str, body: &[u8]) -> String {
let mut mac = hmac::Hmac::<sha2::Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
hex::encode(mac.finalize().into_bytes())
}
Retries
The worker polls for a queued delivery every WEBHOOK_POLL_INTERVAL_SECONDS (default 2) when none is ready.
| Response | What the worker does |
|---|---|
| HTTP 2xx | Marks the delivery succeeded and records the notified total. |
| HTTP 429 or 5xx, or a transport error | Schedules retry_wait, unless this attempt reaches WEBHOOK_MAX_ATTEMPTS. |
| Any other HTTP status | Marks permanent_failure on that attempt. |
attempt_count starts at 0. The attempt being made is attempt_count + 1. When that number is greater than or equal to WEBHOOK_MAX_ATTEMPTS (default 8), a retryable failure becomes permanent.
The delay after a retryable failure is WEBHOOK_RETRY_DELAY_SECONDS (default 30) multiplied by 2 raised to attempt_count, then capped at WEBHOOK_RETRY_MAX_DELAY_SECONDS (default 300). With the defaults, the first failure waits 30 seconds, then 60, 120, and 240. The next waits are capped at 300 seconds.
run() also repairs a restart. Deliveries left in sending move back to retry_wait with next_attempt_at set to now, and addresses still marked dirty are queued again. That recovery function is private. The binary calls it. An embedder who builds the router by hand and does not call run() skips it. See Deployment.
A minimal handler
On Node.js, verifyWebhook in the Node.js client covers steps 1 to 3.
- Read the raw body.
- Recompute the HMAC and compare it to
X-Signature. - Parse the JSON.
- Read
state_versionfromevent_id(zcash:<address_id>:v<state_version>). - If this
event_idis already applied, or itsstate_versionis less than or equal to the last version stored foraddress, return 2xx and leave the invoice unchanged. - Otherwise store
total_received(andreceived_by_confirmations, if present) as the cumulative totals for that address and update the invoice. Compare the version, because a newer total can be smaller. - Return a 2xx status. Any other success-range status counts.
4xxother than429will not be retried.
Node.js client
If your store runs on Node.js, you don’t need to hand-roll the HTTP call or the signature check.
@nerdcash/zcash-payment-client
wraps both. Its source is in clients/node.
- It needs Node.js 20 or later.
- It’s ESM-only and ships TypeScript types.
- Its only dependency is Node’s built-in
node:crypto.
Install
npm install @nerdcash/zcash-payment-client
Create the client
import { createZcashPaymentClient } from "@nerdcash/zcash-payment-client";
const payments = createZcashPaymentClient({
baseUrl: process.env.ZCASH_PAYMENT_SERVICE_BASE_URL!, // e.g. http://zcash-payment-service:8787
webhookSecret: process.env.ZCASH_WEBHOOK_SECRET!, // the same secret the service signs with
// timeoutMs: 10_000, // optional, for POST /payment-session
// fetchImpl: fetch, // optional, for tests or proxies
});
Create a payment session
createPaymentSession calls POST /payment-session and returns { address, qr_text, payment_id, pay_url }. Pass returnUrl to have the hosted pay page send the payer back to your store, then redirect the payer to session.pay_url. getPayment(session.payment_id) reads the same status the pay page shows. Use the webhook, not this status, to mark orders paid.
const session = await payments.createPaymentSession({
label: "Espresso cup",
memo: `Order ${orderId}`,
message: "Thank you for your order.",
amount: "0.05000000", // ZEC as a decimal string
});
It throws PaymentServiceError when the service answers with an error or can’t be reached. error.status holds the HTTP status, or 0 when the service was unreachable.
Show the address and QR code
Show session.address as text. Render session.qr_text (a zcash: URI, see
Showing the address and the QR code) as a QR image.
Any QR library works. qrcode runs on the server and in the browser:
import QRCode from "qrcode";
const svg = await QRCode.toString(session.qr_text, { type: "svg" });
Verify a webhook
When the service sets REQUIRED_CONFIRMATIONS, both the observation and getPayment carry received_by_confirmations (see Waiting for confirmations). receivedWithConfirmations(observation, n) returns the amount with at least n confirmations, or total_received when the field is absent. It throws a RangeError for a depth the service doesn’t track, so asking for 6 confirmations from a service set to 1 fails instead of returning a shallower amount.
verifyWebhook(rawBody, signature) does three things:
- It checks
X-Signatureagainst the raw body in constant time. - It validates the payload.
- It returns the observation:
event_id,payment_source,address,total_received, andobserved_at.
Pass it the raw body bytes, not re-serialized JSON. In Express, give this route its own
express.raw() parser and register it before any app-wide express.json() (or exclude the route
from global body parsers). If a JSON parser runs first, req.body is an object and every delivery
fails verification.
import express from "express";
import { WebhookPayloadError, WebhookSignatureError } from "@nerdcash/zcash-payment-client";
app.post("/webhooks/zcash-payments", express.raw({ type: "application/json" }), async (req, res) => {
let observation;
try {
observation = payments.verifyWebhook(req.body, req.get("x-signature"));
} catch (error) {
if (error instanceof WebhookSignatureError) return res.status(401).end();
if (error instanceof WebhookPayloadError) return res.status(400).end();
throw error;
}
// event_id is zcash:<address_id>:v<state_version>.
const stateVersion = Number(observation.event_id.split(":v").pop());
// In ONE database transaction:
// 1. read the last state_version stored for observation.address (lock the row);
// 2. if stateVersion <= that value, change nothing;
// 3. otherwise store stateVersion, event_id, and total_received for the address,
// and update the invoice.
// Doing the compare and the writes atomically keeps a retried or overlapping
// older delivery from overwriting newer state.
await applyObservationAtomically( // your own persistence code
observation.address, stateVersion, observation);
res.status(200).end();
});
The client checks the signature and the payload’s shape. Your application still has to do the steps in
A minimal handler: deduplicate events, compare state_version, and decide when an invoice is paid.
Other languages
The service’s wire contract is plain HTTP and JSON. For other stacks, follow the Integration guide and the API reference.
Payment links and the pay page
Instead of drawing the address and QR code in your own checkout, you can send the payer to a pay page that the service serves itself. It runs in the same container as the rest of the service, on your own host. No other party sees the payment.
- Your application calls
POST /payment-sessionas usual, optionally with areturn_url. - The response includes
payment_idandpay_url. Redirect the payer topay_url, or send it to them as a payment link. - The page shows the amount, a QR code of the ZIP-321
zcash:URI, an Open in wallet button, and the address. It pollsGET /payments/{payment_id}every few seconds, and reloads when the payment request changes (after a partial payment it asks only for the rest, and shows the remaining amount). Once the link is paid, the QR code and wallet link are removed. - When the received total covers the amount, the page says so and, if you gave a
return_url, sends the payer back there. Without areturn_urlthe page keeps polling, so it also shows a receipt that is later dropped (for example an unconfirmed transaction that never mines). - If the URI is too long for a QR code (a very long
message), the page shows a note in place of the code; the wallet button and address still work.
Your application still learns about the payment from the signed webhook.
The redirect is only for the payer’s convenience. Never mark an order paid because the payer
arrived at your return_url: anyone can open that URL.
Creating a link
curl -s -X POST http://127.0.0.1:8787/payment-session \
-H "Content-Type: application/json" \
-d '{"payment_source":"zcash","label":"Order 1001","amount":"0.25","return_url":"https://shop.example/orders/1001"}'
{
"address": "u1...",
"qr_text": "zcash:u1...?amount=0.25",
"payment_id": "3f9c0d6e1b2a4c5d8e7f6a5b4c3d2e1f",
"pay_url": "https://pay.shop.example/pay/3f9c0d6e1b2a4c5d8e7f6a5b4c3d2e1f"
}
payment_id is 128 random bits in hex, so a link can’t be guessed from another one. pay_url
is absolute when PUBLIC_BASE_URL is set and a path such as /pay/<payment_id> otherwise.
return_url must be an absolute http or https URL without user info (user@host). Anything
else, such as javascript: or data:, is rejected with 400.
To limit where the pay page can send payers, set RETURN_URL_ALLOWED_ORIGINS to a
comma-separated list of origins, such as https://shop.example,https://www.shop.example. A
return_url on any other origin is rejected with 400. When it is unset, any http(s) URL is
accepted. That is safe only while POST /payment-session stays private (see below), since only
your application can then choose a return_url. Setting it is recommended as a second line of
defense: if the session endpoint were ever exposed by mistake, nobody could use your pay page to
send payers to their own site.
Every session gets a payment id, whether or not you use the page. The existing address and
qr_text fields are unchanged.
Status
GET /payments/{payment_id} returns JSON. The pay page uses it, and your application can too.
| Field | Meaning |
|---|---|
payment_id | The id from the session. |
address, qr_text, pay_url | As returned when the session was created. |
label, message, amount | The values the session was created with, or null. |
total_received | The address’s eligible total in ZEC, the same number the webhook carries. |
received_by_confirmations | Only when REQUIRED_CONFIRMATIONS is set. The same array the webhook carries: element i is the total with at least i confirmations. See the integration guide. |
state_version | Increments each time total_received or received_by_confirmations changes. |
status | awaiting_payment, partially_paid, paid, or received. |
return_url | The session’s return_url, or null. |
created_at | RFC 3339 UTC time the link was created. |
status compares total_received with amount:
| Status | When |
|---|---|
awaiting_payment | Nothing eligible has arrived. |
partially_paid | Something arrived, but less than amount. |
paid | total_received is at least amount. |
received | Something arrived, and amount was missing or isn’t a plain ZEC decimal (up to 8 decimal places), so it can’t be compared. |
Eligibility follows WEBHOOK_REPORT_CONFIRMATIONS, exactly as for webhooks. With the default
0, the page turns to paid as soon as the transaction reaches the mempool. Set a higher value if
you’d rather wait for confirmations.
An unknown or malformed id returns 404 with {"error":"not found: unknown payment"}.
Exposing the page
The pay page and the status route are meant to be reached by payers. POST /payment-session
is not: it has no authentication, and anyone who can reach it can mint addresses. Put a reverse
proxy in front of the service and forward only these paths to the public:
GET /pay/*GET /payments/*
Keep everything else on your private network. Then set PUBLIC_BASE_URL to the public origin
(and path prefix, if any) the proxy serves, such as https://pay.shop.example. The page fetches
status with a relative URL, so it also works under a path prefix.
A Caddy example that publishes only the pay page:
pay.shop.example {
@public path /pay/* /payments/*
handle @public {
reverse_proxy zcash-payment-service:8787
}
respond 404
}
The page loads no scripts, styles, fonts, or images from anywhere else. It is sent with a
strict Content-Security-Policy, Referrer-Policy: no-referrer, and Cache-Control: no-store.
What it doesn’t do
- It doesn’t expire links or lock an exchange rate. Price the order in ZEC when you create the session, and decide in your application how long you honor that price.
- It doesn’t refund overpayments or chase underpayments.
partially_paidtells the payer to send the rest. Your application decides what to do with any difference.
WooCommerce plugin
plugins/woocommerce/zcash-payment-gateway is a WooCommerce payment gateway that takes Zcash through your own copy of this service. It works with both the block checkout (the default for new stores) and the classic shortcode checkout, and it supports High-Performance Order Storage.
How it works
- The customer picks Zcash at checkout and places the order.
- The plugin calls
POST /payment-sessionwith the order total, the order number as the label, and the order-received page asreturn_url. It stores the issued address and payment ID on the order and leaves the order Pending payment. - The customer is sent to the service’s pay page, which shows the amount, a QR code, and an Open in wallet link, and updates live.
- The service sends a signed webhook each time the address’s total changes. The plugin checks the
X-SignatureHMAC, ignores stale events, and calls WooCommerce’spayment_complete()once the total covers the order. A partial payment adds an order note instead. - The pay page sends the customer back to the order-received page.
Edge cases the plugin handles:
- Waiting for confirmations. When the service sets
REQUIRED_CONFIRMATIONS, the plugin completes the order only once the lastreceived_by_confirmationselement covers it. A payment seen in the mempool, or with fewer confirmations, adds a pending confirmation order note and leaves the order Pending payment; a reorg that undoes those confirmations on a paid order puts it On hold. When the service doesn’t setREQUIRED_CONFIRMATIONS, the array is absent and the plugin usestotal_receivedas before. - Paying again later reuses the order’s existing address, so a payment to the address the customer already saw still counts. If the order total changed since, it gets a new address, unless money already arrived; then checkout stops and an order note asks you to reconcile by hand.
- Funds drop after payment (an unconfirmed transaction expires, or a reorg): the order goes On hold with a note.
- Payment for a cancelled, failed, or refunded order is never completed automatically, since its stock may be gone. The plugin adds a note for you to review.
- An order keeps one Zcash address for life. If you change an order’s total after its address was issued, checkout stops and a payment arriving at the old address is noted for you to reconcile rather than completing the order. The same applies if the customer switches the order to another payment method. Once a payment is set aside for review, later payments to that order are also left for you until you delete the order’s
_zcash_needs_reviewmeta. - After a partial payment, the pay page’s QR code and wallet link ask only for the remaining balance. The remainder is based on receipts the service has reported (see
WEBHOOK_REPORT_CONFIRMATIONS), so a just-mined payment can take a block or two to count. - Holds you set yourself are kept. The plugin only completes an on-hold order automatically if it put the order on hold itself (after funds dropped).
- Refunded or cancelled orders with Zcash funds are flagged for review, and a closed order can’t be paid again through checkout until you reconcile it.
- Overlapping webhook deliveries for one order are serialized with a database lock, and the event version is recorded only after the order update succeeds, so a failed update is retried.
If the service has no public pay page (no PUBLIC_BASE_URL and no Pay page base URL setting), the customer goes straight to the order-received page, which shows the address and a wallet link.
Setup
Requires WordPress 6.4+, PHP 8.1+, and WooCommerce 8.2+ (the plugin stays inactive on older WooCommerce).
-
Run the service as in Deployment, with
ZCASH_WEBHOOK_URLset to your store’s webhook route and a randomZCASH_WEBHOOK_SECRET:ZCASH_WEBHOOK_URL=https://shop.example.com/?rest_route=/zcash-payment/v1/webhookAlso choose
WEBHOOK_REPORT_CONFIRMATIONSbefore the service’s first start; it is fixed once the database exists. The default0reports unconfirmed (mempool) payments, so the plugin marks an order paid before the transaction is mined. That is fine for low-value or digital goods where speed matters. If a dropped transaction would cost you, set1or more so orders are paid only from mined transactions. Either way, if funds for a paid order later disappear, the plugin puts the order On hold and adds a note.The
?rest_route=form works with any permalink setting.https://shop.example.com/wp-json/zcash-payment/v1/webhookalso works when pretty permalinks are on. -
Expose the pay page publicly as described in Exposing the page and set
PUBLIC_BASE_URL. SetRETURN_URL_ALLOWED_ORIGINSto your store’s origin (for examplehttps://shop.example) so the pay page only ever returns payers to your store. -
Copy
plugins/woocommerce/zcash-payment-gatewayintowp-content/plugins/(or zip the folder and upload it under Plugins → Add New) and activate it. -
Under WooCommerce → Settings → Payments → Zcash, set:
- Service URL: where WordPress reaches the service, for example
http://zcash-payment-service:8787. This is a private address; it does not need to be public. - Webhook secret: the same value as
ZCASH_WEBHOOK_SECRET. - Pay page base URL: only if the service does not set
PUBLIC_BASE_URL. - ZEC per unit of store currency: only if the store does not price in ZEC. See below.
- Service URL: where WordPress reaches the service, for example
Store currency
The plugin adds ZEC to WooCommerce’s currency list. If the store prices in ZEC, order totals are sent as-is. Before entering ZEC prices, set WooCommerce → Settings → General → Number of decimals to 8; with the default of 2, a price such as 0.001 ZEC rounds to 0. If it prices in another currency, you must enter a fixed rate (ZEC per one unit of store currency) and keep it current yourself; the gateway hides itself when no rate is set. The plugin does not fetch prices from any third party.
Testing
Everything below runs without a WooCommerce.com, WordPress.com, wallet, or exchange account, and without real or test coins.
Unit tests cover amount conversion, signature checks, webhook parsing, and pay URL handling:
php plugins/woocommerce/tests/unit.php
End-to-end test (plugins/woocommerce/e2e) starts MySQL, WordPress, and this service with Docker Compose, installs WooCommerce from wordpress.org, configures the gateway, and drives a headless Chromium through the real block checkout:
cd plugins/woocommerce/e2e
npx playwright install --with-deps chromium # once per machine, after npm ci
./run.sh # KEEP_STACK=1 leaves it running on http://localhost:8080
The service runs on testnet with ZCASH_DEV_SIMULATED_PAYMENTS=true, which turns on POST /dev/simulate-payment. The test:
- places an order and checks that the browser lands on the pay page with the right amount and a QR code;
- simulates a partial payment and checks the pay page shows it, then simulates the rest;
- checks that the browser returns to the order-received page and that the order is Processing with the full amount recorded;
- simulates one payment at 0, 1, and 2 confirmations (the stack sets
REQUIRED_CONFIRMATIONS=2) and checks the order stays Pending payment with a pending confirmation note until the last; - checks that the webhook route rejects a bad signature with
401and acknowledges a signed event for an unknown address.
CI runs both on every pull request (.github/workflows/woocommerce-plugin.yml) and keeps screenshots of each step as a build artifact.
What the tests do not cover
- The chain. Simulated payments skip lightwalletd and trial decryption, and their confirmations are simulated rather than mined. The service’s own test suite covers scanning; a real testnet payment is still worth doing by hand before a release.
- Wallet apps. Nobody scans the QR code or opens the
zcash:link in a real wallet. - Other setups. Only one WooCommerce version, the default theme, and the block checkout are exercised. The classic checkout, other themes, and other plugins that change checkout are not.
- Non-ZEC pricing. The manual exchange rate path has unit tests but no end-to-end run.
- Email. The test stack has no mail server.
Easy Digital Downloads plugin
plugins/easy-digital-downloads/zcash-payment-edd is an Easy Digital Downloads (EDD) payment gateway that takes Zcash through your own copy of this service. It works with EDD’s block checkout and the classic [download_checkout] shortcode.
How it works
- The customer picks Zcash at checkout and places the order.
- The plugin records the order as Pending and calls
POST /payment-sessionwith the order total, the order number as the label, and EDD’s receipt page asreturn_url. It stores the issued address and payment ID on the order. - The customer is sent to the service’s pay page, which shows the amount, a QR code, and an Open in wallet link, and updates live.
- The service sends a signed webhook each time the address’s total changes. The plugin checks the
X-SignatureHMAC, ignores stale events, and marks the order Completed (which releases the downloads) once the total covers it. A partial payment adds an order note instead. - The pay page sends the customer back to the receipt page.
Edge cases the plugin handles:
- Waiting for confirmations. When the service sets
REQUIRED_CONFIRMATIONS, the plugin completes the order only once the lastreceived_by_confirmationselement covers it. A payment seen in the mempool, or with fewer confirmations, adds a pending confirmation order note and leaves the order Pending; a reorg that undoes those confirmations on a completed order puts it On hold. When the service doesn’t setREQUIRED_CONFIRMATIONS, the array is absent and the plugin usestotal_receivedas before. - An order keeps one Zcash address for life. If the order’s total changes after its address was issued, a payment to the old address is noted for you to reconcile rather than completing the order.
- Funds drop after payment (an unconfirmed transaction expires, or a reorg): the order goes On hold with a note. The plugin restores it only if it put the order on hold itself.
- Payment for a refunded, revoked, failed, or abandoned order is never completed automatically. Refunding or revoking an order with a Zcash address flags it for review, and later payments to it are left for you until you delete the order’s
_zcash_needs_reviewmeta. - After a partial payment, the pay page asks only for the remaining balance. The remainder is based on receipts the service has reported (see
WEBHOOK_REPORT_CONFIRMATIONS). - Overlapping webhook deliveries for one order are serialized with a database lock, and the event version is recorded only after the order update succeeds, so a failed update is retried.
If the service has no public pay page (no PUBLIC_BASE_URL and no Pay page base URL setting), the customer goes straight to the receipt page, which shows the address and a wallet link.
Setup
Requires WordPress 6.4+, PHP 8.1+, and Easy Digital Downloads 3.1+ (the plugin stays inactive on older EDD).
-
Run the service as in Deployment, with
ZCASH_WEBHOOK_URLset to your store’s webhook route and a randomZCASH_WEBHOOK_SECRET:ZCASH_WEBHOOK_URL=https://shop.example.com/?rest_route=/zcash-payment-edd/v1/webhookThe route differs from the WooCommerce plugin’s, so both plugins can run on one site. Choose
WEBHOOK_REPORT_CONFIRMATIONSbefore the service’s first start, as explained in the WooCommerce setup; the default0completes orders from unconfirmed transactions, which suits instant digital delivery but means a dropped transaction leaves files already delivered. -
Expose the pay page publicly as described in Exposing the page and set
PUBLIC_BASE_URL. SetRETURN_URL_ALLOWED_ORIGINSto your store’s origin. -
Copy
plugins/easy-digital-downloads/zcash-payment-eddintowp-content/plugins/(or zip the folder and upload it) and activate it. -
Under Downloads → Settings → Payments, enable Zcash (self-hosted), then on its Zcash section set:
- Service URL: where WordPress reaches the service, for example
http://zcash-payment-service:8787. This does not need to be public. - Webhook secret: the same value as
ZCASH_WEBHOOK_SECRET. - Pay page base URL: only if the service does not set
PUBLIC_BASE_URL. - ZEC per unit of store currency: only if the store does not price in ZEC.
- Service URL: where WordPress reaches the service, for example
The gateway hides itself at checkout until the service URL and secret are set.
Store currency
The plugin adds ZEC to EDD’s currency list and shows ZEC amounts with 8 decimal places. If the store prices in ZEC, order totals are sent as-is. If it prices in another currency, you must enter a fixed rate (ZEC per one unit of store currency) and keep it current yourself; the gateway hides itself when no rate is set. The plugin does not fetch prices from any third party.
Testing
Everything below runs without an EDD, WordPress.com, wallet, or exchange account, and without real or test coins.
Unit tests cover amount conversion, signature checks, webhook parsing, and pay URL handling:
php plugins/easy-digital-downloads/tests/unit.php
End-to-end test (plugins/easy-digital-downloads/e2e) starts MySQL, WordPress, and this service with Docker Compose, installs EDD from wordpress.org, configures the gateway, and drives a headless Chromium through the block checkout:
cd plugins/easy-digital-downloads/e2e
npm ci && npx playwright install --with-deps chromium # once per machine
./run.sh # KEEP_STACK=1 leaves it running on http://localhost:8080
The service runs on testnet with ZCASH_DEV_SIMULATED_PAYMENTS=true, which turns on POST /dev/simulate-payment. The test:
- buys a download and checks that the browser lands on the pay page with the right amount and a QR code;
- simulates a partial payment and checks the pay page shows it, then simulates the rest;
- checks that the browser returns to the receipt page and that the order is Completed with the full amount recorded;
- pays a revoked order and checks that the payment is held for review and the order stays revoked;
- simulates one payment at 0, 1, and 2 confirmations (the stack sets
REQUIRED_CONFIRMATIONS=2) and checks the order stays Pending with a pending confirmation note until the last; - checks that the webhook route rejects a bad signature with
401and acknowledges a signed event for an unknown address.
CI runs both on every pull request through .github/workflows/checks.yml, which calls .github/workflows/edd-plugin.yml, and keeps screenshots of each step as a build artifact.
What the tests do not cover
- The chain. Simulated payments skip lightwalletd and trial decryption, and their confirmations are simulated rather than mined.
- Wallet apps. Nobody scans the QR code or opens the
zcash:link in a real wallet. - Other setups. Only one EDD version, the default theme, and the block checkout are exercised.
- Non-ZEC pricing. The manual exchange rate path has unit tests but no end-to-end run.
- Email. The test stack has no mail server.
PrestaShop module
plugins/prestashop/zcashpayment is a PrestaShop payment module that takes Zcash through your own copy of this service. It is tested on PrestaShop 9 with the default Hummingbird theme and needs PrestaShop 8.1 or later on PHP 8.1 or later.
How it works
- The customer picks Pay with Zcash (ZEC) at checkout and places the order.
- The module creates the order in its own Awaiting Zcash payment state and calls
POST /payment-sessionwith the order total, the order reference as the label, and PrestaShop’s order confirmation page asreturn_url. It stores the issued address and payment ID in its ownzcash_payment_ordertable. - The customer is sent to the service’s pay page, which shows the amount, a QR code, and an Open in wallet link, and updates live.
- The service sends a signed webhook each time the address’s total changes. The module checks the
X-SignatureHMAC, ignores stale events, and moves the order to Payment accepted once the total covers it. That records an order payment for the full amount. A partial payment adds a private order message instead. - The pay page sends the customer back to the order confirmation page.
Edge cases the module handles:
- An order keeps one Zcash address for life. If the order’s total changes after its address was issued, a payment to the old address is noted for you to reconcile rather than completing the order.
- Waiting for confirmations. When the service sets
REQUIRED_CONFIRMATIONS, the module marks the order Payment accepted only once the lastreceived_by_confirmationselement covers it. A payment seen in the mempool, or with fewer confirmations, adds a pending confirmation message and leaves the order Awaiting Zcash payment; a reorg that undoes those confirmations on a paid order puts it in Zcash funds on hold. When the service doesn’t setREQUIRED_CONFIRMATIONS, the array is absent and the module usestotal_receivedas before. - Funds drop after payment (an unconfirmed transaction expires, or a reorg): the order goes to Zcash funds on hold with a message. The module restores the earlier state only if it put the order on hold itself.
- Payment for a cancelled, refunded, or failed order is never applied automatically. Moving an order with a Zcash address to one of those states, or issuing a credit slip for it, flags it for review. Later payments to it are left for you until you clear
needs_reviewfor the order inzcash_payment_order. - After a partial payment, the pay page asks only for the remaining balance. The remainder is based on receipts the service has reported (see
WEBHOOK_REPORT_CONFIRMATIONS). - Overlapping webhook deliveries for one order are serialized with a MySQL named lock, and the event version is recorded only after the order update succeeds, so a failed update or database error is retried. State changes and credit slips from the back office wait for the same lock, so a cancellation can’t slip in between a webhook’s check and its update.
- Carts that PrestaShop would split into several orders (several packages or carriers) aren’t offered Zcash, since one address pays exactly one order.
If the service has no public pay page (no PUBLIC_BASE_URL and no Pay page base URL setting), the customer goes straight to the order confirmation page, which shows the address and a wallet link.
Setup
-
Run the service as in Deployment, with
ZCASH_WEBHOOK_URLset to the module’s webhook and a randomZCASH_WEBHOOK_SECRET:ZCASH_WEBHOOK_URL=https://shop.example.com/index.php?fc=module&module=zcashpayment&controller=webhookUse your shop’s own domain: PrestaShop redirects requests for any other host name, and a redirected webhook arrives as a
GETthat the module rejects. ChooseWEBHOOK_REPORT_CONFIRMATIONSbefore the service’s first start, as explained in the WooCommerce setup; the default0accepts orders from unconfirmed transactions. -
Expose the pay page publicly as described in Exposing the page and set
PUBLIC_BASE_URL. SetRETURN_URL_ALLOWED_ORIGINSto your shop’s origin. -
Zip the
plugins/prestashop/zcashpaymentfolder (the zip must contain thezcashpaymentfolder) and upload it under Modules → Module Manager → Upload a module, or copy the folder intomodules/and install it there. -
Open the module’s Configure page and set:
- Service URL: where PrestaShop reaches the service, for example
http://zcash-payment-service:8787. This does not need to be public. - Webhook secret: the same value as
ZCASH_WEBHOOK_SECRET. - Pay page base URL: only if the service does not set
PUBLIC_BASE_URL. - ZEC per unit of store currency: only if the shop does not price in ZEC.
The page also shows the webhook URL to give the service.
- Service URL: where PrestaShop reaches the service, for example
The payment option is hidden at checkout until the service URL and secret are set.
Store currency
Installing the module adds a disabled ZEC currency with 8 decimal places. To price in ZEC, enable it under International → Localization → Currencies, and check that the module is allowed for it under Payment → Preferences. Order totals in ZEC are sent as-is. PrestaShop stores amounts with 6 decimal places, so prices finer than 0.000001 ZEC are not possible.
If the shop’s default currency is not ZEC, you can enter a fixed rate (ZEC per one unit of that default currency) and keep it current yourself. The rate is used only for carts in the default currency; carts in any other currency, and all non-ZEC carts when no rate is set, aren’t offered Zcash. The module does not fetch prices from any third party.
Testing
Everything below runs without a PrestaShop, wallet, or exchange account, and without real or test coins.
Unit tests cover amount conversion, signature checks, webhook parsing, and pay URL handling:
php plugins/prestashop/tests/unit.php
End-to-end test (plugins/prestashop/e2e) starts MySQL, PrestaShop, and this service with Docker Compose, installs and configures the module, and drives a headless Chromium through a guest checkout:
cd plugins/prestashop/e2e
npm ci && npx playwright install --with-deps chromium # once per machine
./run.sh # KEEP_STACK=1 leaves it running on http://localhost:8080
The service runs on testnet with ZCASH_DEV_SIMULATED_PAYMENTS=true, which turns on POST /dev/simulate-payment. The test:
- buys a virtual product and checks that the browser lands on the pay page with the right amount and a QR code;
- simulates a partial payment and checks the pay page shows it, then simulates the rest;
- checks that the browser returns to the confirmation page and that the order is Payment accepted with the full amount recorded;
- pays a cancelled order and checks that the payment is held for review and the order stays cancelled;
- simulates one payment at 0, 1, and 2 confirmations (the stack sets
REQUIRED_CONFIRMATIONS=2) and checks the order stays Awaiting Zcash payment with a single pending confirmation message until the last; - checks that the webhook rejects a bad signature with
401and acknowledges a signed event for an unknown address.
CI runs both on every pull request through .github/workflows/checks.yml, which calls .github/workflows/prestashop-plugin.yml, and keeps screenshots of each step as a build artifact.
What the tests do not cover
- The chain. Simulated payments skip lightwalletd and trial decryption, and their confirmations are simulated rather than mined.
- Wallet apps. Nobody scans the QR code or opens the
zcash:link in a real wallet. - Other setups. Only one PrestaShop version, the default theme, and guest checkout are exercised.
- Non-ZEC pricing. The manual exchange rate path has unit tests but no end-to-end run.
- Email. The test stack has no mail server.
Medusa plugin
plugins/medusa is a Medusa v2 plugin (@nerdcash/medusa-payment-zcash) that takes Zcash through your own copy of this service. It adds a payment provider, a signed webhook route, and an order widget in the admin.
How it works
- The customer picks Zcash at checkout. Medusa creates a payment session, and the provider calls
POST /payment-sessionwith the cart total and areturn_url. It stores the issued address and the pay page link (pay_url) in the session data. - The storefront completes the cart. The session is pending authorization, so Medusa places the order with payment still outstanding, and the storefront sends the customer to
pay_url. - The service sends a signed webhook to
/hooks/zcasheach time the address’s total changes. The plugin checks theX-SignatureHMAC, finds the session by its address, ignores stale events, and confirms the total withGET /payments/{id}. Once the total covers the order, it runs Medusa’sprocessPaymentWorkflow, which authorizes and captures the payment. A partial payment is recorded but captures nothing. - The pay page sends the customer back to your storefront.
Edge cases the plugin handles:
- Waiting for confirmations. When the service sets
REQUIRED_CONFIRMATIONS, the session is authorized as soon astotal_received(mempool included) covers it, so checkout can finish, but the plugin captures only once the lastreceived_by_confirmationselement covers it. A capture from the admin is refused until then too. Until then it logs pending confirmation. A reorg that undoes those confirmations after capture sets the hold described below. When the service doesn’t setREQUIRED_CONFIRMATIONS, the array is absent and the plugin usestotal_receivedas before. - Funds drop after capture (an unconfirmed transaction expires, or a reorg): the plugin sets the order’s
zcash_payment_holdmetadata, shows it in the admin widget, and emitszcash_payment.hold_changedso your fulfillment code can wait. When the funds return and the service confirms the full total, the hold is cleared. Releasing a hold never captures again or re-runs order placement or fulfillment. - Holds you set yourself are kept. If
zcash_payment_holdalready has a value, the plugin leaves it alone, and it only clears the exact message it wrote. - A payment’s amount is fixed once its address is issued. If the cart total changes, start a new payment session; the old address is not reused or re-priced.
- Payment for a canceled order is never captured. The plugin records it in
zcash_payment_reviewmetadata, shown in the widget, for you to reconcile. If a cancellation lands while a capture is in flight, the order is flagged the same way so you can refund it. - Stale or replayed events are ignored by their event version. Overlapping deliveries for one session are serialized with Medusa’s locking module, and the version is stored only after the update succeeds, so a failed update is retried.
- State is kept in session metadata, which storefront clients can’t write, and amounts are converted with exact decimal math.
Setup
Requires Medusa 2.22 or later and Node.js 20+.
-
Run the service as in Deployment, with
ZCASH_WEBHOOK_URLpointing at your Medusa server’s/hooks/zcashroute and a randomZCASH_WEBHOOK_SECRET. SetPUBLIC_BASE_URLandRETURN_URL_ALLOWED_ORIGINS(your storefront’s origin) as described in Exposing the page. See the WooCommerce page for choosingWEBHOOK_REPORT_CONFIRMATIONS; the same trade-off applies. -
Build and install the plugin. It isn’t on npm yet:
cd plugins/medusa && npm ci && npm run build && npm pack cd /path/to/your-medusa-app && npm install /path/to/nerdcash-medusa-payment-zcash-0.1.0.tgz -
Register it in
medusa-config.ts:plugins: [{ resolve: "@nerdcash/medusa-payment-zcash", options: {} }], modules: [ { resolve: "@medusajs/medusa/payment", options: { providers: [ { resolve: "@nerdcash/medusa-payment-zcash/providers/zcash", id: "zcash", options: { serviceUrl: "http://zcash-payment-service:8787", // private address webhookSecret: process.env.ZCASH_WEBHOOK_SECRET, zecRate: { usd: "0.025" }, // ZEC per unit of store currency returnUrl: "https://shop.example/order/confirmed?session_id={session_id}", }, }, ], }, }, ],Option Meaning serviceUrlWhere Medusa reaches the service. It does not need to be public. webhookSecretThe same value as ZCASH_WEBHOOK_SECRET.zecRateA decimal string, or a map by currency code. Required unless the region prices in ZEC. The plugin does not fetch prices from any third party; keep it current yourself. returnUrlWhere the pay page returns the customer. {session_id}is replaced with the payment session id. A storefront may passreturn_urlin the session data instead.payBaseUrlOnly if the service does not set PUBLIC_BASE_URL. -
Run
npx medusa db:migrate, then enable the providerpp_zcash_zcashon your regions in the admin (Settings → Regions).
Storefront
After initiatePaymentSession with provider_id: "pp_zcash_zcash", complete the cart as usual, then redirect the customer to the session’s data.pay_url. The order’s payment_status turns captured once the payment arrives. plugins/medusa/e2e/app/src/api/demo-store contains a minimal working storefront.
Testing
Nothing below needs a wallet, an exchange account, or test coins.
Unit tests cover amount conversion, signature checks, webhook parsing, and the webhook decision rules (capture, partial, hold, release, review, stale):
cd plugins/medusa && npm ci && npm run typecheck && npm test && npm run build
End-to-end test (plugins/medusa/e2e) starts Postgres, a Medusa store with the plugin installed, and this service with Docker Compose, then drives a headless Chromium through checkout:
cd plugins/medusa/e2e
npm ci && npx playwright install --with-deps chromium # once per machine
./run.sh # KEEP_STACK=1 leaves it running on http://localhost:9000
The service runs on testnet with ZCASH_DEV_SIMULATED_PAYMENTS=true, which turns on POST /dev/simulate-payment. The test:
- places an order and checks the pay page shows the right amount;
- simulates a partial payment, then the rest, and checks the browser returns to the store and the order is captured exactly once;
- sends signed webhooks for a drop in funds (the order is held), a stale event (ignored), and the recovery (the hold is released with no second capture);
- pays a canceled order and checks it is flagged for review, not captured;
- simulates one payment at 0, 1, and 2 confirmations (the stack sets
REQUIRED_CONFIRMATIONS=2) and checks it is captured only at the last; - checks that the webhook rejects a bad signature with
401, acknowledges an unknown address, and that the service refuses a foreignreturn_url.
CI runs both on every pull request (.github/workflows/medusa-plugin.yml) and keeps screenshots, including the admin widget, as a build artifact.
What the tests do not cover
- The chain. Simulated payments skip lightwalletd and trial decryption, and their confirmations are simulated rather than mined. A real testnet payment is still worth doing by hand before a release.
Vendure plugin
plugins/vendure is a Vendure 3 plugin (@nerdcash/vendure-payment-zcash) that takes Zcash through your own copy of this service. It adds a payment method handler, a signed webhook route, and guards that keep a held order from being fulfilled.
How it works
- The storefront calls
addPaymentToOrderwith methodzcash. The handler callsPOST /payment-sessionwith the order total and areturn_url, and stores the issued address as the payment’stransactionId. The payment is Authorized, so Vendure places the order (PaymentAuthorized) with payment still outstanding. - The storefront sends the customer to the pay page. Its link is in the payment’s metadata as
pay_url; the Shop API shows only the metadata’spublicpart, which also holds the address, the ZEC amount, and the QR text. - The service sends a signed webhook to
/zcash-payments/webhookeach time the address’s total changes. The plugin checks theX-SignatureHMAC, finds the payment by its address, ignores stale events, and confirms the total withGET /payments/{id}. Once the total covers the payment, it settles it, and the order moves toPaymentSettled. A partial payment is recorded in the order history but settles nothing. - The pay page sends the customer back to your storefront.
Settling a Zcash payment by hand in the admin runs the same live check, so it is refused until the service reports enough received. It is also refused for a cancelled order or a payment flagged for review. A refusal leaves the payment’s state alone, so it can still settle later.
Edge cases the plugin handles:
- Waiting for confirmations. When the service sets
REQUIRED_CONFIRMATIONS, the plugin settles the payment only once the lastreceived_by_confirmationselement covers it, and an administrator’s settle is refused until then. A payment seen in the mempool, or with fewer confirmations, adds a pending confirmation order note; a reorg that undoes those confirmations after settlement holds the order. When the service doesn’t setREQUIRED_CONFIRMATIONS, the array is absent and the plugin usestotal_receivedas before. - Funds drop after settlement (an unconfirmed transaction expires, or a reorg): the plugin puts the order on hold. It adds a private history note and publishes a
ZcashPaymentHoldEvent. While held, fulfillments can’t be created, shipped, or delivered, and the order can’t move to a shipped or delivered state. When the funds return and the service confirms the full total, the hold is released. Releasing a hold never settles again or re-runs order placement or fulfillment. - A payment’s amount is fixed once its address is issued. If the order changes, add a new payment; the old address is not reused or re-priced.
- Payment for a cancelled order is never settled. The plugin adds a history note and publishes a
ZcashPaymentReviewEventso you can refund it. If a cancellation lands while a settlement is in flight, the order is flagged the same way. - Stale or replayed events are ignored by their event version. Overlapping deliveries for one payment are serialized with a row lock (on SQLite, by its one-writer-at-a-time transactions). The version is stored only after the update succeeds, so a failed update is retried. A new fulfillment takes the same lock, so a hold can’t land between the hold check and the fulfillment being saved.
- State is kept in the payment’s metadata, which storefronts can’t write, and amounts are converted with exact integer math. The metadata also marks the payments the plugin issued, so the payment method can have any code.
Setup
Requires Vendure 3 and Node.js 20+.
-
Run the service as in Deployment, with
ZCASH_WEBHOOK_URLpointing at your Vendure server’s/zcash-payments/webhookroute and a randomZCASH_WEBHOOK_SECRET. SetPUBLIC_BASE_URLandRETURN_URL_ALLOWED_ORIGINS(your storefront’s origin) as described in Exposing the page. See the WooCommerce page for choosingWEBHOOK_REPORT_CONFIRMATIONS; the same trade-off applies. -
Build and install the plugin. It isn’t on npm yet:
cd plugins/vendure && npm ci && npm run build && npm pack cd /path/to/your-vendure-app && npm install /path/to/nerdcash-vendure-payment-zcash-0.1.0.tgz -
Add it to
vendure-config.ts:import { ZcashPaymentPlugin } from "@nerdcash/vendure-payment-zcash" plugins: [ ZcashPaymentPlugin.init({ serviceUrl: "http://zcash-payment-service:8787", // private address webhookSecret: process.env.ZCASH_WEBHOOK_SECRET!, zecRate: { usd: "0.025" }, // ZEC per unit of store currency returnUrl: "https://shop.example/order/{order_code}", }), ],Option Meaning serviceUrlWhere Vendure reaches the service. It does not need to be public. webhookSecretThe same value as ZCASH_WEBHOOK_SECRET.zecRateA decimal string, or a map by currency code. Required unless the channel prices in ZEC. The plugin does not fetch prices from any third party; keep it current yourself. returnUrlWhere the pay page returns the customer. {order_code}is replaced with the order code. A storefront may passreturn_urlin theaddPaymentToOrdermetadata instead.payBaseUrlOnly if the service does not set PUBLIC_BASE_URL. -
In the admin, create a payment method with the Zcash handler (Settings → Payment methods).
Storefront
Move the order to ArrangingPayment, call addPaymentToOrder(input: { method: "<your payment method code>", metadata: {} }), then redirect the customer to the payment’s metadata.public.pay_url. The order turns PaymentSettled once the payment arrives. plugins/vendure/e2e/app/src/demo-store.plugin.ts contains a minimal working storefront.
Testing
Nothing below needs a wallet, an exchange account, or test coins.
Unit tests cover amount conversion, signature checks, webhook parsing, and the webhook decision rules (settle, partial, hold, release, review, stale):
cd plugins/vendure && npm ci && npm run typecheck && npm test && npm run build
End-to-end test (plugins/vendure/e2e) starts Postgres, a Vendure server with the plugin and the admin UI installed, and this service with Docker Compose, then drives a headless Chromium through checkout:
cd plugins/vendure/e2e
npm ci && npx playwright install --with-deps chromium # once per machine
./run.sh # KEEP_STACK=1 leaves it running on http://localhost:3000
The service runs on testnet with ZCASH_DEV_SIMULATED_PAYMENTS=true, which turns on POST /dev/simulate-payment. The test:
- places an order and checks the pay page shows the right amount;
- simulates a partial payment, then the rest, and checks the browser returns to the store and the order is settled exactly once;
- sends signed webhooks for a drop in funds (the order is held and a fulfillment is refused), a stale event (ignored), and the recovery (the hold is released with no second settlement, and a fulfillment then succeeds once);
- checks that an administrator can’t settle an unpaid Zcash payment;
- pays a cancelled order and checks it is flagged for review, not settled;
- simulates one payment at 0, 1, and 2 confirmations (the stack sets
REQUIRED_CONFIRMATIONS=2) and checks it is settled only at the last, with an administrator’s settle refused before then; - checks that the webhook rejects a bad signature with
401, acknowledges an unknown address, and that the service refuses a foreignreturn_url.
CI runs both on every pull request (.github/workflows/vendure-plugin.yml) and keeps screenshots, including the admin order page, as a build artifact.
What the tests do not cover
- The chain. Simulated payments skip lightwalletd and trial decryption, and their confirmations are simulated rather than mined. A real testnet payment is still worth doing by hand before a release.
API reference
This page is the contract implemented in this repository. Request and response fields are the serde names on the Rust structs. Nothing here adds a route, a field, or a header that the code does not set.
The crate depends on axum 0.8.9. Where a failure comes from axum’s Json extractor rather than this crate’s AppError, the status below is the one axum’s own tests assert for that version.
HTTP
build_router registers four routes and no middleware:
| Method | Path | Handler |
|---|---|---|
GET | /health | service::health |
POST | /payment-session | service::payment_session |
GET | /payments/{payment_id} | service::payment_status |
GET | /pay/{payment_id} | pay_page::pay_page (HTML) |
Handlers that return AppError respond with JSON:
{"error":"<Display of the AppError>"}
| Variant | Status | Display prefix |
|---|---|---|
InvalidConfig | 400 | invalid configuration: |
InvalidRequest | 400 | invalid request: |
NotFound | 404 | not found: |
StartupIntegrity | 400 | startup integrity check failed: |
Wallet | 500 | wallet error: |
Database | 500 | database error: |
Io | 500 | io error: |
Json | 500 | json error: |
Http | 500 | http error: |
TimeFormat | 500 | time formatting error: |
TimeParse | 500 | time parsing error: |
GET /health returns Database when the metadata query fails. POST /payment-session returns InvalidRequest for a bad payment_source, and Wallet or Database when minting an address fails. Identity failures are returned by PaymentService::initialize and run() before the socket listens. They use the same AppError display text, as a process error.
GET /health
Returns 200 and HealthResponse:
| Field | JSON type | Source |
|---|---|---|
ok | boolean | Always true when the handler succeeds. |
current_sync_state | string | service_metadata.current_sync_state. |
detected_tip_height | number or null | last_seen_tip_height when it is greater than 0, otherwise null. |
last_scanned_height | number or null | last_scanned_height when it is greater than 0, otherwise null. |
The row inserted at first startup has current_sync_state "starting" and both heights 0, so both JSON heights are null.
The sync worker later writes one of these states:
| State | When |
|---|---|
catching_up | A scan toward the sampled tip is in progress. |
synced | The last scanned height is at or past the sampled tip. |
blocked_scanning_key_material | ZCASH_UIVK is unset, so scanning keys cannot be built. |
blocked_lightwalletd_endpoint | The UIVK is set and LIGHTWALLETD_URL is unset. |
record_sync_status stores whatever string it is given. Those four, plus the initial starting, are the ones the running service writes.
The unit test health_response_reports_persisted_sync_heights records state catching_up with tip 321 and scanned height 300, and the handler returns:
{
"ok": true,
"current_sync_state": "catching_up",
"detected_tip_height": 321,
"last_scanned_height": 300
}
POST /payment-session
Body JSON, content type application/json (a subtype with a +json suffix is also accepted by axum). PaymentSessionRequest:
| Field | Rust type | Required | Role |
|---|---|---|---|
payment_source | String | yes | Must equal zcash. |
label | Option<String> | no | Stored on the issued address. |
memo | Option<String> | no | Stored, and copied into qr_text (base64url-encoded, as ZIP-321 requires) when non-blank. |
message | Option<String> | no | Stored, and copied into qr_text when non-blank. |
amount | Option<String> | no | Stored as text, and copied into qr_text when non-blank. Only the pay page status parses it (see Payment links). |
return_url | Option<String> | no | Absolute http(s) URL the pay page returns the payer to once paid. Blank is treated as unset. |
Unknown JSON fields are ignored. A missing payment_source, or a value of the wrong JSON type, never reaches the handler.
| Failure | Status | Body |
|---|---|---|
| Content-Type is not JSON | 415 | axum’s plain-text rejection |
| JSON syntax is invalid | 400 | plain text starting with Failed to parse the request body as JSON |
| JSON does not match the struct | 422 | plain text starting with Failed to deserialize the JSON body into the target type |
payment_source is not zcash | 400 | {"error":"invalid request: payment_source must be 'zcash'"} |
return_url is not an absolute http(s) URL, has user info, or is not on an allowed origin | 400 | {"error":"invalid request: return_url must ..."} |
| Address derivation or the database fails | 500 | {"error":"wallet error: ..."} or {"error":"database error: ..."} |
Success is 200 and PaymentSessionResponse:
| Field | Meaning |
|---|---|
address | Newly derived unified address. Each successful call returns a different one. The test fresh_addresses_are_real_unified_addresses expects the string to start with u. |
qr_text | zcash:<address> plus an optional query. |
payment_id | 32 lowercase hex characters (128 random bits) identifying this session’s pay page. |
pay_url | <PUBLIC_BASE_URL>/pay/<payment_id>, or /pay/<payment_id> when PUBLIC_BASE_URL is unset. |
Query construction in build_qr_text, covered by tests in src/service.rs:
zcash:uaddr
zcash:uaddr?amount=0.12345678&memo=SW52b2ljZSBJTlYtMTAwMQ&message=Thanks%20for%20your%20purchase.
Order is amount, then memo, then message. A missing or whitespace-only field is omitted. The value that is encoded is the original string, not a trimmed copy. As ZIP-321 requires, memo is the unpadded base64url encoding of its UTF-8 bytes. For amount and message, unreserved characters stay as themselves (A-Z a-z 0-9 - _ . ~). Every other byte becomes % plus two uppercase hex digits.
The address is derived from the canonical UIVK with Orchard required, Sapling required, and the transparent receiver omitted. The app database also stores an Ironwood receiver whose bytes equal the Orchard receiver.
GET /payments/{payment_id} and GET /pay/{payment_id}
GET /payments/{payment_id} returns PaymentStatusResponse as JSON. GET /pay/{payment_id} returns the hosted pay page as HTML. Both return 404 NotFound for an id that isn’t 32 lowercase hex characters or isn’t known. Fields, statuses, and how to expose them are in Payment links and the pay page.
POST /dev/simulate-payment (testnet test hook)
Off unless ZCASH_DEV_SIMULATED_PAYMENTS=true on testnet with no LIGHTWALLETD_URL; otherwise 404. Body {"address": "<issued address>", "amount": "0.2"}. Records a fake mined receipt of amount ZEC for that address, then queues the normal webhook. Returns {"txid": "...", "total_received": "0.20000000"}. Two optional fields drive confirmation depth deterministically: confirmations (0 records a mempool receipt, n a receipt with n confirmations; omitted means fully confirmed) and txid (re-reports an earlier simulated transaction, for example at a deeper confirmations, instead of adding a new one; the same txid at another address is a separate receipt there, like another output of one transaction). An explicit confirmations below WEBHOOK_REPORT_CONFIRMATIONS is recorded but not counted or reported yet, like a scanned receipt. It exists so store plugins can be tested end to end without a chain or funds. Never enable it on a service that takes real orders: anyone who can reach it can mark an address paid.
Webhook
This is a request the service sends, not a route it serves. spawn_webhook_delivery_worker starts the loop only when both webhook_url and webhook_secret are set.
- Method:
POST - URL:
ZCASH_WEBHOOK_URL - Header
Content-Type:application/json - Header
X-Signature: lowercase hex HMAC-SHA256 - Body: the exact
request_body_jsonstored when the delivery was queued
The HMAC key is ZCASH_WEBHOOK_SECRET as UTF-8 bytes. The message is the raw body. sign_body uses hmac with sha2::Sha256 and encodes the digest with hex::encode.
The stored JSON is serde_json::to_string of these fields, in this order:
| Field | Value |
|---|---|
event_id | zcash:<address_id>:v<state_version> |
payment_source | zcash |
address | issued_addresses.unified_address |
total_received | Eligible zatoshis rendered as ZEC |
observed_at | RFC 3339 UTC time when the row was queued |
received_by_confirmations | Only when REQUIRED_CONFIRMATIONS is set: array of N+1 ZEC strings, see below |
format_zat_as_zec divides the absolute zatoshis by 100_000_000 and prints the remainder in 8 digits. 12345678 zatoshis become 0.12345678. 100000000 become 1.00000000. A negative i64 gets a leading -.
received_by_confirmations element i sums the same active, eligible receipts that have a confirmation depth of at least i at the tip the address was last computed for (mempool receipts have depth 0). Element 0 therefore always equals total_received, and the array never increases from left to right. It is stored as address_totals.received_by_confirmations and copied into the delivery row, so retries resend the exact signed body.
state_version increments by one when current_total_zat changes, or when received_by_confirmations changes (a new block deepening a receipt, a reorg, or a mempool expiry). current_total_zat is the sum of value_zat over receipts for that address that are active and eligible_for_webhook. A delivery is inserted when dirty_state is dirty and the current total, or the current received_by_confirmations, differs from the newest outstanding or acknowledged one. Once every element equals element 0 the array stops changing, so no further deliveries are queued for that address until funds arrive or a reorg undoes a block. event_id is unique, and the insert is INSERT OR IGNORE.
claim_ready_webhook_delivery takes the oldest row that is queued, or retry_wait with next_attempt_at already due, ordered by observed_at then delivery_id. A newer queued delivery can be sent while an older one is still in retry_wait. Receivers should keep the highest state_version they have applied for an address. A later delivery of a lower version is an older total, including after a reorg where the newer total is smaller.
Eligibility:
- Mined receipt: confirmation depth is
tip_height - mined_height + 1whentip_height >= mined_height, else0. Eligible when the depth is>= webhook_report_confirmations. - Mempool receipt: eligible when
webhook_report_confirmations == 0andfirst_observed_atis strictly later than 24 hours before the check. The sync loop reconciles mempool receipts only in that zero-confirmation mode.
Delivery outcomes are in the integration guide. On startup, run() moves rows left in sending back to retry_wait with next_attempt_at set to the current time, then queues addresses whose dirty_state is still dirty.
Configuration
Config::from_env reads the process environment. Strings are trimmed. An empty string is treated as unset for ZCASH_UIVK, LIGHTWALLETD_URL, ZCASH_WEBHOOK_URL, and ZCASH_WEBHOOK_SECRET.
| Variable | Default | Accepted values |
|---|---|---|
LISTEN_ADDR | 0.0.0.0:8787 when PORT is also unset | Used as the bind address when set. Wins over PORT. |
PORT | 8787 | Used only when LISTEN_ADDR is unset. Bind address becomes 0.0.0.0:<port>. |
ZCASH_NETWORK | testnet | testnet or mainnet. |
ZCASH_UIVK | unset | UIVK text. See startup rules. |
ZCASH_BIRTHDAY_HEIGHT | unset | Must parse as u32. |
LIGHTWALLETD_URL | unset | Passed to the lightwalletd client. Required for scanning. |
APP_DATA_DIR | data/zcash-payment | Base directory for the default paths below. |
APP_DB_PATH | <APP_DATA_DIR>/app.db | Must differ from WALLET_DB_PATH. |
WALLET_DB_PATH | <APP_DATA_DIR>/wallet.db | zcash_client_sqlite database. |
LOG_DIR | <APP_DATA_DIR>/logs | Daily JSON file zcash-payment.log. |
CATCH_UP_THRESHOLD_BLOCKS | 1 | Parses as u32, including 0. |
CATCH_UP_BATCH_SIZE | 100 | u32, must be greater than 0. |
SCAN_PARALLELISM | number of CPUs the process may use | usize, must be greater than 0. Caps the threads used to trial-decrypt compact blocks. 1 scans serially. See Deployment. |
SYNC_POLL_INTERVAL_SECONDS | 5 | u64, must be greater than 0. Fallback delay when the mempool stream is unavailable. |
ZCASH_WEBHOOK_URL | unset | Absolute http or https URL with a host. |
ZCASH_WEBHOOK_SECRET | unset | HMAC key. Delivery runs only when this and the URL are both set. |
PUBLIC_BASE_URL | unset | Absolute http(s) URL with an optional path prefix and no query or fragment; trailing / removed. Prefix for pay_url. See Payment links. |
ZCASH_DEV_SIMULATED_PAYMENTS | false | true/false/1/0. Enables POST /dev/simulate-payment. Rejected unless ZCASH_NETWORK=testnet and LIGHTWALLETD_URL is unset (no real scanning runs alongside it). For tests only. A database that ever recorded a simulated payment is marked, and the service refuses to start on it with LIGHTWALLETD_URL set; use a fresh database for real scanning. |
RETURN_URL_ALLOWED_ORIGINS | unset | Comma-separated http(s) origins (no path) a session’s return_url must match. Unset allows any http(s) URL. See Payment links. |
WEBHOOK_POLL_INTERVAL_SECONDS | 2 | u64, greater than 0. |
WEBHOOK_RETRY_DELAY_SECONDS | 30 | u64, greater than 0. Base of the exponential backoff. |
WEBHOOK_RETRY_MAX_DELAY_SECONDS | 300 | u64, greater than 0. Cap on the backoff. |
WEBHOOK_MAX_ATTEMPTS | 8 | u32, greater than 0. |
WEBHOOK_REPORT_CONFIRMATIONS | 0 | u32. 0 includes provisional mempool receipts. |
FINALITY_CONFIRMATIONS | 100 | u32. Stored and used for checkpointing. Does not gate total_received. |
REQUIRED_CONFIRMATIONS | unset | u32 N, at most 100 and at most FINALITY_CONFIRMATIONS. Adds received_by_confirmations (N+1 elements) to webhooks and GET /payments/{id}. Requires WEBHOOK_REPORT_CONFIRMATIONS=0, because element 0 includes mempool receipts. Unset (the default, and every existing database) omits the field. |
RUST_LOG | info | tracing-subscriber EnvFilter directive. |
A value that fails its parse or range check returns InvalidConfig and the process does not listen. The error strings include:
ZCASH_NETWORK must be 'testnet' or 'mainnet'ZCASH_BIRTHDAY_HEIGHT must be a positive integer(the parse failed; a numericu32, including0, is accepted)CATCH_UP_THRESHOLD_BLOCKS must be a non-negative integerCATCH_UP_BATCH_SIZE must be a positive integerorCATCH_UP_BATCH_SIZE must be greater than zeroSCAN_PARALLELISM must be a positive integerorSCAN_PARALLELISM must be greater than zero- the same pair of messages for
SYNC_POLL_INTERVAL_SECONDS,WEBHOOK_POLL_INTERVAL_SECONDS,WEBHOOK_RETRY_DELAY_SECONDS,WEBHOOK_RETRY_MAX_DELAY_SECONDS, andWEBHOOK_MAX_ATTEMPTS WEBHOOK_REPORT_CONFIRMATIONS must be a non-negative integerFINALITY_CONFIRMATIONS must be a non-negative integerREQUIRED_CONFIRMATIONS must be a non-negative integerREQUIRED_CONFIRMATIONS needs WEBHOOK_REPORT_CONFIRMATIONS=0, because received_by_confirmations[0] includes mempool receiptsREQUIRED_CONFIRMATIONS must be at most 100REQUIRED_CONFIRMATIONS must not exceed FINALITY_CONFIRMATIONSAPP_DB_PATH and WALLET_DB_PATH must point to different filesZCASH_WEBHOOK_URL must be a valid absolute http(s) URL; got ...ZCASH_WEBHOOK_URL must be an absolute http(s) URL; got ...ZCASH_WEBHOOK_URL must use http or https; got ...ZCASH_WEBHOOK_URL must include a host; got ...- the same four messages for
PUBLIC_BASE_URL, plusPUBLIC_BASE_URL must not include a query or fragment; got ... ZCASH_DEV_SIMULATED_PAYMENTS must be 'true' or 'false'ZCASH_DEV_SIMULATED_PAYMENTS is only allowed with ZCASH_NETWORK=testnetZCASH_DEV_SIMULATED_PAYMENTS cannot be combined with LIGHTWALLETD_URL
Setting the URL without the secret, or the secret without the URL, is allowed. The webhook worker stays off.
Named defaults live in src/constants.rs:
| Constant | Value |
|---|---|
APP_SCHEMA_VERSION | 2 |
WEBHOOK_REPORT_CONFIRMATIONS | 0 |
FINALITY_CONFIRMATIONS | 100 |
DEFAULT_CATCH_UP_THRESHOLD_BLOCKS | 1 |
DEFAULT_CATCH_UP_BATCH_SIZE | 100 |
DEFAULT_SYNC_POLL_INTERVAL_SECONDS | 5 |
DEFAULT_WEBHOOK_POLL_INTERVAL_SECONDS | 2 |
DEFAULT_WEBHOOK_RETRY_DELAY_SECONDS | 30 |
DEFAULT_WEBHOOK_RETRY_MAX_DELAY_SECONDS | 300 |
DEFAULT_WEBHOOK_MAX_ATTEMPTS | 8 |
WEBHOOK_REPORT_CONFIRMATIONS and FINALITY_CONFIRMATIONS in that table are the defaults. The environment variables of the same names override them for a new database. Once service_metadata exists, a later start whose values differ from the stored row fails. See the next section.
Startup rules
PaymentService::initialize and each sync iteration call ensure_wallet_identity.
On a database with no wallet identity yet:
- The UIVK is
ZCASH_UIVK, or, if that is unset, the UIVK read from the wallet databaseaccountstable. If neither exists, startup fails withfirst startup requires ZCASH_UIVK unless the wallet DB already contains a canonical account. - The birthday is
ZCASH_BIRTHDAY_HEIGHT, or the wallet database birthday. If neither exists, startup fails withfirst startup requires ZCASH_BIRTHDAY_HEIGHT unless the wallet DB already contains a canonical account. - When both the environment and the wallet database supply a UIVK, they must be equal (
provided ZCASH_UIVK does not match the existing wallet DB identity). - The UIVK is stored as
canonical_uivk.
On a later start:
- A provided
ZCASH_UIVKmust equalcanonical_uivk. ZCASH_NETWORKmust equal the stored network.- A provided
ZCASH_BIRTHDAY_HEIGHTmust equal the stored birthday. Omitting it is allowed. - A UIVK and birthday read from the wallet database must match the stored identity.
WalletView::decode then requires the canonical UIVK to decode for that network and to contain Orchard and Sapling (configured UIVK must contain Orchard and Sapling receivers). If the wallet database UIVK does not match the decoded canonical form, startup fails with wallet DB UIVK does not match the decoded canonical wallet view.
ensure_service_metadata inserts the confirmation settings on first start. A later start fails with stored service metadata confirmation settings do not match the configured constants when WEBHOOK_REPORT_CONFIRMATIONS or FINALITY_CONFIRMATIONS differs from that row.
REQUIRED_CONFIRMATIONS is deliberately not fixed at database creation. The array is derived from stored receipt heights rather than accumulated, so it can be rebuilt at any time. The setting is stored in service_metadata.required_confirmations (a nullable column added to existing databases on startup, NULL meaning off). When a start’s value differs from the stored one, every address’s array is rebuilt from its receipts in one transaction. That rebuild is silent: it does not bump state_version or queue webhooks, so changing the setting does not flood your endpoint. The next real change for an address (new funds, a new block deepening a receipt, a reorg) carries the new shape. Turning it off makes the field disappear from later events.
The scan loop builds keys from the environment variable ZCASH_UIVK on every iteration, not from the stored identity alone. Lightwalletd calls in src/scanner.rs request blocks by height, the latest block, tree state, the mempool stream, mempool transaction ids, and raw transactions by id. Those requests do not send the UIVK. Decryption uses scanning keys built locally from that UIVK.
The service keeps one shared connection to lightwalletd. It sends HTTP/2 and TCP keepalives on it, bounds connect and unary requests with timeouts, and polls the chain tip every 15 seconds while it waits on the mempool stream. A connection that drops silently is replaced within about half a minute instead of stalling block and mempool scanning until the operating system gives up on it.
Crate entry points
src/lib.rs exports these modules: config, constants, db, error, logging, receipt_ingest, scanner, service, wallet_db, wallet_runtime, webhook_delivery, zcash.
The binary and an embedded host call the items below. Signatures match the source.
pub struct AppState {
pub service: Arc<PaymentService>,
}
pub fn build_router(state: AppState) -> Router;
pub async fn run() -> Result<(), AppError>;
impl Config {
pub fn from_env() -> Result<Self, AppError>;
}
impl PaymentService {
pub fn initialize(config: Config) -> Result<Self, AppError>;
pub fn issue_payment_session(
&self,
request: PaymentSessionRequest,
) -> Result<PaymentSessionResponse, AppError>;
pub fn health_response(&self) -> Result<HealthResponse, AppError>;
}
pub fn build_qr_text(
address: &str,
amount: Option<&str>,
memo: Option<&str>,
message: Option<&str>,
) -> String;
pub fn spawn_wallet_sync_worker(config: Config);
pub fn spawn_webhook_delivery_worker(config: Config);
pub fn init_logging(config: &Config) -> Result<WorkerGuard, AppError>;
run is the path main uses. It calls Config::from_env, init_logging, PaymentService::initialize, the private webhook recovery, both spawn_* workers, build_router, and axum::serve with shutdown on Ctrl-C.
PaymentService’s fields are private. Config’s fields are public and match the environment table: listen_addr, network, startup_uivk, lightwalletd_url, birthday_height, wallet_db_path, app_db_path, log_dir, catch_up_threshold_blocks, catch_up_batch_size, sync_poll_interval_seconds, webhook_url, webhook_secret, webhook_poll_interval_seconds, webhook_retry_delay_seconds, webhook_retry_max_delay_seconds, webhook_max_attempts, webhook_report_confirmations, finality_confirmations, public_base_url, return_url_allowed_origins.
db, scanner, receipt_ingest, wallet_db, and zcash are public as well. They are the storage and scan implementation behind the HTTP routes. Their types and function signatures are the pub items in those files. This guide does not add behavior on top of them.
Deployment
The same crate ships as a library and as a container. CI on main compiles the binary, builds a linux/amd64 and linux/arm64 image, and pushes it to GHCR as :main. Pull requests build the image and do not push it.
Releases are tagged v<version>. Each one publishes ghcr.io/nerdcash/zcash-payment-service:v<version> and :<version>, and a stable release also moves :<major>.<minor> and :latest. Pin a version tag in production so an upgrade happens only when you choose it. :main follows every merge.
Rust crate
Cargo.toml names the package zcash-payment-service at version 0.1.0. The repository does not publish that version to crates.io. Depend on it from Git:
[dependencies]
zcash-payment-service = { git = "https://github.com/nerdcash/zcash-payment-service.git" }
Run the binary
src/main.rs is:
#[tokio::main]
async fn main() -> Result<(), zcash_payment_service::error::AppError> {
zcash_payment_service::run().await
}
cargo run and cargo build --release produce that binary. The release path is target/release/zcash-payment-service (with .exe on Windows). The Docker image copies the same binary to /usr/local/bin/zcash-payment-service.
run() loads Config from the environment, starts logging, opens the databases, checks the wallet identity, recovers interrupted webhook rows, spawns the scan worker and the webhook worker, and serves the router on listen_addr until Ctrl-C.
Embed the router
You can build the same router inside your own Tokio process. This uses the public functions and skips the private startup recovery that run() performs. Call init_logging yourself if you want the daily JSON log file.
use std::sync::Arc;
use zcash_payment_service::{
build_router,
config::Config,
service::PaymentService,
wallet_runtime::spawn_wallet_sync_worker,
webhook_delivery::spawn_webhook_delivery_worker,
AppState,
};
#[tokio::main]
async fn main() -> Result<(), zcash_payment_service::error::AppError> {
let config = Config::from_env()?;
let service = Arc::new(PaymentService::initialize(config.clone())?);
spawn_wallet_sync_worker(config.clone());
spawn_webhook_delivery_worker(config.clone());
let app = build_router(AppState { service });
let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
PaymentService::initialize creates the wallet database schema, stores or checks the canonical UIVK, and decodes that UIVK into the view used to mint addresses. Both workers take Config by value and spawn a Tokio task. The webhook task is skipped when ZCASH_WEBHOOK_URL or ZCASH_WEBHOOK_SECRET is unset.
Keep APP_DATA_DIR, or the two explicit database paths, on durable disk. APP_DB_PATH and WALLET_DB_PATH must be different paths. The app database holds the UIVK, issued addresses, receipt totals, and webhook state. The wallet database is the separate zcash_client_sqlite file.
Docker container
The Dockerfile is a multi-stage build.
- The builder is the pinned
rust:1.99-bookwormimage. It compilescargo build --releaseforx86_64-unknown-linux-gnuoraarch64-unknown-linux-gnu, matching the target architecture. - The runtime image is the pinned
debian:bookworm-slimimage withca-certificatesandcurl. - The image exposes port
8787, setsPORT=8787, and startszcash-payment-service.
PORT=8787 makes the process bind 0.0.0.0:8787 inside the container. EXPOSE does not publish the port. Nothing in the image sets APP_DATA_DIR, so a container without a volume writes app.db, wallet.db, and logs under /app/data/zcash-payment, and that data disappears with the container.
Compose in this repository
docker compose up --build uses docker-compose.yml:
- Image name
zcash-payment-service:${IMAGE_TAG:-local}. - Host port
127.0.0.1:8787forwarded to container port8787. APP_DATA_DIR=/data/zcash-paymentand a bind mount of./dataonto that path.ZCASH_NETWORKdefaults totestnetwhen the variable is empty.ZCASH_UIVK,ZCASH_BIRTHDAY_HEIGHT,LIGHTWALLETD_URL,ZCASH_WEBHOOK_URL, andZCASH_WEBHOOK_SECRETare passed through from the shell or from a.envfile.- Restart policy
unless-stopped. - Health check:
curl -sf http://localhost:8787/healthevery 30 seconds.
Published image
The CI workflow pushes, on a main push or a manual run of that workflow on main:
ghcr.io/nerdcash/zcash-payment-service:<git sha>ghcr.io/nerdcash/zcash-payment-service:main
The image name is ghcr.io/ plus the lowercased repository owner plus /zcash-payment-service. The cleanup workflow keeps the main tag, every release tag, and the five newest SHA tags. Production should use a release image, such as 0.1.0:
docker pull ghcr.io/nerdcash/zcash-payment-service:0.1.0
Run the published image with the same settings the Compose file uses, and point ZCASH_WEBHOOK_URL at an endpoint your application serves:
services:
zcash-payment-service:
image: ghcr.io/nerdcash/zcash-payment-service:0.1.0
environment:
PORT: "8787"
APP_DATA_DIR: /data/zcash-payment
ZCASH_NETWORK: mainnet
ZCASH_UIVK: ${ZCASH_UIVK}
ZCASH_BIRTHDAY_HEIGHT: ${ZCASH_BIRTHDAY_HEIGHT}
LIGHTWALLETD_URL: ${LIGHTWALLETD_URL}
ZCASH_WEBHOOK_URL: http://consumer-app:8080/webhooks/zcash-payments
ZCASH_WEBHOOK_SECRET: ${ZCASH_WEBHOOK_SECRET}
volumes:
- ./data/zcash-payment:/data/zcash-payment
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8787/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 5s
src/spec.md describes the calling application as the owner of ZCASH_PAYMENT_SERVICE_BASE_URL. This binary does not read that variable. From another container on the same Compose network the base URL is http://zcash-payment-service:8787.
Give the container the UIVK, the birthday height, and a lightwalletd URL, and keep the volume. The process has no request authentication, so publish the port on an address your application can reach and the public internet cannot. If you use the pay page, forward only /pay/* and /payments/* to the public through a reverse proxy, set PUBLIC_BASE_URL, and set RETURN_URL_ALLOWED_ORIGINS to your store’s origin. Leave ZCASH_DEV_SIMULATED_PAYMENTS unset; it is a testnet-only test hook.
Production compose file with HTTPS
deploy/ has a ready-made setup that does the above: the service plus Caddy, which gets and renews HTTPS certificates by itself.
- Only
/pay/*and/payments/*are served publicly onPAY_DOMAIN. Everything else, including/payment-sessionand/health, answers404, and HTTP redirects to HTTPS. - The private API is published on
PRIVATE_API_BIND(default127.0.0.1) for a store on the same server. For a store elsewhere, set it to a private-network or VPN address, never a public one. - The wallet database lives in the
service-datavolume, and Caddy’s certificates live incaddy-data. Both have healthchecks and restart automatically.
cd deploy
cp .env.example .env # set PAY_DOMAIN, ZCASH_UIVK, ZCASH_BIRTHDAY_HEIGHT, LIGHTWALLETD_URL, webhook settings
docker compose up -d
Point PAY_DOMAIN’s DNS at the server and open ports 80 and 443 first, so the certificate can be issued. To try it without a domain, set PAY_DOMAIN=localhost and TLS_MODE=internal, which uses Caddy’s own local certificate authority.
deploy/test/test.sh brings the stack up on localhost with the internal CA, on testnet with simulated payments. It checks that:
- the pay page and status route work over HTTPS;
- the private routes are not public;
- a payment shows publicly;
- data and certificates survive a restart.
CI runs it on every pull request (.github/workflows/deploy.yml).
Scan parallelism
Catch-up scanning trial-decrypts every shielded output in each compact block. The service spreads blocks across a dedicated thread pool and downloads several block ranges ahead of the scanner. Receipts are still recorded in height order.
SCAN_PARALLELISM sets the size of that pool. The default is the number of CPUs available to the process, which gives the fastest catch-up. The pool is separate from the Tokio runtime and from rayon’s global pool, so the cap holds even when you embed the router in another application.
Set a lower value when the same host also serves web pages or other latency-sensitive traffic. A long catch-up, such as a first sync from an old birthday height, can otherwise keep every core busy for minutes and slow page responses. For example, on a 4-core host that also runs a storefront, SCAN_PARALLELISM=2 leaves room for the web server. SCAN_PARALLELISM=1 scans one block at a time.
Each catch-up progress log line reports blocks_per_second, scan_seconds, download_wait_seconds, and scan_parallelism. If download_wait_seconds dominates, more threads will not help; the lightwalletd server is the bottleneck.
Mainnet spam filter
On mainnet, between heights 1,710,000 and 2,050,000, the scanner skips any transaction with more than 50 shielded outputs. That range covers the 2022 output-spam attack, and skipping those transactions greatly speeds a scan through it. The filter is built in and has no setting. A real payment in that window would need more than 50 outputs in one transaction to be missed. Testnet is never filtered.
Running on a small VPS
This guide puts the service on one small Linux server with the compose file in deploy/: the service plus Caddy, which gets and renews an HTTPS certificate for the pay page by itself. The private API stays on the server (or a private network); only the pay page and the payment status route are public.
Choosing a host
The service is light. In a test run, the service and Caddy together used under 50 MiB of memory while idle; scanning from an old birthday height takes more CPU and memory for a while, and the wallet database grows with the chain activity it scans. A plan with 1 GiB of memory, 1 vCPU, and 20 GiB or more of disk is comfortable. The image is published for both x86-64 and ARM64.
Two examples, with prices as listed on the providers’ pricing pages in November 2026. Prices change; check the current page before you buy.
| Provider | Plan | Price | Notes |
|---|---|---|---|
| DigitalOcean | Basic Droplet, 1 GiB / 1 vCPU / 25 GiB SSD / 1,000 GiB transfer | $6/month | The $4/month plan (512 MiB, 10 GiB disk) works for a quiet store but leaves little room. Automatic backups cost 20% (weekly) or 30% (daily) of the Droplet price. |
| Hetzner Cloud | Regular Performance | from €6.49/month, excluding VAT | Cost-Optimized plans start at €5.99/month but were listed as unavailable. US locations: Hillsboro, OR and Ashburn, VA. |
Any provider with a public IPv4 address and Docker support works the same way. Pick a region near your store’s server, since the store calls the private API on every checkout.
Server setup
These steps assume a fresh Ubuntu LTS server and that you log in as a user with sudo.
-
Update the system and turn on unattended security updates:
sudo apt update && sudo apt upgrade -y sudo apt install -y unattended-upgrades -
Install Docker Engine and the Compose plugin by following Docker’s instructions for Ubuntu. To run
dockerwithoutsudo, add your user to thedockergroup and log in again (membership in that group is equivalent to root on this server, so only add your own admin user), then check that you can reach the daemon:sudo usermod -aG docker "$USER" # log out and back in docker compose version docker psOtherwise, put
sudoin front of everydockercommand below. -
Use SSH keys and turn off password logins (
PasswordAuthentication noin/etc/ssh/sshd_config), thensudo systemctl reload ssh.
Firewall
Allow only SSH, HTTP, and HTTPS (UDP 443 is HTTP/3):
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw enable
Ports that Docker publishes bypass ufw, so ufw alone does not protect them. The compose file publishes the private API on 127.0.0.1 only, which is not reachable from outside. If you change PRIVATE_API_BIND, also add the provider’s cloud firewall (DigitalOcean, Hetzner) allowing only 22, 80, and 443 from the internet.
DNS
Pick a host name for the pay page, such as pay.shop.example, and create an A record (and an AAAA record if the server has IPv6) pointing at the server. Wait until it resolves:
dig +short pay.shop.example
Caddy requests the certificate when it starts, and the request fails until DNS points at the server and ports 80 and 443 are open.
Start the service
git clone https://github.com/nerdcash/zcash-payment-service.git
cd zcash-payment-service/deploy
cp .env.example .env
chmod 600 .env
Edit .env. Every setting is described in the file; the ones you must fill in are:
PAY_DOMAIN: the host name from the DNS step.ZCASH_UIVKandZCASH_BIRTHDAY_HEIGHT: the wallet’s unified incoming viewing key and the height it was created at. The viewing key can see payments but cannot spend them; the spending key never goes on this server.LIGHTWALLETD_URL: a lightwalletd server for the network.ZCASH_WEBHOOK_URLandZCASH_WEBHOOK_SECRET: your store’s webhook route (see the plugin pages) and a secret fromopenssl rand -hex 32.RETURN_URL_ALLOWED_ORIGINS: your store’s origin.IMAGE_TAG: a version tag (.env.examplestarts at the current release,0.1.0), so upgrades happen only when you choose (see Upgrades).
To get a notice from Let’s Encrypt before a certificate problem, uncomment the email line in Caddyfile.
Start it:
docker compose up -d --wait
docker compose ps
docker compose logs -f zcash-payment-service # watch the first scan; Ctrl+C to stop following
Check it from the server and from outside:
curl -s http://127.0.0.1:8787/health # private API, on the server only
curl -sI https://pay.shop.example/pay/x # public: 404 for an unknown session, over HTTPS
curl -sI https://pay.shop.example/health # public: 404, the private API is not exposed
Connect a store plugin
The store needs two things: the private API URL, to create payment sessions, and a webhook route the service can reach, to report payments. Follow the setup section of your plugin’s page (WooCommerce, Medusa, Vendure), using these values:
- Store on the same server: the service URL is
http://127.0.0.1:8787. If the store itself runs in a container,127.0.0.1is that container; put the store on the same Docker network and usehttp://zcash-payment-service:8787, or setPRIVATE_API_BINDto the Docker bridge gateway address (often172.17.0.1). - Store on another server: connect the two servers over a private network (DigitalOcean VPC, Hetzner networks) or a VPN such as WireGuard, set
PRIVATE_API_BINDto this server’s private address, and usehttp://<private address>:8787. Never bind the private API to a public address: it has no authentication. - Webhook: set
ZCASH_WEBHOOK_URLto the store’s webhook route and use the sameZCASH_WEBHOOK_SECRETin the plugin settings. Thendocker compose up -dto apply the change.
Place a test order, open the pay page link, and confirm the order moves to paid after the payment arrives. You can try the whole flow on testnet first with a testnet viewing key and ZCASH_NETWORK=testnet.
Backups
What to keep:
- The
.envfile. It holds the viewing key, the birthday height, and the webhook secret. Store a copy somewhere safe and private, such as a password manager. - The
service-datavolume. It holds the app database (the viewing key, every issued address and payment session, receipt totals, and webhook state) and the wallet database. The service only credits payments to addresses recorded in the app database, so without a backup of it a rescan cannot rebuild payment sessions or their receipt totals. The funds themselves are safe in your wallet; you would match those payments to orders by hand. - The wallet’s seed phrase lives in your wallet, not on this server. Back it up there; it is the only thing that can spend the funds.
The Caddy volumes hold only certificates, which Caddy requests again if they are lost.
To back up the data volume, stop the service for a moment so the databases are consistent, stream the volume into a new archive that only you can read, and start it again:
cd zcash-payment-service/deploy
backup="$HOME/zcash-payment-data-$(date +%F-%H%M).tar.gz"
docker compose stop zcash-payment-service
(umask 077 && set -o noclobber &&
docker run --rm -v zcash-payment_service-data:/data:ro alpine tar czf - -C /data . > "$backup")
docker compose start zcash-payment-service
The archive contains the viewing key and your payment records, so umask 077 keeps it private, and noclobber refuses to overwrite an existing file. Copy it off the server (for example with scp, or to encrypted object storage) and keep several. Payments made while the service is stopped are picked up when it starts again. You can run this nightly from cron. A provider’s automatic server backups are a good extra layer but snapshot a running database, so keep your own copies too.
Restoring
Restore into a stopped service and an empty volume, never over a running one:
cd zcash-payment-service/deploy
docker compose down # stops everything; volumes are kept
docker volume rm zcash-payment_service-data # only if it exists and you are replacing it
docker compose create
docker run --rm -i -v zcash-payment_service-data:/data alpine tar xzf - -C /data \
< ~/zcash-payment-data-YYYY-MM-DD-HHMM.tar.gz
docker compose up -d --wait
On a new server, set up the server and .env as above first.
Restoring a backup older than your last checkout reuses addresses. The service hands out addresses in sequence and records the position in the app database, so a restored older copy issues again the addresses it handed out after the backup, and a late payment to one of those could be credited to a new, unrelated order. If orders were placed after the backup was taken, do not take new orders with the restored copy: keep the payment method turned off in your store, settle the orders placed after the backup by checking your wallet, and for new orders start a separate deployment with a fresh volume and the viewing key of a new wallet account. Frequent backups, such as nightly or after each order, keep that gap small.
Upgrades
Pin IMAGE_TAG in .env to a release, such as 0.1.0 (a release tag v0.1.0 publishes images tagged 0.1.0 and v0.1.0; 0.1 follows that minor series’ newest patch). main follows every change on the main branch and is meant for testing, not production. Upgrade on purpose:
-
Read the release notes for the new version.
-
Take a backup (above).
-
Update the deployment files and the image.
--force-recreatemakes Caddy load a changedCaddyfile, which it does not do on its own:cd zcash-payment-service git pull cd deploy # set IMAGE_TAG in .env to the new version, and compare .env with .env.example for new settings docker compose pull docker compose up -d --wait --force-recreate docker compose logs --tail 50 zcash-payment-service
To roll back when the release notes say the new version did not change the database, set IMAGE_TAG back and run docker compose up -d --wait. If it did change the database, stop the service first (docker compose down) so the old image never opens the upgraded database, set IMAGE_TAG back, and follow Restoring with the backup you took before upgrading. The warning there applies: if orders were placed on the new version, those addresses will be issued again by the restored copy.
Keep the server itself updated too: unattended-upgrades handles security updates, and docker compose pull for the caddy image picks up Caddy fixes.
Comparison with other processors
Several open-source projects let a merchant accept Zcash without giving up custody. This page compares zcash-payment-service with two of them, CipherPay and BTCPay Server with its Zcash plugin, so you can pick the one that fits your store. Facts about the other projects come from their own sites and repositories, linked in the table, as of October 2026. Check those sources for anything that may have changed.
| zcash-payment-service | CipherPay | BTCPay Server + Zcash plugin | |
|---|---|---|---|
| Hosting model | Self-hosted only: one container, or a Rust crate embedded in your process | Hosted service at cipherpay.app; the API is also self-hostable | Self-hosted BTCPay Server instance (plugin) |
| Fees | None | 1% of confirmed payments, settled in ZEC (BILLING_FEE_RATE, configurable when self-hosting) | None charged by BTCPay Server |
| Custody | Non-custodial: payers pay the merchant’s own wallet | Non-custodial | Non-custodial |
| Third parties in a payment | None beyond the lightwalletd server you choose | CipherPay runs the checkout and holds the viewing key when you use the hosted service; self-hosted deployments depend on CipherScan | None beyond your chosen node or lightwalletd server |
| Key required | Unified incoming viewing key (UIVK) | Unified full viewing key (UFVK), stored encrypted | Viewing key entered in the plugin settings, via zcash-walletd |
| Chain access | lightwalletd | Not verified | lightwalletd, with an optional Zebra full node (install guide) |
| Zcash support status | Production | Production (cipherpay.app) | Plugin 1.0.2 published June 2025 (forum). The plugin shares one Zcash wallet across every store on the instance |
| Store plugins | WooCommerce, Easy Digital Downloads, PrestaShop, Medusa, and Vendure plugins, plus the HTTP API, signed webhooks, and a Node.js client | Shopify app and WooCommerce plugin, plus payment links and a POS | BTCPay Server’s own store integrations (not verified for Zcash specifically) |
| License | MIT | MIT (cipherpay-api) | MIT (BTCPay Server, plugin) |
When to choose this service
- No middleman. The payer sends ZEC straight to your wallet, and your own server notices it. No processor stands between you and your customer, and nobody else holds your viewing key.
- No fees. There is no account, usage meter, or percentage.
- Small footprint. You run one more container or link one more crate. You don’t need a full node or a BTCPay Server instance.
- Least privilege. It needs only an incoming viewing key, so it can see payments but never spend them.
When another option may fit better
- You sell on Shopify and want a ready-made app: CipherPay offers one.
- You don’t want to run any server process: CipherPay’s hosted service handles that.
- You already run BTCPay Server for other currencies and don’t share the instance: the Zcash plugin adds ZEC there.