Merchant API

Accept private payments from your own backend. Create a charge, get a signed webhook when it settles, pull a cryptographic receipt. No signup, no KYC, no dashboard required — one curl gets you a key.

Base URLhttps://privacyhood-backend-843fl.ondigitalocean.app

Quickstart

Three calls, start to finish. Everything below is copy-paste runnable against the live API right now — no sandbox, no test keys, because there is nothing to sign up for.

# 1 — mint an API key. Keep it on your server; it is shown once and never stored by us.
curl -sX POST https://privacyhood-backend-843fl.ondigitalocean.app/api-keys \
  -H 'content-type: application/json' \
  -d '{"label":"my-store"}'
# → {"keyId":"mk_…","apiKey":"phk_mk_….<hmac>","label":"my-store","createdAt":"…"}

export PRIVACYHOOD_API_KEY="phk_mk_….<hmac>"   # ← paste the apiKey from step 1

# 2 — create an invoice for 25 USDG, with a webhook.
#     NOTE: if the key is missing or empty this STILL returns 201 — and the invoice is orphaned,
#     meaning you can never list, void or receipt it. Always confirm the header is really set.
curl -sX POST https://privacyhood-backend-843fl.ondigitalocean.app/invoices \
  -H "authorization: Bearer $PRIVACYHOOD_API_KEY" \
  -H 'content-type: application/json' \
  -d '{
        "recipient": "0xYourPayoutAddress",
        "amount": "25",
        "ref": "ORDER-1042",
        "memo": "One large coffee",
        "webhookUrl": "https://yourstore.com/hooks/privacyhood"
      }'
# → {"invoiceId":"inv_…","manageToken":"…","webhookSecret":"whsec_…", …}
#   Store webhookSecret against your order NOW — it is per-invoice and shown only once.

# 3 — send the customer to checkout. Build this URL yourself; the API does not return one.
echo "https://www.privacyhood.org/pay?invoice=inv_…"

What your customer sees: a checkout page at https://www.privacyhood.org/pay?invoice=<id>. They pay from any wallet; the funds route through a shielded pool before reaching you, so your payout address is never linked on-chain to theirs. Build that URL yourself from the invoiceId — the API does not return it.

Authentication

Send your key as a bearer token: authorization: Bearer phk_…. A key looks like phk_<keyId>.<hmac> and is verified in constant time without a database read.

The key is shown once and we never store it — only its id, so we can revoke it but can never show or recover it. Keep it on your server: it covers every invoice it will ever create, so it does not belong in a browser, a mobile app, or a git repo. Lost one? Revoke it and mint another.

Keys are isolated from each other. Another merchant's key listing your invoices gets an empty array, and acting on an invoice it does not own returns 401 — the same answer as a bad key, so nothing leaks about what exists.

Test mode

Mint a test key (`mode:"test"`, id `tk_…`) and its invoices become a sandbox: they can never be paid with real USDG, and you settle them yourself with POST /invoices/{id}/simulate-pay — which fires the same signed invoice.paid webhook a live payment would. This is how you test your webhook handler before you've ever taken a real payment. Test and live are fully isolated: a test invoice can't attach a real transfer, and simulate-pay refuses live invoices.

# 1 — a test key (note the tk_ id in the response)
curl -sX POST https://privacyhood-backend-843fl.ondigitalocean.app/api-keys \
  -H 'content-type: application/json' -d '{"mode":"test","label":"local dev"}'
export PRIVACYHOOD_TEST_KEY="phk_tk_….<hmac>"

# 2 — a test invoice, pointed at your webhook endpoint
INV=$(curl -sX POST https://privacyhood-backend-843fl.ondigitalocean.app/invoices \
  -H "authorization: Bearer $PRIVACYHOOD_TEST_KEY" -H 'content-type: application/json' \
  -d '{"recipient":"0xYourPayoutAddress","amount":"25","ref":"TEST-1","webhookUrl":"https://your.site/hook"}' \
  | grep -o '"invoiceId":"[^"]*"' | cut -d'"' -f4)

