Developer documentation

Get paid in Monero, non-custodially.

Create an invoice, show the buyer a hosted checkout, and settle when a signed webhook says the payment confirmed. Umbra watches the chain with your view key alone, so it can see payments arrive but can never move them.

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.
Payments are final. Monero transactions cannot be reversed, so refunds are something you send yourself, from your own wallet.

How it works

01

Share your view key

Sign up with your address and private view key. Umbra creates a watch-only wallet from them.

02

Create an invoice

Each invoice gets its own subaddress and a hosted checkout page, so payments match themselves.

03

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.

POST /api/merchants
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.

POST /api/invoices
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.

Your webhook endpoint
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.

There is no key-rotation endpoint yet. The API key is issued once at signup; the webhook secret can be rotated (see Retries & redelivery).

Core concepts

Invoices

An invoice is one payment request: an amount, a subaddress, and a deadline.

Parameters

amount_xmr

Amount in XMR as a decimal string, up to 12 decimal places. Use this or the price_* pair, not both.

price_amount + price_currency

Fiat price. Converted at creation using the market rate, which is then locked.

description

Free text, shown on the checkout page.

expires_in_minutes

5 to 1440. Defaults to 60.

return_url

http(s) link the buyer can follow back to your order after paying.

metadata

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.

An invoice is paid when the received total reaches the amount due. Underpayments leave it pending; anything over is credited to the invoice and kept.

Payment statuses

pending

Created, waiting for payment. Becomes expired when its window elapses.

detected

The full amount was seen in the mempool or a fresh block. Fast, optimistic, not final.

confirmed

The payment reached the confirmation threshold (10 by default). Safe to fulfil.

expired

The 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_detected

0-conf. The full amount was seen. Treat as incoming, not settled.

payment_confirmed

Confirmed on-chain. This is the settlement signal.

Example delivery
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.

verify.ts
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();
Frameworks that parse JSON for you (Express, Next.js route handlers, Laravel) will break verification if you re-serialise the parsed object. Capture the raw body first.

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/webhooks

Delivery log for one invoice: attempts, last error, delivered_at.

POST/api/webhook-deliveries/:id/redeliver

Queue a delivery again, resetting its attempt count.

POST/api/me/rotate-secret

Issue a new webhook secret. The old one stops working immediately, so update your server first.

API reference

Invoices

POST/api/merchants

Create an account. Returns the API key and webhook secret once. No auth.

POST/api/invoices

Create an invoice. Returns the subaddress and checkout_url.

GET/api/invoices/:id

Invoice status, amounts and confirmations. Public: this is what the checkout page polls.

GET/api/me/invoices

Every invoice on your account, newest first.

Account

GET/api/me

Your account: name, address, webhook URL.

POST/api/me/webhook

Change the URL webhooks are delivered to.

POST/api/me/rotate-secret

Replace the webhook secret, effective immediately.

PUT/api/me/shopify

Connect or update a Shopify store.

GET/api/me/shopify

Shopify connection status and recent orders.

DELETE/api/me/shopify

Disconnect Shopify.

Errors

Errors are JSON: { "error": "amount_xmr must be positive" }, with a status that says whose problem it is.

400

Your request was malformed: a bad amount, an unsupported currency, an expiry outside 5–1440.

401

Missing or unknown API key.

404

No such invoice, delivery, or connection, or it belongs to another account.

409

Conflict: that Monero address is already registered to a merchant.

500

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

01

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.

02

Give it access to orders

On the app's version, select the Admin API scopes read_orders and write_orders, then release the version.

03

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.

04

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.

05

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.

06

Add the pay button to your emails

In Settings → Notifications → Order confirmation, paste the returned email_snippet where the button should appear.

Step 4 · connect your store
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>"
  }'
Step 6 · order confirmation email
{% 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

Order placed

Shopify holds it as Payment pending. Umbra invoices it in the order's currency with the rate locked.

Buyer pays

From the button in their confirmation email, or on your pay page using their order number.

Payment confirmed

Umbra marks the order Paid through the Admin API.

Order cancelled

The unpaid invoice is withdrawn so nobody can pay a cancelled order.

Invoice expired

Opening the pay link again issues a fresh quote at the current rate.

Partial payment

Never re-quoted automatically; the buyer is asked to contact you, so nothing they sent is lost.

Umbra keeps only each order's number, total, status and order-page link, never customer names, emails or addresses, and stores your client secret encrypted. Recent orders are also polled every two minutes, so a failed webhook never strands an order.

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.

01

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

02

Add your keys

In WooCommerce → Settings → Payments → Umbra Monero, enable it and paste in your Umbra API URL, API key and webhook secret.

03

Point Umbra at your store

Register the webhook URL shown on that screen with Umbra. This is what marks orders paid.

04

Choose when orders complete

Confirmed (default, final) or 0-conf (instant but reversible). Pick 0-conf only for instantly delivered goods.

Step 3 · register your store's webhook
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

Buyer picks Monero

The order is created On hold, reserving stock, and invoiced in your store currency.

Buyer pays

On the hosted Umbra checkout, then lands back on your order-received page.

Payment seen

At 0-conf the plugin adds an order note. The order stays On hold.

Payment confirmed

The order is marked paid and moves to Processing, or Completed for downloads.

Buyer never pays

The invoice expires and the order stays On hold for you to cancel. Nothing is charged.

Refunds

Sent from your own wallet, because Umbra never holds your funds.

The payment method hides itself when your store currency isn't one Umbra can price, and only the order total, currency, number and a return link are ever sent.

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 www counts as a failed webhook delivery.
  • Return 200 quickly 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

Fiat currencies

USD, EUR, GBP, CAD, AUD, CHF, JPY, NZD, SEK, PLN, BRL, INR.

Invoice window

5 to 1440 minutes, default 60.

Confirmations

10 by default, roughly 20 minutes.

Metadata

JSON object, 4 KB maximum.

Amount precision

12 decimal places (1 piconero).

Webhook attempts

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.

Ship it.

Create an account, drop in your view key, point a webhook at your server. Funds settle straight to your wallet.