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.
https://privacyhood-backend-843fl.ondigitalocean.appQuickstart
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)| Step | Do this |
|---|---|
| 1 | Download the plugin ZIP and install it: WordPress admin → Plugins → Add New → Upload Plugin. Activate. |
| 2 | Mint a merchant API key at privacyhood.org/merchant (free, no signup) and copy your 0x payout address. |
| 3 | WooCommerce → Settings → Payments → PrivacyHood → Manage. Enable it, paste the API key and payout address, save. |
| 4 | Place 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
| Status | Meaning |
|---|---|
| open | Nobody 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. |
| pending | A payment is in flight through the privacy pipeline. Do not fulfil yet. |
| paid | Settled. `deliveredTx` is the on-chain delivery and a signed receipt is now available. Final — a paid invoice can never be voided. |
| void | You 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.
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.
| Field | Type | Notes |
|---|---|---|
| label | string | Optional, ≤64 chars. For your own bookkeeping. |
| mode | string | Optional. "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"
}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 }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.
| Field | Type | Notes |
|---|---|---|
| recipientrequired | string | Your payout address (0x, EIP-55 checksummed). |
| amountrequired | string | Whole tokens, e.g. "25" (1–10,000). Or send amountBaseUnits instead. |
| ref | string | Your order id, ≤64 chars. Echoed back in the webhook. |
| memo | string | Shown to the payer at checkout, ≤280 chars. |
| webhookUrl | string | https:// 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
}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
}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 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>"
}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" }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 }
}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.
| Field | Type | Notes |
|---|---|---|
| recipientrequired | string | Your payout address. |
| amountrequired | string | Per-period amount in whole tokens. |
| cadencerequired | string | "daily" | "weekly" | "monthly". |
| ref | string | A 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"
}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>" }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.
| Status | When |
|---|---|
| 400 | Malformed body — a bad address, a missing/out-of-range amount, or an unsafe webhook URL. The `error` field says which. |
| 401 | Missing, 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. |
| 404 | No such invoice. Ids are unguessable, so this is not an enumeration oracle. |
| 409 | The state forbids it — voiding a paid invoice, or requesting a receipt before payment. |
| 429 | Rate limited. Back off and retry. |
| 501 | That 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.