# 3 — settle it. Your endpoint receives a real signed invoice.paid webhook within the response.
curl -sX POST https://privacyhood-backend-843fl.ondigitalocean.app/invoices/$INV/simulate-pay \
  -H "authorization: Bearer $PRIVACYHOOD_TEST_KEY"
# → { "ok":true, "status":"paid", "test":true, "deliveredTx":"0x…",
#     "webhook":{ "delivered":true, "responseStatus":200 } }

The webhook a test invoice fires is signed and shaped identically to a live one — same x-privacyhood-signature, same body — so if your handler accepts it in test, it accepts the real thing. The only tell is a synthetic deliveredTx. Test invoices don't issue a signed receipt (there's no real payment to certify).

E-commerce plugins

Don't want to write code? The WooCommerce plugin wires all of the above into your store: it creates the invoice, redirects the shopper to checkout, and flips the order to paid from the signed webhook — no funds ever touch your server.

Download the WooCommerce plugin (.zip)
StepDo this
1Download the plugin ZIP and install it: WordPress admin → Plugins → Add New → Upload Plugin. Activate.
2Mint a merchant API key at privacyhood.org/merchant (free, no signup) and copy your 0x payout address.
3WooCommerce → Settings → Payments → PrivacyHood → Manage. Enable it, paste the API key and payout address, save.
4Place a test order. You're redirected to the hosted checkout; once it settles, the signed webhook flips the order to paid automatically.

Shopify: Shopify does not allow a third-party crypto payment gateway without becoming an approved Payments Partner, so there is no drop-in app to install. Two honest paths work today: (1) add PrivacyHood as a manual/offsite payment method and drop a hosted pay link built from POST /invoices on your order page, or (2) call this API from your own backend or a Shopify Function and reconcile with the invoice.paid webhook. Both use exactly the endpoints documented above. A first-class Shopify app is on the roadmap.

What you actually receive

You receive the full invoice amount. The fee is added on top of it, not taken out of it — a 25 USDG invoice deposits 25 USDG to your payout address, and the payer covers the fee. Price your goods normally; you do not need to gross up.

The fee is a percentage plus a small flat component, quoted to the payer before they commit and snapshotted at that moment so it cannot move underneath them. The live schedule is at GET /fee-tiers; senders holding $PHOOD pay less.

Settlement is not instant — funds route through a swap, a shielded pool, and a fresh-key relayer before delivery, which is what breaks the on-chain link between your customer and you. Drive fulfilment off the invoice.paid webhook rather than expecting a synchronous result.

Invoice lifecycle

StatusMeaning
openNobody has started paying — OR a previous attempt expired or was refunded, so it is payable again. Not a dead end, and a `pending` invoice can return here.
pendingA payment is in flight through the privacy pipeline. Do not fulfil yet.
paidSettled. `deliveredTx` is the on-chain delivery and a signed receipt is now available. Final — a paid invoice can never be voided.
voidYou cancelled it before payment. Terminal.

Endpoints

Amounts are always strings. amountBaseUnits is an integer string in USDG base units (6 decimals), so "25000000" is 25 USDG — never use a float for money. Invoices accept 1–10,000 USDG.

POST/api-keyspublic

Create an API key

Mints a durable merchant identity. The key is shown ONCE and never stored by us — we keep only its id, so we can revoke it but can never show or recover it. Creation is unauthenticated because a key grants nothing you cannot already do without one; it only groups invoices under an identity you hold.

FieldTypeNotes
labelstringOptional, ≤64 chars. For your own bookkeeping.
modestringOptional. "test" mints a SANDBOX key (id `tk_…`) whose invoices never move real money. Omit for a live key.

Request

curl -sX POST https://privacyhood-backend-843fl.ondigitalocean.app/api-keys \
  -H 'content-type: application/json' \
  -d '{"label":"my-store"}'

Response

{
  "keyId": "mk_<uuid>",
  "apiKey": "phk_mk_<uuid>.<64-hex hmac>",
  "label": "my-store",
  "createdAt": "2026-08-07T19:56:18.906Z"
}
POST/api-keys/revokeAPI key

Revoke your API key

A key revokes itself — there is no account above it that could authorize revoking someone else's, and taking an id from the URL would only add an enumeration surface. Effective immediately. Invoices already created stay payable; you simply lose API access to them, so mint the replacement first.

