Loading...
Loading...
Docs / Migration guide / BTCPay Server
Use this page to retire the merchant-operated payment stack: replace the invoice contract with Payvra payments, swap the webhook signature scheme, hand reconciliation to the dashboard, and only then shut down the self-hosted instance.
The safe rollout: keep the BTCPay instance live for in-flight invoices, point new payment creation at Payvra in sandbox, prove the signed webhook path, then promote to live and decommission the self-hosted stack on a calm day.
What to prove before cutover
No more on-call rotation for payments
Multi-chain stablecoin coverage
Cutover reversibility
Cutover facts
Auth change
token ... authorization with a Payvra secret key (sk_test_ in sandbox, live secret-key prefix in production).Signing change
BTCPay-Sig header in sha256=hex form. Payvra uses HMAC-SHA256 over a timestamp-bound payload encoded in base64.Settlement change
Decommission rule
BTCPay Server and Payvra both accept crypto payments, but the operating model is fundamentally different. BTCPay is a self-hosted stack the merchant runs, monitors, and patches. Payvra is a managed multi-chain platform with the same security properties without the operational burden.
Treat this migration as an infrastructure decommission, not just an endpoint rename. You are replacing the invoice lifecycle, the webhook signature format, the payout mechanics, and the on-call rotation that supports them.
Eliminate the on-call rotation
First-party stablecoin auto-conversion
Multi-chain coverage
Compliance and reconciliation lift
| BTCPay Server | Maps to | Payvra | Notes |
|---|---|---|---|
POST /api/v1/stores/{storeId}/invoices | POST /v1/payments | BTCPay invoices become Payvra payments. `amount` and `currency` map directly; `metadata` carries through for reconciliation. | |
GET /api/v1/stores/{storeId}/invoices/{invoiceId} | GET /v1/payments/{id} | Same read path with richer settlement and conversion detail. | |
GET /api/v1/stores/{storeId}/invoices | GET /v1/payments | Payvra adds cursor pagination, status, and chain filters. | |
POST /api/v1/stores/{storeId}/invoices/{invoiceId}/refund | POST /v1/refunds | Refunds become a top-level resource on Payvra rather than a sub-resource on the invoice. | |
POST /api/v1/stores/{storeId}/payment-requests | POST /v1/payment-links | BTCPay payment requests map to reusable Payvra payment links with the same shareable behavior. | |
POST /api/v1/stores/{storeId}/payouts | POST /v1/withdrawals | Self-hosted payouts depend on Lightning channel state; Payvra withdrawals execute on-demand. |
Create a payment
BTCPay Server
// BTCPay Serverconst response = await fetch( "https://btcpay.example.com/api/v1/stores/STORE_ID/invoices", { method: "POST", headers: { Authorization: `token ${BTCPAY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ amount: "49.99", currency: "USD", metadata: { orderId: "order_123" }, checkout: { redirectURL: "https://yoursite.com/success", }, }), },);const invoice = await response.json();// Redirect to: invoice.checkoutLinkPayvra
// Payvraimport Payvra from "payvra";const payvraSecretKey = process.env.PAYVRA_SECRET_KEY;if (!payvraSecretKey) { throw new Error("Set PAYVRA_SECRET_KEY before running this example.");}const payvra = new Payvra(payvraSecretKey);const payment = await payvra.payments.create({ amount: "49.99", currency: "USDC", metadata: { orderId: "order_123" },});// Redirect to: payment.checkoutUrlVerify webhooks
sha256=hex. Payvra signs a timestamp-bound payload and emits a base64 signature with replay-protection headers.BTCPay Server webhook
// BTCPay Server webhook handlerapp.post("/webhooks/btcpay", (req, res) => { const signature = req.headers["btcpay-sig"]; const expected = "sha256=" + crypto .createHmac("sha256", WEBHOOK_SECRET) .update(req.rawBody) .digest("hex"); if (signature !== expected) { return res.status(401).send("Invalid"); } const { type, invoiceId, metadata } = req.body; if (type === "InvoiceSettled") { // Mark order paid, metadata.orderId } res.sendStatus(200);});Payvra webhook
// Payvra webhook handlerapp.post("/webhooks", (req, res) => { const signature = req.headers["webhook-signature"]; const timestamp = req.headers["webhook-timestamp"]; const webhookId = req.headers["webhook-id"]; const signedContent = `${webhookId}.${timestamp}.${req.rawBody}`; const expected = crypto .createHmac("sha256", WEBHOOK_SECRET) .update(signedContent) .digest("base64"); if (signature !== `v1,${expected}`) { return res.status(401).send("Invalid"); } const { type, data } = req.body; if (type === "payment.completed") { // Mark order paid, data.metadata.orderId } res.sendStatus(200);});payment. events so your handler can branch on the event name directly rather than parsing invoice state.| BTCPay event | Maps to | Payvra | Notes |
|---|---|---|---|
Webhook InvoiceCreated | Webhook payment.created | Invoice created and awaiting deposit. | |
Webhook InvoiceReceivedPayment | Webhook payment.confirming | Deposit detected, waiting for confirmations. | |
Webhook InvoiceProcessing | Webhook payment.confirming | Same intermediate confirming state. | |
Webhook InvoiceSettled | Webhook payment.completed | Final settled state, order should advance to paid here. | |
Webhook InvoiceExpired | Webhook payment.failed | Expiry maps to a terminal failed state on Payvra. | |
Webhook InvoiceInvalid | Webhook payment.failed | Invalid invoices map to the same failed state. |
Create sandbox credentials and keep BTCPay live
sk_test_... and leave the existing BTCPay instance accepting in-flight invoices. The first milestone is proving the new Payvra path, not forcing an all-at-once cutover.Install the Payvra SDK and replace invoice creation
metadata.orderId field maps cleanly to Payvra metadata.orderId.npm install payvraReplace the webhook handler before trusting paid-state updates
Run one full sandbox order to completion
Promote to live and schedule the BTCPay decommission
Do I have to shut down BTCPay immediately?
What about Lightning Network payments?
How does signing differ?
What about self-custody?
Start in sandbox, prove the signed webhook path, and only then shift live traffic. The migration stays reversible if you treat it as a controlled cutover instead of a same-day rewrite.