Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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-service from this Git repository and call run, or mount the router yourself.
  • Docker container. Build the image in this repository, or pull ghcr.io/nerdcash/zcash-payment-service after CI publishes it from main.

What a payment looks like

  1. Your application asks POST /payment-session for an address when a checkout starts.
  2. You show the returned address and a QR code of qr_text to the payer, or send them to the service’s own pay page.
  3. 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.
  4. When ZCASH_WEBHOOK_URL and ZCASH_WEBHOOK_SECRET are 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_CONFIRMATIONS is 0.
  • 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:

VariableRole
ZCASH_UIVKUnified incoming viewing key for the network you select. It must include Orchard and Sapling receivers.
ZCASH_BIRTHDAY_HEIGHTBlock height to treat as the wallet birthday. A u32.
LIGHTWALLETD_URLLightwalletd 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-session when a payer is ready to pay.
  • Shows the address and a QR code.
  • Receives the webhook, checks X-Signature, and applies it when its state_version is newer than the last version stored for that address.
  • Decides when total_received covers 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, then memo, then message, are appended as a query string, in that order. memo is 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:

VariableRole
ZCASH_WEBHOOK_URLAbsolute http or https URL of your endpoint, including the host.
ZCASH_WEBHOOK_SECRETShared 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"}
FieldMeaning
event_idzcash:<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_sourceAlways zcash.
addressThe unified address from POST /payment-session.
total_receivedEligible total in ZEC. Zatoshis divided by 100_000_000, always with 8 fractional digits (1.00000000, 0.00000001).
observed_atRFC 3339 UTC timestamp taken when the delivery row was queued.
received_by_confirmationsPresent 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 + 1 when the tip is at or above the mined height, and 0 otherwise. The note is eligible when that depth is at least the configured threshold. At the default of 0, a mined note is eligible as soon as it is recorded.
  • At threshold 0, a mempool note is eligible while its first_observed_at is 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_CONFIRMATIONS defaults to 100. It is the depth used for checkpointing. It does not gate total_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/json
  • X-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.