Request

curl -sX POST https://privacyhood-backend-843fl.ondigitalocean.app/api-keys/revoke \
  -H "authorization: Bearer $PRIVACYHOOD_API_KEY"

Response

{ "ok": true, "keyId": "mk_<uuid>", "revoked": true }
POST/invoicesAPI key

Create an invoice

The core call. Send `amount` as a whole-token decimal string ("25") OR `amountBaseUnits` as an integer string ("25000000", 6 decimals) — one of the two is required. The API key is technically optional and a request without one still returns 201, but that invoice belongs to nobody: only the `manageToken` in the response can ever act on it again. Always send the key from a server.

FieldTypeNotes
recipientrequiredstringYour payout address (0x, EIP-55 checksummed).
amountrequiredstringWhole tokens, e.g. "25" (1–10,000). Or send amountBaseUnits instead.
refstringYour order id, ≤64 chars. Echoed back in the webhook.
memostringShown to the payer at checkout, ≤280 chars.
webhookUrlstringhttps:// and publicly resolvable only. Returns a per-invoice signing secret.

Request

curl -sX POST https://privacyhood-backend-843fl.ondigitalocean.app/invoices \
  -H "authorization: Bearer $PRIVACYHOOD_API_KEY" \
  -H 'content-type: application/json' \
  -d '{"recipient":"0xYourPayoutAddress","amount":"25","ref":"ORDER-1042"}'

Response

{
  "invoiceId": "inv_<uuid>",
  "manageToken": "<64-hex>",          // per-invoice credential, shown once
  "amountBaseUnits": "25000000",
  "token": "USDG",
  "recipient": "0xYourPayoutAddress",
  "memo": "One large coffee",
  "ref": "ORDER-1042",
  "webhookUrl": "https://yourstore.com/hooks/privacyhood",
  "webhookSecret": "whsec_<48-hex>"   // PER INVOICE, shown once — store it
}
GET/invoices?limit=50API key

List your invoices

Every invoice created with this key, newest first, with status computed live. `limit` defaults to 50 and caps at 100. Keys are isolated: another merchant's key returns an empty list, never yours. This is the one endpoint a manage token cannot serve — listing is what the key is for.

Request

curl -s "https://privacyhood-backend-843fl.ondigitalocean.app/invoices?limit=50" \
  -H "authorization: Bearer $PRIVACYHOOD_API_KEY"

Response

{
  "invoices": [
    {
      "invoiceId": "inv_<uuid>",
      "status": "open",
      "amountBaseUnits": "25000000",
      "token": "USDG",
      "recipient": "0xYourPayoutAddress",
      "memo": "One large coffee",
      "ref": "ORDER-1042",
      "createdAt": "2026-08-07T19:56:19.198Z",
      "deliveredTx": null
    }
  ],
  "count": 1
}
GET/invoices/{id}public

Read an invoice

Public — this is what the checkout page itself reads, so it exposes only what a payer needs (never the manage token or webhook secret). Status is computed on read from the paying transfer. `deliveredTx` appears only once paid. Invoice ids are unguessable, so they are not enumerable.

Request

curl -s https://privacyhood-backend-843fl.ondigitalocean.app/invoices/inv_<uuid>

Response

{
  "invoiceId": "inv_<uuid>",
  "status": "paid",
  "amountBaseUnits": "25000000",
  "token": "USDG",
  "recipient": "0xYourPayoutAddress",
  "memo": "One large coffee",
  "ref": "ORDER-1042",
  "createdAt": "2026-08-07T19:56:19.198Z",
  "deliveredTx": "0x<delivery tx hash>"
}
GET/invoices/{id}/receiptapi-key-or-manage-token

Get a signed receipt

A portable, protocol-signed proof of payment that embeds the zero-knowledge clean-funds certificate for the paying transfer. Paid invoices only — an unpaid one returns 409. Contains post-privacy facts only (the delivery tx), never the payer's wallet. Anyone can check `signature` against the address published by GET /certificate/signer.

Request

