Skip to content

Accept shielded ZEC in your DApp

Your server creates a payment with @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.

  1. 1Sign in on the merchant dashboard with the Base wallet that should receive payouts and copy your API key.
  2. 2Install the SDK in your server project.
  3. 3Create a payment and send the customer to its checkout.
InstallShell
npm install ./path/to/zecone/sdk   # @zecone/sdk (local package for the hackathon build)
.env (server only)Shell
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 once

Keep secrets on your server

Your API key and webhook secret must never appear in browser code, a public repository or a client bundle. The browser only ever needs the payment id and its read-only checkout token.

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:

onboard.ts (server)TypeScript
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

app/api/orders/route.ts (Next.js)TypeScript
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 embedHTML
<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)

components/pay.tsxTSX
"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.

app/api/zecone/webhook/route.tsTypeScript
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

Checkout callbacks and iframe messages are UI hints. Never unlock goods because the browser says the payment finished.

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

Read status and receipts (server)TypeScript
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

Base payouts stay behind the operator's separate SIGN and BROADCAST approvals, liquidity checks and the paused submitter.

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

Start the three local servicesShell
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