ResponseWhat the worker does
HTTP 2xxMarks the delivery succeeded and records the notified total.
HTTP 429 or 5xx, or a transport errorSchedules retry_wait, unless this attempt reaches WEBHOOK_MAX_ATTEMPTS.
Any other HTTP statusMarks 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.

  1. Read the raw body.
  2. Recompute the HMAC and compare it to X-Signature.
  3. Parse the JSON.
  4. Read state_version from event_id (zcash:<address_id>:v<state_version>).
  5. If this event_id is already applied, or its state_version is less than or equal to the last version stored for address, return 2xx and leave the invoice unchanged.
  6. Otherwise store total_received (and received_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.
  7. Return a 2xx status. Any other success-range status counts. 4xx other than 429 will 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:

  1. It checks X-Signature against the raw body in constant time.
  2. It validates the payload.
  3. It returns the observation: event_id, payment_source, address, total_received, and observed_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.

  1. Your application calls POST /payment-session as usual, optionally with a return_url.
  2. The response includes payment_id and pay_url. Redirect the payer to pay_url, or send it to them as a payment link.
  3. The page shows the amount, a QR code of the ZIP-321 zcash: URI, an Open in wallet button, and the address. It polls GET /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.
  4. When the received total covers the amount, the page says so and, if you gave a return_url, sends the payer back there. Without a return_url the page keeps polling, so it also shows a receipt that is later dropped (for example an unconfirmed transaction that never mines).
  5. 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.

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.

FieldMeaning
payment_idThe id from the session.
address, qr_text, pay_urlAs returned when the session was created.
label, message, amountThe values the session was created with, or null.
total_receivedThe address’s eligible total in ZEC, the same number the webhook carries.
received_by_confirmationsOnly 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_versionIncrements each time total_received or received_by_confirmations changes.
statusawaiting_payment, partially_paid, paid, or received.
return_urlThe session’s return_url, or null.
created_atRFC 3339 UTC time the link was created.

status compares total_received with amount:

StatusWhen
awaiting_paymentNothing eligible has arrived.
partially_paidSomething arrived, but less than amount.
paidtotal_received is at least amount.
receivedSomething 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_paid tells 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

  1. The customer picks Zcash at checkout and places the order.
  2. The plugin calls POST /payment-session with the order total, the order number as the label, and the order-received page as return_url. It stores the issued address and payment ID on the order and leaves the order Pending payment.
  3. 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.
  4. The service sends a signed webhook each time the address’s total changes. The plugin checks the X-Signature HMAC, ignores stale events, and calls WooCommerce’s payment_complete() once the total covers the order. A partial payment adds an order note instead.
  5. 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 last received_by_confirmations element 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 set REQUIRED_CONFIRMATIONS, the array is absent and the plugin uses total_received as 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_review meta.
  • 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).

  1. Run the service as in Deployment, with ZCASH_WEBHOOK_URL set to your store’s webhook route and a random ZCASH_WEBHOOK_SECRET:

    ZCASH_WEBHOOK_URL=https://shop.example.com/?rest_route=/zcash-payment/v1/webhook
    

    Also choose WEBHOOK_REPORT_CONFIRMATIONS before the service’s first start; it is fixed once the database exists. The default 0 reports 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, set 1 or 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/webhook also works when pretty permalinks are on.

  2. Expose the pay page publicly as described in Exposing the page and set PUBLIC_BASE_URL. Set RETURN_URL_ALLOWED_ORIGINS to your store’s origin (for example https://shop.example) so the pay page only ever returns payers to your store.

  3. Copy plugins/woocommerce/zcash-payment-gateway into wp-content/plugins/ (or zip the folder and upload it under Plugins → Add New) and activate it.

  4. 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.

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 401 and 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

  1. The customer picks Zcash at checkout and places the order.
  2. The plugin records the order as Pending and calls POST /payment-session with the order total, the order number as the label, and EDD’s receipt page as return_url. It stores the issued address and payment ID on the order.
  3. 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.
  4. The service sends a signed webhook each time the address’s total changes. The plugin checks the X-Signature HMAC, 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.
  5. 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 last received_by_confirmations element 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 set REQUIRED_CONFIRMATIONS, the array is absent and the plugin uses total_received as 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_review meta.
  • 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).

  1. Run the service as in Deployment, with ZCASH_WEBHOOK_URL set to your store’s webhook route and a random ZCASH_WEBHOOK_SECRET:

    ZCASH_WEBHOOK_URL=https://shop.example.com/?rest_route=/zcash-payment-edd/v1/webhook
    

    The route differs from the WooCommerce plugin’s, so both plugins can run on one site. Choose WEBHOOK_REPORT_CONFIRMATIONS before the service’s first start, as explained in the WooCommerce setup; the default 0 completes orders from unconfirmed transactions, which suits instant digital delivery but means a dropped transaction leaves files already delivered.

  2. Expose the pay page publicly as described in Exposing the page and set PUBLIC_BASE_URL. Set RETURN_URL_ALLOWED_ORIGINS to your store’s origin.

  3. Copy plugins/easy-digital-downloads/zcash-payment-edd into wp-content/plugins/ (or zip the folder and upload it) and activate it.

  4. 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.

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 401 and 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

  1. The customer picks Pay with Zcash (ZEC) at checkout and places the order.
  2. The module creates the order in its own Awaiting Zcash payment state and calls POST /payment-session with the order total, the order reference as the label, and PrestaShop’s order confirmation page as return_url. It stores the issued address and payment ID in its own zcash_payment_order table.
  3. 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.
  4. The service sends a signed webhook each time the address’s total changes. The module checks the X-Signature HMAC, 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.
  5. 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 last received_by_confirmations element 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 set REQUIRED_CONFIRMATIONS, the array is absent and the module uses total_received as 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_review for the order in zcash_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

  1. Run the service as in Deployment, with ZCASH_WEBHOOK_URL set to the module’s webhook and a random ZCASH_WEBHOOK_SECRET:

    ZCASH_WEBHOOK_URL=https://shop.example.com/index.php?fc=module&module=zcashpayment&controller=webhook
    

    Use your shop’s own domain: PrestaShop redirects requests for any other host name, and a redirected webhook arrives as a GET that the module rejects. Choose WEBHOOK_REPORT_CONFIRMATIONS before the service’s first start, as explained in the WooCommerce setup; the default 0 accepts orders from unconfirmed transactions.

  2. Expose the pay page publicly as described in Exposing the page and set PUBLIC_BASE_URL. Set RETURN_URL_ALLOWED_ORIGINS to your shop’s origin.

  3. Zip the plugins/prestashop/zcashpayment folder (the zip must contain the zcashpayment folder) and upload it under Modules → Module Manager → Upload a module, or copy the folder into modules/ and install it there.

  4. 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.

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 401 and 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

  1. The customer picks Zcash at checkout. Medusa creates a payment session, and the provider calls POST /payment-session with the cart total and a return_url. It stores the issued address and the pay page link (pay_url) in the session data.
  2. 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.
  3. The service sends a signed webhook to /hooks/zcash each time the address’s total changes. The plugin checks the X-Signature HMAC, finds the session by its address, ignores stale events, and confirms the total with GET /payments/{id}. Once the total covers the order, it runs Medusa’s processPaymentWorkflow, which authorizes and captures the payment. A partial payment is recorded but captures nothing.
  4. 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 as total_received (mempool included) covers it, so checkout can finish, but the plugin captures only once the last received_by_confirmations element 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 set REQUIRED_CONFIRMATIONS, the array is absent and the plugin uses total_received as before.
  • Funds drop after capture (an unconfirmed transaction expires, or a reorg): the plugin sets the order’s zcash_payment_hold metadata, shows it in the admin widget, and emits zcash_payment.hold_changed so 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_hold already 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_review metadata, 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+.

  1. Run the service as in Deployment, with ZCASH_WEBHOOK_URL pointing at your Medusa server’s /hooks/zcash route and a random ZCASH_WEBHOOK_SECRET. Set PUBLIC_BASE_URL and RETURN_URL_ALLOWED_ORIGINS (your storefront’s origin) as described in Exposing the page. See the WooCommerce page for choosing WEBHOOK_REPORT_CONFIRMATIONS; the same trade-off applies.

  2. 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
    
  3. 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}",
              },
            },
          ],
        },
      },
    ],
    
    OptionMeaning
    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 pass return_url in the session data instead.
    payBaseUrlOnly if the service does not set PUBLIC_BASE_URL.
  4. Run npx medusa db:migrate, then enable the provider pp_zcash_zcash on 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 foreign return_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

  1. The storefront calls addPaymentToOrder with method zcash. The handler calls POST /payment-session with the order total and a return_url, and stores the issued address as the payment’s transactionId. The payment is Authorized, so Vendure places the order (PaymentAuthorized) with payment still outstanding.
  2. 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’s public part, which also holds the address, the ZEC amount, and the QR text.
  3. The service sends a signed webhook to /zcash-payments/webhook each time the address’s total changes. The plugin checks the X-Signature HMAC, finds the payment by its address, ignores stale events, and confirms the total with GET /payments/{id}. Once the total covers the payment, it settles it, and the order moves to PaymentSettled. A partial payment is recorded in the order history but settles nothing.
  4. 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 last received_by_confirmations element 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 set REQUIRED_CONFIRMATIONS, the array is absent and the plugin uses total_received as 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 ZcashPaymentReviewEvent so 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+.

  1. Run the service as in Deployment, with ZCASH_WEBHOOK_URL pointing at your Vendure server’s /zcash-payments/webhook route and a random ZCASH_WEBHOOK_SECRET. Set PUBLIC_BASE_URL and RETURN_URL_ALLOWED_ORIGINS (your storefront’s origin) as described in Exposing the page. See the WooCommerce page for choosing WEBHOOK_REPORT_CONFIRMATIONS; the same trade-off applies.

  2. 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
    
  3. 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}",
      }),
    ],
    
    OptionMeaning
    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 pass return_url in the addPaymentToOrder metadata instead.
    payBaseUrlOnly if the service does not set PUBLIC_BASE_URL.
  4. 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 foreign return_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:

MethodPathHandler
GET/healthservice::health
POST/payment-sessionservice::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>"}
VariantStatusDisplay prefix
InvalidConfig400invalid configuration:
InvalidRequest400invalid request:
NotFound404not found:
StartupIntegrity400startup integrity check failed:
Wallet500wallet error:
Database500database error:
Io500io error:
Json500json error:
Http500http error:
TimeFormat500time formatting error:
TimeParse500time 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:

FieldJSON typeSource
okbooleanAlways true when the handler succeeds.
current_sync_statestringservice_metadata.current_sync_state.
detected_tip_heightnumber or nulllast_seen_tip_height when it is greater than 0, otherwise null.
last_scanned_heightnumber or nulllast_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:

StateWhen
catching_upA scan toward the sampled tip is in progress.
syncedThe last scanned height is at or past the sampled tip.
blocked_scanning_key_materialZCASH_UIVK is unset, so scanning keys cannot be built.
blocked_lightwalletd_endpointThe 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:

FieldRust typeRequiredRole
payment_sourceStringyesMust equal zcash.
labelOption<String>noStored on the issued address.
memoOption<String>noStored, and copied into qr_text (base64url-encoded, as ZIP-321 requires) when non-blank.
messageOption<String>noStored, and copied into qr_text when non-blank.
amountOption<String>noStored as text, and copied into qr_text when non-blank. Only the pay page status parses it (see Payment links).
return_urlOption<String>noAbsolute 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.