curl -s https://privacyhood-backend-843fl.ondigitalocean.app/invoices/inv_<uuid>/receipt \
  -H "authorization: Bearer $PRIVACYHOOD_API_KEY"

Response

{
  "type": "privacyhood-invoice-receipt",
  "version": 1,
  "invoice": { "invoiceId": "inv_<uuid>", "ref": "ORDER-1042", "amountBaseUnits": "25000000", "…": "…" },
  "payment": { "transferId": "tf_<uuid>", "deliveredTx": "0x…" },
  "certificate": { "…": "…" },
  "issuedAt": 1785616579198,
  "signer": "0x<protocol signer>",
  "signature": "0x<sig>"
}
POST/invoices/{id}/voidapi-key-or-manage-token

Void an invoice

Cancels an unpaid invoice. A paid one returns 409 — settlement is final, so there is nothing to cancel. Accepts either the creating API key or that invoice's manage token; any other key gets 401.

Request

curl -sX POST https://privacyhood-backend-843fl.ondigitalocean.app/invoices/inv_<uuid>/void \
  -H "authorization: Bearer $PRIVACYHOOD_API_KEY"

Response

{ "ok": true, "invoiceId": "inv_<uuid>", "status": "void" }
POST/invoices/{id}/simulate-payapi-key-or-manage-token

Simulate payment (test mode)

TEST MODE only. Settles a test invoice on demand and fires its real signed invoice.paid webhook synchronously, returning the delivery result — so you can exercise your whole handler without spending a cent. Rejected (400) for a live invoice, which is what keeps sandbox and real money apart. Idempotent and re-fireable: call it again to resend the webhook. deliveredTx is a deterministic synthetic hash, not an on-chain tx.

Request

curl -sX POST https://privacyhood-backend-843fl.ondigitalocean.app/invoices/inv_<uuid>/simulate-pay \
  -H "authorization: Bearer $PRIVACYHOOD_TEST_KEY"

Response

{
  "ok": true,
  "invoiceId": "inv_<uuid>",
  "status": "paid",
  "test": true,
  "deliveredTx": "0x<synthetic 64-hex>",
  "webhook": { "url": "https://your.site/hook", "delivered": true, "responseStatus": 200 }
}
POST/subscriptionspublic

Create a subscription

Recurring billing. A cron mints a fresh invoice each period, and one stable link (/pay?sub=<id>) always resolves to the current one — prefer it over the invoice id. This is a PULL model: each period is paid deliberately by the customer, and nothing can auto-debit them, by design. Cancel with the manage token via POST /subscriptions/{id}/cancel.

FieldTypeNotes
recipientrequiredstringYour payout address.
amountrequiredstringPer-period amount in whole tokens.
cadencerequiredstring"daily" | "weekly" | "monthly".
refstringA PREFIX — each cycle's invoice gets its own suffixed ref, not this exact string.

Request

curl -sX POST https://privacyhood-backend-843fl.ondigitalocean.app/subscriptions \
  -H 'content-type: application/json' \
  -d '{"recipient":"0xYourPayoutAddress","amount":"10","cadence":"monthly"}'

Response

{
  "subscriptionId": "sub_<uuid>",
  "manageToken": "<64-hex>",
  "amountBaseUnits": "10000000",
  "token": "USDG",
  "recipient": "0xYourPayoutAddress",
  "memo": null,
  "refPrefix": null,
  "cadence": "monthly",
  "currentInvoiceId": "inv_<uuid>",   // may be null if the first period failed to mint
  "nextRunAt": "2026-09-06T19:56:19.198Z"
}
GET/certificate/signerpublic

Receipt signer address

The address every receipt and clean-funds certificate recovers to. Recover the signer from a receipt's `signature` and compare it against this — that is how a third party verifies a receipt without trusting us.

Request

curl -s https://privacyhood-backend-843fl.ondigitalocean.app/certificate/signer

Response

{ "signer": "0x<protocol signer address>" }
GET/fee-tierspublic

Fee schedule

The live fee table, published so you can price against exactly what is charged. Senders holding $PHOOD pay a lower rate; the tiers are on-chain-checkable.

Request

curl -s https://privacyhood-backend-843fl.ondigitalocean.app/fee-tiers

Response

