Getting started
Introduction
Umbra is a Monero payment gateway. You give it your wallet address and your private view key, never your spend key, and it mints a fresh subaddress for every invoice, watches the chain for payments to it, and calls your server when one arrives.
Because a view key can only read, funds land in your wallet and stay there. Umbra cannot spend them, freeze them, or hand them to anyone else.
- REST over HTTPS, JSON in and out. No SDK required.
- Prices in XMR, or in 12 fiat currencies with the rate locked at creation.
- Two webhook events: one at 0-conf, one at the confirmation threshold.
- A hosted checkout page with a QR code, or build your own from the API.
How it works
Share your view key
Sign up with your address and private view key. Umbra creates a watch-only wallet from them.
Create an invoice
Each invoice gets its own subaddress and a hosted checkout page, so payments match themselves.
Receive signed webhooks
payment_detected when the payment appears, payment_confirmed when it is final. Fulfil on the second one.
Quickstart
Four steps from nothing to a settled payment. Everything below works with curl.
1. Create your account
The API key and webhook secret come back once and are never shown again. Umbra stores only a hash of the key, so a lost key cannot be recovered.
curl -X POST https://www.umbra.international/api/merchants \
-H "Content-Type: application/json" \
-d '{
"name": "My Shop",
"address": "4...", # your Monero address
"view_key": "...", # private VIEW key, 64 hex chars
"webhook_url": "https://myshop.com/umbra/webhook"
}'
# Response. api_key and webhook_secret are shown once, store them now.
{
"merchant_id": "1cf6b8b8-...",
"api_key": "xmr_sk_...",
"webhook_secret": "..."
}2. Create an invoice
Price in fiat with price_amount and price_currency, or in Monero with amount_xmr. Fiat prices are converted at creation and the rate is locked for the life of the invoice.
curl -X POST https://www.umbra.international/api/invoices \
-H "Authorization: Bearer $UMBRA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"price_amount": "25.00",
"price_currency": "USD",
"description": "Order #1042",
"return_url": "https://myshop.com/orders/1042",
"metadata": { "order_id": 1042 }
}'
{
"invoice_id": "0c0766e8-...",
"subaddress": "8...",
"amount_xmr": "0.044186005408",
"price_amount": "25.00",
"price_currency": "USD",
"rate": 565.8,
"status": "pending",
"expires_at": "2026-09-22T11:54:03.184Z",
"checkout_url": "https://www.umbra.international/checkout/0c0766e8-..."
}3. Send the buyer to the checkout
Redirect them to checkout_url. The page shows the amount, a QR code and a monero: link, and updates itself as the payment lands. If you passed a return_url, they get a link back to your order page.
4. Settle on the webhook
Verify the signature, fulfil the order, and reply 200. Anything else is treated as a failure and retried.
import crypto from "crypto";
// rawBody must be the raw bytes, not parsed-and-restringified JSON.
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(rawBody)
.digest("hex");
const got = req.headers["x-webhook-signature"] ?? "";
const ok =
got.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
if (!ok) return res.status(401).end(); // never parse an unverified body
// ... fulfil the order, then:
res.status(200).end();Authentication
Every merchant endpoint takes your API key as a bearer token: Authorization: Bearer xmr_sk_…. Missing or unknown keys get 401.
Two routes are deliberately public, because the buyer needs them and has no key: GET /api/invoices/:idand the checkout page. The invoice id is the capability, so treat it as a secret URL and don't put it anywhere public.
Core concepts
Invoices
An invoice is one payment request: an amount, a subaddress, and a deadline.
Parameters
Amount in XMR as a decimal string, up to 12 decimal places. Use this or the price_* pair, not both.
Fiat price. Converted at creation using the market rate, which is then locked.
Free text, shown on the checkout page.
5 to 1440. Defaults to 60.
http(s) link the buyer can follow back to your order after paying.
JSON object up to 4 KB, echoed in every webhook for this invoice. Use it to carry your own order id.
Amounts
Amounts are exact. Internally everything is integer piconero (10-12 XMR), so nothing is lost to floating point. The API gives you both amount_atomic and a decimal amount_xmr string.
pending; anything over is credited to the invoice and kept.Payment statuses
pendingCreated, waiting for payment. Becomes expired when its window elapses.
detectedThe full amount was seen in the mempool or a fresh block. Fast, optimistic, not final.
confirmedThe payment reached the confirmation threshold (10 by default). Safe to fulfil.
expiredThe window elapsed without sufficient payment.
A detected payment can still vanish: a dropped or replaced transaction, or a reorg. If that happens the invoice falls back to pending and its payment_detected webhook is withdrawn before delivery. This is why goods worth more than a coffee should ship on payment_confirmed.
Webhooks
Umbra POSTs JSON to the URL registered on your account, once per state change. The event name is in X-Webhook-Event and the signature in X-Webhook-Signature.
payment_detected0-conf. The full amount was seen. Treat as incoming, not settled.
payment_confirmedConfirmed on-chain. This is the settlement signal.
POST /umbra/webhook
X-Webhook-Event: payment_confirmed
X-Webhook-Signature: f889d2ae...ec23a # HMAC-SHA256, hex, no prefix
{
"event": "payment_confirmed",
"invoice_id": "0c0766e8-...",
"merchant_id": "1cf6b8b8-...",
"amount_due_xmr": "0.044186005408",
"amount_received_xmr": "0.044186005408",
"price_amount": "25.00",
"price_currency": "USD",
"rate": "565.8",
"txids": ["5faf1d15...99717"],
"confirmations": 10,
"metadata": { "order_id": 1042 },
"timestamp": "2026-09-22T11:57:10.433Z"
}Reply with any 2xx once you have stored the event. Umbra retries anything else, so a slow or failing endpoint will receive the same event again.
Verifying signatures
The signature is HMAC-SHA256 of the raw request body, hex-encoded, keyed with your webhook secret. There is no prefix and no timestamp in the value. Compare in constant time, and never parse a body you haven't verified.
import crypto from "crypto";
// rawBody must be the raw bytes, not parsed-and-restringified JSON.
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(rawBody)
.digest("hex");
const got = req.headers["x-webhook-signature"] ?? "";
const ok =
got.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
if (!ok) return res.status(401).end(); // never parse an unverified body
// ... fulfil the order, then:
res.status(200).end();Retries & redelivery
A delivery is attempted up to 8 times with exponential backoff, starting at 10 seconds and capping at an hour, with a 10-second timeout per attempt. Deliveries are queued per invoice and event, so an event is never duplicated by the queue itself, though a retry can still arrive after your handler succeeded, so make handlers idempotent.
You can inspect every attempt, including the last error, and push a delivery back onto the queue by hand.
GET/api/invoices/:id/webhooksDelivery log for one invoice: attempts, last error, delivered_at.
POST/api/webhook-deliveries/:id/redeliverQueue a delivery again, resetting its attempt count.
POST/api/me/rotate-secretIssue a new webhook secret. The old one stops working immediately, so update your server first.
API reference
Invoices
POST/api/merchantsCreate an account. Returns the API key and webhook secret once. No auth.
POST/api/invoicesCreate an invoice. Returns the subaddress and checkout_url.
GET/api/invoices/:idInvoice status, amounts and confirmations. Public: this is what the checkout page polls.
GET/api/me/invoicesEvery invoice on your account, newest first.
Account
GET/api/meYour account: name, address, webhook URL.
POST/api/me/webhookChange the URL webhooks are delivered to.
POST/api/me/rotate-secretReplace the webhook secret, effective immediately.
PUT/api/me/shopifyConnect or update a Shopify store.
GET/api/me/shopifyShopify connection status and recent orders.
DELETE/api/me/shopifyDisconnect Shopify.
Errors
Errors are JSON: { "error": "amount_xmr must be positive" }, with a status that says whose problem it is.
400Your request was malformed: a bad amount, an unsupported currency, an expiry outside 5–1440.
401Missing or unknown API key.
404No such invoice, delivery, or connection, or it belongs to another account.
409Conflict: that Monero address is already registered to a merchant.
500Something failed on our side. Safe to retry.
Integrations
Shopify
Buyers pick Pay with Monero at checkout, Shopify holds the order as pending, and Umbra marks it paid once the invoice confirms. Setup is six steps and no code.
Create an app for your store
In Shopify's Dev Dashboard, choose Create app → Start from Dev Dashboard. This is the only way to create store apps since January 2026.
Give it access to orders
On the app's version, select the Admin API scopes read_orders and write_orders, then release the version.
Install it on your store
From the app's Home page, choose Install app. The app and store must be in the same Shopify organization.
Connect it to Umbra
Send the app's Client ID and secret with your Umbra API key. Umbra verifies them, subscribes to your orders, and replies with the text for the next two steps.
Add the payment method
In Shopify: Settings → Payments → Manual payment methods → Create custom payment method. Name it exactly Pay with Monero and paste in the returned payment_instructions.
Add the pay button to your emails
In Settings → Notifications → Order confirmation, paste the returned email_snippet where the button should appear.
curl -X PUT https://www.umbra.international/api/me/shopify \
-H "Authorization: Bearer $UMBRA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"shop_domain": "your-store.myshopify.com",
"client_id": "<Client ID>",
"client_secret": "<Client secret>"
}'{% if financial_status == "pending" %}
<p>
<a href="https://www.umbra.international/shopify/pay/your-store.myshopify.com/{{ id }}">
Pay with Monero
</a>
</p>
{% endif %}What happens to each order
Shopify holds it as Payment pending. Umbra invoices it in the order's currency with the rate locked.
From the button in their confirmation email, or on your pay page using their order number.
Umbra marks the order Paid through the Admin API.
The unpaid invoice is withdrawn so nobody can pay a cancelled order.
Opening the pay link again issues a fresh quote at the current rate.
Never re-quoted automatically; the buyer is asked to contact you, so nothing they sent is lost.
WooCommerce
Install the plugin, paste in two keys, and Monero appears at your checkout. Works with the classic and the block checkout, and needs no build step.
Install the plugin
Upload the umbra-monero-gateway folder to wp-content/plugins/ and activate it. Requires WooCommerce 7.0+, WordPress 6.0+ and PHP 7.4+.
Add your keys
In WooCommerce → Settings → Payments → Umbra Monero, enable it and paste in your Umbra API URL, API key and webhook secret.
Point Umbra at your store
Register the webhook URL shown on that screen with Umbra. This is what marks orders paid.
Choose when orders complete
Confirmed (default, final) or 0-conf (instant but reversible). Pick 0-conf only for instantly delivered goods.
curl -X POST https://www.umbra.international/api/me/webhook \
-H "Authorization: Bearer $UMBRA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"webhook_url": "https://yourstore.com/?wc-api=umbra_monero"}'What happens to each order
The order is created On hold, reserving stock, and invoiced in your store currency.
On the hosted Umbra checkout, then lands back on your order-received page.
At 0-conf the plugin adds an order note. The order stays On hold.
The order is marked paid and moves to Processing, or Completed for downloads.
The invoice expires and the order stays On hold for you to cancel. Nothing is charged.
Sent from your own wallet, because Umbra never holds your funds.
Going live
Checklist
- Run your own Monero node. A public node can see which blocks you request. Your own node keeps that to yourself.
- Back up your master key. It encrypts stored view keys; lose it and they cannot be decrypted.
- Use the final HTTPS host everywhere. A redirect from the bare domain to
wwwcounts as a failed webhook delivery. - Return
200quickly and do slow work afterwards; the delivery times out after 10 seconds. - Make handlers idempotent. A retry can arrive after you already fulfilled.
- Send one small real payment before launch, end to end.
Limits & currencies
USD, EUR, GBP, CAD, AUD, CHF, JPY, NZD, SEK, PLN, BRL, INR.
5 to 1440 minutes, default 60.
10 by default, roughly 20 minutes.
JSON object, 4 KB maximum.
12 decimal places (1 piconero).
8, with backoff from 10 seconds to 1 hour.
Something missing or wrong here? Tell us. These docs describe a gateway that is still being built in the open.