FailureStatusBody
Content-Type is not JSON415axum’s plain-text rejection
JSON syntax is invalid400plain text starting with Failed to parse the request body as JSON
JSON does not match the struct422plain text starting with Failed to deserialize the JSON body into the target type
payment_source is not zcash400{"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 origin400{"error":"invalid request: return_url must ..."}
Address derivation or the database fails500{"error":"wallet error: ..."} or {"error":"database error: ..."}

Success is 200 and PaymentSessionResponse:

FieldMeaning
addressNewly 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_textzcash:<address> plus an optional query.
payment_id32 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_json stored 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:

FieldValue
event_idzcash:<address_id>:v<state_version>
payment_sourcezcash
addressissued_addresses.unified_address
total_receivedEligible zatoshis rendered as ZEC
observed_atRFC 3339 UTC time when the row was queued
received_by_confirmationsOnly 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 + 1 when tip_height >= mined_height, else 0. Eligible when the depth is >= webhook_report_confirmations.
  • Mempool receipt: eligible when webhook_report_confirmations == 0 and first_observed_at is 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.

VariableDefaultAccepted values
LISTEN_ADDR0.0.0.0:8787 when PORT is also unsetUsed as the bind address when set. Wins over PORT.
PORT8787Used only when LISTEN_ADDR is unset. Bind address becomes 0.0.0.0:<port>.
ZCASH_NETWORKtestnettestnet or mainnet.
ZCASH_UIVKunsetUIVK text. See startup rules.
ZCASH_BIRTHDAY_HEIGHTunsetMust parse as u32.
LIGHTWALLETD_URLunsetPassed to the lightwalletd client. Required for scanning.
APP_DATA_DIRdata/zcash-paymentBase directory for the default paths below.
APP_DB_PATH<APP_DATA_DIR>/app.dbMust differ from WALLET_DB_PATH.
WALLET_DB_PATH<APP_DATA_DIR>/wallet.dbzcash_client_sqlite database.
LOG_DIR<APP_DATA_DIR>/logsDaily JSON file zcash-payment.log.
CATCH_UP_THRESHOLD_BLOCKS1Parses as u32, including 0.
CATCH_UP_BATCH_SIZE100u32, must be greater than 0.
SCAN_PARALLELISMnumber of CPUs the process may useusize, must be greater than 0. Caps the threads used to trial-decrypt compact blocks. 1 scans serially. See Deployment.
SYNC_POLL_INTERVAL_SECONDS5u64, must be greater than 0. Fallback delay when the mempool stream is unavailable.
ZCASH_WEBHOOK_URLunsetAbsolute http or https URL with a host.
ZCASH_WEBHOOK_SECRETunsetHMAC key. Delivery runs only when this and the URL are both set.
PUBLIC_BASE_URLunsetAbsolute 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_PAYMENTSfalsetrue/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_ORIGINSunsetComma-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_SECONDS2u64, greater than 0.
WEBHOOK_RETRY_DELAY_SECONDS30u64, greater than 0. Base of the exponential backoff.
WEBHOOK_RETRY_MAX_DELAY_SECONDS300u64, greater than 0. Cap on the backoff.
WEBHOOK_MAX_ATTEMPTS8u32, greater than 0.
WEBHOOK_REPORT_CONFIRMATIONS0u32. 0 includes provisional mempool receipts.
FINALITY_CONFIRMATIONS100u32. Stored and used for checkpointing. Does not gate total_received.
REQUIRED_CONFIRMATIONSunsetu32 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_LOGinfotracing-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 numeric u32, including 0, is accepted)
  • CATCH_UP_THRESHOLD_BLOCKS must be a non-negative integer
  • CATCH_UP_BATCH_SIZE must be a positive integer or CATCH_UP_BATCH_SIZE must be greater than zero
  • SCAN_PARALLELISM must be a positive integer or SCAN_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, and WEBHOOK_MAX_ATTEMPTS
  • WEBHOOK_REPORT_CONFIRMATIONS must be a non-negative integer
  • FINALITY_CONFIRMATIONS must be a non-negative integer
  • REQUIRED_CONFIRMATIONS must be a non-negative integer
  • REQUIRED_CONFIRMATIONS needs WEBHOOK_REPORT_CONFIRMATIONS=0, because received_by_confirmations[0] includes mempool receipts
  • REQUIRED_CONFIRMATIONS must be at most 100
  • REQUIRED_CONFIRMATIONS must not exceed FINALITY_CONFIRMATIONS
  • APP_DB_PATH and WALLET_DB_PATH must point to different files
  • ZCASH_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, plus PUBLIC_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=testnet
  • ZCASH_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:

