Accept shielded ZEC in your DApp
@zecone/sdk, your customer pays on a hosted or embedded checkout, and your server unlocks the order when a signed webhook confirms settlement. Amounts are decimal strings end to end; there is no floating-point money anywhere in the path.Quickstart
Three steps take you from nothing to a working checkout.
- 1Sign in on the merchant dashboard with the Base wallet that should receive payouts and copy your API key.
- 2Install the SDK in your server project.
- 3Create a payment and send the customer to its checkout.
npm install ./path/to/zecone/sdk # @zecone/sdk (local package for the hackathon build)ZECONE_API_KEY=zk_…_sk_… # from the dashboard, shown once
ZECONE_BASE_URL=http://127.0.0.1:4321
ZECONE_WEBHOOK_SECRET=whsec_… # from the webhook settings, shown onceKeep secrets on your server
Merchant onboarding
A merchant account is created only after an EIP-4361 (Sign-In with Ethereum) signature from the payout wallet. ZecOne writes the whole message (domain, URI, chain, a single-use nonce and a 10-minute expiry), stores it, and verifies that the returned signature recovers to the claimed address. A bare address never yields a credential. Smart-contract wallets are verified through ERC-1271/6492 when the server has a Base RPC configured.
The dashboard does this for you. To onboard from your own app, relay the two public calls from your backend and keep the returned API key server-side:
import { ZecOne } from "@zecone/sdk";
const base = process.env.ZECONE_BASE_URL!;
// 1) Ask ZecOne for the exact message the wallet must sign.
const challenge = await ZecOne.onboarding.challenge(base, {
address, chainId: 8453, domain: "shop.example", uri: "https://shop.example/setup",
});
// 2) Browser: signature = await wallet.signMessage(challenge.message)
// 3) Verify. apiKey is returned once, only when the account is created.
const { merchant, apiKey } = await ZecOne.onboarding.verify(base, { message: challenge.message, signature });Payments always pay out to the verified address. Changing it requires a new signature from the new wallet, made from a signed-in dashboard session.
Create a payment
import { ZecOne } from "@zecone/sdk";
const zecone = new ZecOne({ apiKey: process.env.ZECONE_API_KEY!, baseUrl: process.env.ZECONE_BASE_URL });
export async function POST() {
const orderId = crypto.randomUUID();
const payment = await zecone.payments.create({
amountUsd: "12.50",
description: "Pixel Pass · lifetime",
metadata: { orderId }, // never shown to the customer
idempotencyKey: orderId, // safe to retry
});
// Save payment.id against your order, then give the browser ONLY these:
return Response.json({
paymentId: payment.id,
checkoutToken: payment.checkoutToken, // read-only capability
checkoutUrl: payment.checkoutUrl, // hosted page
});
}The response includes the exact quote (quote.zecAmount, quote.ethPayout), a 15-minute expiresAt and, on real (monitor or live) checkouts only, the shielded address and a ZIP-321 paymentUri for QR codes. Demo payments have simulated: true and no address. The SDK refuses to run in a browser because it holds your secret key.
Hosted checkout & payment links
Redirect the customer to payment.checkoutUrl, or create a link from the dashboard and share it. The hosted page shows the amount, a countdown and live progress (server-sent events with polling fallback), plus the address and QR code on real checkouts. It works on mobile.
For an <iframe>, use /embed/<id>?t=<checkoutToken>. It posts { source: "zecone-checkout", type, paymentId, state } to the parent window.
<iframe src="https://zecone.example/embed/pay_…?t=…" width="420" height="640" style="border:0"></iframe>
<script>
window.addEventListener("message", (e) => {
if (e.data?.source !== "zecone-checkout") return;
if (e.data.type === "completed") fetch("/api/orders/" + e.data.paymentId); // ask YOUR server
});
</script>Embedded checkout (React / Next.js)
"use client";
import { ZecOneCheckout } from "@zecone/sdk/react";
export function Pay({ paymentId, checkoutToken }: { paymentId: string; checkoutToken: string }) {
return (
<ZecOneCheckout
apiBaseUrl={process.env.NEXT_PUBLIC_ZECONE_URL!}
paymentId={paymentId}
checkoutToken={checkoutToken}
theme={{ accent: "#5b3df5", radius: 12 }} // or "light" | "dark"
onStatusChange={(c) => console.log(c.state)}
onCompleted={() => location.assign("/orders/thanks")} // then confirm on your server
onExpired={() => alert("Quote expired. Create a new order.")}
/>
);
}It handles loading, awaiting payment, processing, completed, expired, failed and manual-review states. It never receives your API key and cannot change a payment. @zecone/sdk/checkout exposes the same polling and QR helpers without React.
Webhooks
Register an HTTPS endpoint in the dashboard. Each event is signed with HMAC-SHA256 over timestamp.rawBody and sent in X-ZecOne-Signature: t=…,v1=…. Events older than five minutes are rejected. Delivery is at-least-once: network errors, timeouts, 408, 429 and 5xx are retried with exponential backoff (up to 8 attempts); other 4xx responses stop retries. De-duplicate on event.id.
import { constructEvent } from "@zecone/sdk/webhooks";
export async function POST(req: Request) {
const raw = await req.text(); // the RAW body; do not JSON.parse first
let event;
try {
event = constructEvent(raw, req.headers.get("x-zecone-signature"), process.env.ZECONE_WEBHOOK_SECRET!);
} catch {
return new Response("bad signature", { status: 401 });
}
if (await alreadyProcessed(event.id)) return new Response("ok");
if (event.type === "payment.completed") await markOrderPaid(event.payment!.id);
return new Response("ok");
}Events: payment.created, payment.awaiting_payment, payment.detected, payment.verifying, payment.zec_confirmed, payment.payout_submitted, payment.payout_confirmed, payment.completed, payment.expired, payment.failed, payment.manual_review and webhook.ping.
Unlocking orders safely
Never trust the browser
Unlock on your server after a verified payment.completed webhook, and (recommended) re-read the payment with zecone.payments.retrieve(id) to confirm state === "COMPLETED" and that the amount and metadata.orderId match your order.
Status & receipts
const p = await zecone.payments.retrieve(id); // state, quote, payoutTxHash…
const events = await zecone.payments.events(id); // full audit trail
const receipt = await zecone.payments.receipt(id); // COMPLETED only
// receipt.payout.explorerUrl → https://basescan.org/tx/0x…Lifecycle: CREATED → QUOTED → AWAITING_PAYMENT → PAYMENT_DETECTED → VERIFYING → ZEC_CONFIRMED → PAYOUT_PENDING → PAYOUT_SUBMITTED → PAYOUT_CONFIRMED → COMPLETED, plus EXPIRED, FAILED and MANUAL_REVIEW. Demo receipts carry evidenceKind: "recorded-mainnet-replay".
API reference
Authenticate with Authorization: Bearer <key>. Authenticated endpoints send no CORS headers; call them from your server.
- POST/api/v1/merchants/challengeGet an EIP-4361 message for a Base address. Auth: public
- POST/api/v1/merchants/verifySubmit the signature; returns merchant, API key (once) and session. Auth: public
- GET/api/v1/merchantYour merchant profile and key ids. Auth: key
- POST/api/v1/merchant/keysRotate the API key (optionally revoke others). Auth: session
- POST/api/v1/merchant/payout-addressChange payout address with a signature from the new wallet. Auth: session
- POST/api/v1/paymentsCreate a payment. Supports Idempotency-Key. Auth: payments:create
- GET/api/v1/paymentsYour payments, newest first (limit, offset, state). Auth: payments:read
- GET/api/v1/payments/:idOne payment. Auth: payments:read
- GET/api/v1/payments/:id/quoteIts quote and whether it expired. Auth: payments:read
- GET/api/v1/payments/:id/eventsLifecycle events; add ?stream=1 for SSE. Auth: payments:read
- GET/api/v1/payments/:id/receiptReceipt for a COMPLETED payment. Auth: payments:read
- POST/api/v1/payments/:id/cancelCancel an unpaid payment. Auth: payments:cancel
- POST/api/v1/webhooksSet your endpoint; returns the signing secret once. Auth: webhooks:manage
- POST/api/v1/webhooks/testSend a signed webhook.ping now. Auth: webhooks:manage
- GET/api/v1/checkout/:id?token=Customer-safe status view (CORS-enabled). Auth: checkout token
- GET/api/v1/quote/preview?amountUsd=Indicative quote (demo rate). Auth: public
- GET/api/v1/healthMode and safety flags. Auth: public
Errors & idempotency
Errors are JSON: { "error": { "code", "message", "details"? } }; the SDK throws ZecOneError with code and status. Common codes: unauthorized, insufficient_scope, invalid_amount, recipient_not_verified, idempotency_conflict, checkout_busy (with Retry-After), insufficient_liquidity, not_completed, rate_limited.
Send an Idempotency-Key (the SDK generates one per call and reuses it on retries). Repeating a key with the same body returns the original payment; a different body returns 409 idempotency_conflict.
Demo, monitor & live
Demo (default): checkouts are simulated. No payable address or payment URI is ever shown; after a few seconds a server-side observer replays the recorded mainnet pilot transactions. Every event, webhook and receipt says evidence: "recorded-mainnet-replay" / simulated: true.
Monitor (operator-authorized): checkouts show the real receiving address. The payment API reads the authenticated mainnet watcher (UFVK-only wallet, two independent providers, authenticated block corpus) and moves payments through payment.detected to payment.zec_confirmed with chain evidence (txid, height, confirmations, tip, ownership digest). It never triggers a payout: settlement: "disabled".
Live settlement is disabled in this build
Attribution & current limits
In monitor mode ZecOne attributes a shielded note to an order only if the watcher decrypted it for the receiving address, the order held the exclusive allocation of that address, and the note was mined after the allocation, never by amount or timing alone. All real checkouts share one receiving address, so only one real checkout can be awaiting payment at a time; others get checkout_busy. Per-order diversified addresses would remove this limit and are not enabled yet.
A wrong amount goes to MANUAL_REVIEW; a source transaction can fund only one order; payments are quoted at a fixed demo rate in this build; and a quote is only issued if the relayer's configured balance covers it and all open payouts.
No browser action can complete a payment in any mode.
Example DApp: Access Pass
examples/access-pass is a separate merchant app: it onboards with a Base wallet signature, keeps its API key and webhook secret on its server, creates payments through the SDK, embeds the checkout, and unlocks content only after a verified payment.completed webhook plus a server-side status check.
Run locally
npm ci
npm run sdk:build
npm run payments:start # API on http://127.0.0.1:4321 (demo mode)
npm run dev # this site on http://localhost:3000
npm run dapp:start # Access Pass example on http://127.0.0.1:4333