{ "enabled": true, "baseRate": 0.03, "phoodAddress": "0x…", "decimals": 18, "tiers": [] }

Webhooks

Set webhookUrl when you create an invoice and we POST a signed invoice.paid event once it settles — so your store reacts instead of polling. Delivery is driven by a background job, so expect it within about a minute of settlement rather than instantly.

The signing secret is per invoice, not per account. Each POST /invoices returns its own webhookSecret, once. Store it against your order and look it up by data.invoiceId when the event arrives — a single global secret will fail to verify every invoice but one.

The event we send

POST https://yourstore.com/hooks/privacyhood
x-privacyhood-event: invoice.paid
x-privacyhood-signature: sha256=<hmac of the RAW body under THIS invoice's webhookSecret>
user-agent: PrivacyHooks-Webhooks/1

{
  "id": "evt_<random>",
  "event": "invoice.paid",
  "createdAt": "2026-08-07T19:56:19.198Z",
  "data": {
    "invoiceId": "inv_<uuid>",
    "ref": "ORDER-1042",
    "memo": "One large coffee",
    "amountBaseUnits": "25000000",
    "token": "USDG",
    "recipient": "0xYourPayoutAddress",
    "deliveredTx": "0x<delivery tx hash>"
  }
}

Verify the signature before you trust a single field — otherwise anyone who guesses your URL can mark orders paid.

// IMPORTANT: the signing secret is PER INVOICE, not per account. Store the webhookSecret you got
// back from POST /invoices alongside your order, then look it up by data.invoiceId when the event
// arrives. A single global secret will fail to verify everything except the one invoice it came from.
import { createHmac, timingSafeEqual } from "node:crypto";

function signatureMatches(rawBody: Buffer, header: string, secret: string): boolean {
  const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(header || "");
  // Compare lengths first — timingSafeEqual throws if they differ.
  return a.length === b.length && timingSafeEqual(a, b);
}

// Express — express.raw(), NOT express.json(). Re-serializing parsed JSON changes the bytes and the
// HMAC will never match.
app.post("/hooks/privacyhood", express.raw({ type: "application/json" }), async (req, res) => {
  let evt;
  try {
    evt = JSON.parse(req.body.toString("utf8"));
  } catch {
    return res.sendStatus(400);
  }

  // Look the secret up by invoice — you stored it when you created the invoice.
  const order = await db.orders.findByInvoiceId(evt?.data?.invoiceId);
  if (!order) return res.sendStatus(404);
  if (!signatureMatches(req.body, req.header("x-privacyhood-signature") || "", order.webhookSecret)) {
    return res.sendStatus(401);
  }

  // Delivery is at-least-once, so make this idempotent.
  if (evt.event === "invoice.paid" && !order.paidAt) {
    // Check the amount you expected — never fulfil on the event's word alone.
    if (evt.data.amountBaseUnits === order.amountBaseUnits) await fulfil(order.id);
  }
  res.sendStatus(200); // any 2xx stops the retries
});

Retries: any non-2xx is retried with backoff, up to 6 attempts, then we stop. Return 2xx as soon as you have stored the event and do the slow work afterwards. Treat delivery as at-least-once and make your handler idempotent on data.invoiceId.
Your URL must be https and publicly resolvable — localhost, private ranges, and cloud metadata addresses are rejected at creation time, so use a tunnel to test locally.

Errors & limits

Every error is JSON with an error string. Rate limits are per IP: roughly 10/min for creating things (keys, invoices, subscriptions) and 120/min for reads. Exceeding either returns 429.

StatusWhen
400Malformed body — a bad address, a missing/out-of-range amount, or an unsafe webhook URL. The `error` field says which.
401Missing, malformed, revoked, or foreign credential. Also returned when a key acts on an invoice it does not own — deliberately the same answer, so nothing leaks about what exists.
404No such invoice. Ids are unguessable, so this is not an enumeration oracle.
409The state forbids it — voiding a paid invoice, or requesting a receipt before payment.
429Rate limited. Back off and retry.
501That feature is disabled on this deployment.

Prefer a machine-readable contract? The full surface is published as OpenAPI 3.1 — import it into Postman or generate a client.