ConstantValue
APP_SCHEMA_VERSION2
WEBHOOK_REPORT_CONFIRMATIONS0
FINALITY_CONFIRMATIONS100
DEFAULT_CATCH_UP_THRESHOLD_BLOCKS1
DEFAULT_CATCH_UP_BATCH_SIZE100
DEFAULT_SYNC_POLL_INTERVAL_SECONDS5
DEFAULT_WEBHOOK_POLL_INTERVAL_SECONDS2
DEFAULT_WEBHOOK_RETRY_DELAY_SECONDS30
DEFAULT_WEBHOOK_RETRY_MAX_DELAY_SECONDS300
DEFAULT_WEBHOOK_MAX_ATTEMPTS8

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 database accounts table. If neither exists, startup fails with first 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 with first 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_UIVK must equal canonical_uivk.
  • ZCASH_NETWORK must equal the stored network.
  • A provided ZCASH_BIRTHDAY_HEIGHT must 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-bookworm image. It compiles cargo build --release for x86_64-unknown-linux-gnu or aarch64-unknown-linux-gnu, matching the target architecture.
  • The runtime image is the pinned debian:bookworm-slim image with ca-certificates and curl.
  • The image exposes port 8787, sets PORT=8787, and starts zcash-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:8787 forwarded to container port 8787.
  • APP_DATA_DIR=/data/zcash-payment and a bind mount of ./data onto that path.
  • ZCASH_NETWORK defaults to testnet when the variable is empty.
  • ZCASH_UIVK, ZCASH_BIRTHDAY_HEIGHT, LIGHTWALLETD_URL, ZCASH_WEBHOOK_URL, and ZCASH_WEBHOOK_SECRET are passed through from the shell or from a .env file.
  • Restart policy unless-stopped.
  • Health check: curl -sf http://localhost:8787/health every 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 on PAY_DOMAIN. Everything else, including /payment-session and /health, answers 404, and HTTP redirects to HTTPS.
  • The private API is published on PRIVATE_API_BIND (default 127.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-data volume, and Caddy’s certificates live in caddy-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.

ProviderPlanPriceNotes
DigitalOceanBasic Droplet, 1 GiB / 1 vCPU / 25 GiB SSD / 1,000 GiB transfer$6/monthThe $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 CloudRegular Performancefrom €6.49/month, excluding VATCost-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.

  1. Update the system and turn on unattended security updates:

    sudo apt update && sudo apt upgrade -y
    sudo apt install -y unattended-upgrades
    
  2. Install Docker Engine and the Compose plugin by following Docker’s instructions for Ubuntu. To run docker without sudo, add your user to the docker group 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 ps
    

    Otherwise, put sudo in front of every docker command below.

  3. Use SSH keys and turn off password logins (PasswordAuthentication no in /etc/ssh/sshd_config), then sudo 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_UIVK and ZCASH_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_URL and ZCASH_WEBHOOK_SECRET: your store’s webhook route (see the plugin pages) and a secret from openssl rand -hex 32.
  • RETURN_URL_ALLOWED_ORIGINS: your store’s origin.
  • IMAGE_TAG: a version tag (.env.example starts 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.1 is that container; put the store on the same Docker network and use http://zcash-payment-service:8787, or set PRIVATE_API_BIND to the Docker bridge gateway address (often 172.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_BIND to this server’s private address, and use http://<private address>:8787. Never bind the private API to a public address: it has no authentication.
  • Webhook: set ZCASH_WEBHOOK_URL to the store’s webhook route and use the same ZCASH_WEBHOOK_SECRET in the plugin settings. Then docker compose up -d to 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 .env file. 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-data volume. 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:

  1. Read the release notes for the new version.

  2. Take a backup (above).

  3. Update the deployment files and the image. --force-recreate makes Caddy load a changed Caddyfile, 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-serviceCipherPayBTCPay Server + Zcash plugin
Hosting modelSelf-hosted only: one container, or a Rust crate embedded in your processHosted service at cipherpay.app; the API is also self-hostableSelf-hosted BTCPay Server instance (plugin)
FeesNone1% of confirmed payments, settled in ZEC (BILLING_FEE_RATE, configurable when self-hosting)None charged by BTCPay Server
CustodyNon-custodial: payers pay the merchant’s own walletNon-custodialNon-custodial
Third parties in a paymentNone beyond the lightwalletd server you chooseCipherPay runs the checkout and holds the viewing key when you use the hosted service; self-hosted deployments depend on CipherScanNone beyond your chosen node or lightwalletd server
Key requiredUnified incoming viewing key (UIVK)Unified full viewing key (UFVK), stored encryptedViewing key entered in the plugin settings, via zcash-walletd
Chain accesslightwalletdNot verifiedlightwalletd, with an optional Zebra full node (install guide)
Zcash support statusProductionProduction (cipherpay.app)Plugin 1.0.2 published June 2025 (forum). The plugin shares one Zcash wallet across every store on the instance
Store pluginsWooCommerce, Easy Digital Downloads, PrestaShop, Medusa, and Vendure plugins, plus the HTTP API, signed webhooks, and a Node.js clientShopify app and WooCommerce plugin, plus payment links and a POSBTCPay Server’s own store integrations (not verified for Zcash specifically)
LicenseMITMIT (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.