{"message":"Onboarding & facilitator flow (HTML).","url":"/docs/facilitator","markdown":"# Facilitator flow and testing\n\nThis document describes how the **main Worker** (resource server) and the **facilitator** Worker work together, and how to test the full flow end-to-end.\n\n---\n\n## Architecture\n\n- **Main Worker** (e.g. `https://xpr.testsitout.com`): Serves free routes and one paywalled route (`/protected-route`). When you request the paywalled route without payment, it returns **402** with payment details. It can accept proof of payment in two ways:\n  1. **Direct:** you send the **transaction id** (query or header); the main Worker verifies the transaction on XPR Network (Hyperion) and marks it used in KV.\n  2. **Via facilitator:** you send a **Bearer token** (JWT) issued by the facilitator; the main Worker verifies the JWT and marks the tx id (inside the JWT) used in KV. It does **not** call the chain when using the facilitator.\n\n- **Facilitator** (e.g. `https://facilitator-xpr.testsitout.com`): A separate Worker that **verifies** a transaction on Proton (Hyperion) and **issues a short-lived JWT**. Clients POST their `tx_id` (and optionally `audience` in multi-tenant) to the facilitator; if the payment is valid, the facilitator returns a token. The main Worker trusts that token (shared secret or per-partner secret).\n\n**Single-tenant:** One JWT secret shared by the main Worker and the facilitator. No `audience`.\n\n**Multi-tenant:** You run one facilitator; each “partner” (e.g. a site) **registers** with an **audience** id and their own **secret**. The facilitator stores partner secrets in KV and signs JWTs with the right secret when the client sends `audience`. Each partner sets `FACILITATOR_URL`, `FACILITATOR_AUDIENCE`, and `FACILITATOR_JWT_SECRET` on their main Worker.\n\n---\n\n## End-to-end flow (with facilitator)\n\n1. **Client** requests the protected resource (e.g. `GET https://xpr.testsitout.com/protected-route`).\n2. **Main Worker** responds with **402** and a JSON body that includes:\n   - `payTo`, `maxAmountRequired`, `resource`, `explorer`, etc.\n   - `facilitatorUrl` (e.g. `https://facilitator-xpr.testsitout.com/verify`)\n   - `facilitatorAudience` (in multi-tenant, e.g. `mpc-test`)\n3. **Client** pays on Proton (sends XPR/XUSDC to `payTo`), gets a **transaction id**.\n4. **Client** POSTs to the facilitator:\n   - Body: `{ \"tx_id\": \"<id>\", \"resource\": \"/protected-route\" }` and, in multi-tenant, `\"audience\": \"mpc-test\"`.\n5. **Facilitator** fetches the transaction from Hyperion, checks it’s a valid transfer to the receiver for the required amount. If the tx id was already used to issue a token, returns **409**. Otherwise it stores “issued” and returns **200** with `{ \"token\": \"<jwt>\", \"expires_in\": 300 }`.\n6. **Client** requests the protected resource again with `Authorization: Bearer <token>`.\n7. **Main Worker** verifies the JWT (using the partner’s secret and, if set, `aud` = `FACILITATOR_AUDIENCE`), extracts the tx id, checks it’s not already used in KV, marks it used, and returns **200** with the premium content.\n\n---\n\n## Setup (multi-tenant example)\n\n1. **Deploy the main Worker** (this repo root): `npm run deploy`. Configure `XPR_RECEIVER_ACCOUNT`, `XPR_MIN_AMOUNT`, and optionally `FACILITATOR_URL`, `FACILITATOR_AUDIENCE` (vars), and `FACILITATOR_JWT_SECRET` (secret).\n\n2. **Deploy the facilitator** (`facilitator/`): `npx wrangler deploy`. Configure `XPR_RECEIVER_ACCOUNT`, `XPR_MIN_AMOUNT`, KV `USED_TX_IDS`, and `FACILITATOR_ADMIN_SECRET` (secret). Do **not** set `FACILITATOR_JWT_SECRET` on the facilitator in multi-tenant.\n\n3. **Register a partner** (once per partner):\n   - **PowerShell:**\n     ```powershell\n     Invoke-RestMethod -Uri \"https://facilitator-xpr.testsitout.com/register\" -Method Post -ContentType \"application/json\" -Headers @{\"X-Admin-Key\"=\"YOUR_ADMIN_SECRET\"} -Body '{\"audience\":\"mpc-test\",\"secret\":\"their-strong-secret\"}'\n     ```\n   - **curl (Bash/Git Bash):**\n     ```bash\n     curl -X POST \"https://facilitator-xpr.testsitout.com/register\" \\\n       -H \"Content-Type: application/json\" \\\n       -H \"X-Admin-Key: YOUR_ADMIN_SECRET\" \\\n       -d '{\"audience\":\"mpc-test\",\"secret\":\"their-strong-secret\"}'\n     ```\n\n4. **Configure the main Worker** for that partner: set `FACILITATOR_URL`, `FACILITATOR_AUDIENCE` (e.g. `mpc-test`), and `FACILITATOR_JWT_SECRET` (the same value as `secret` used in register). Redeploy the main Worker.\n\n---\n\n## How to test\n\nBase URL for the main site: **https://xpr.testsitout.com**  \nFacilitator: **https://facilitator-xpr.testsitout.com**  \nExample audience: **mpc-test**.\n\n### Step 1: Get the 402 and confirm facilitator fields\n\nUse **Accept: application/json** so the response is JSON (otherwise the server may return HTML).\n\n**PowerShell:**\n\n```powershell\n$r = Invoke-WebRequest -Uri \"https://xpr.testsitout.com/protected-route\" -Method Get -SkipHttpErrorCheck -Headers @{ \"Accept\" = \"application/json\" }\n$r.StatusCode   # should be 402\n($r.Content | ConvertFrom-Json) | Format-List payTo, maxAmountRequired, facilitatorUrl, facilitatorAudience\n```\n\n**curl:**\n\n```bash\ncurl -sS -H \"Accept: application/json\" \"https://xpr.testsitout.com/protected-route\"\n# Expect 402 and JSON with payTo, maxAmountRequired, facilitatorUrl, facilitatorAudience (e.g. mpc-test)\n```\n\nYou should see `facilitatorUrl` (e.g. `https://facilitator-xpr.testsitout.com/verify`) and `facilitatorAudience` (e.g. `mpc-test`).\n\n---\n\n### Step 2: Pay on Proton\n\nSend **0.1000 XPR** (or the amount in `maxAmountRequired`) to the account in **payTo** (e.g. **cbeau**) from any XPR wallet or the Proton CLI. Note the **transaction id** from the wallet or [Proton explorer](https://proton.bloks.io).\n\n---\n\n### Step 3: Exchange tx id for a token (facilitator)\n\nReplace `YOUR_TX_ID` with the real transaction id from step 2.\n\n**PowerShell:**\n\n```powershell\n$body = '{\"tx_id\":\"YOUR_TX_ID\",\"resource\":\"/protected-route\",\"audience\":\"mpc-test\"}'\n$resp = Invoke-RestMethod -Uri \"https://facilitator-xpr.testsitout.com/verify\" -Method Post -ContentType \"application/json\" -Body $body\n$resp.token   # copy this for step 4\n$resp.expires_in\n```\n\n**curl:**\n\n```bash\ncurl -s -X POST \"https://facilitator-xpr.testsitout.com/verify\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"tx_id\":\"YOUR_TX_ID\",\"resource\":\"/protected-route\",\"audience\":\"mpc-test\"}' \n# Response: {\"token\":\"eyJ...\",\"expires_in\":300}\n```\n\nOn success you get `token` (JWT) and `expires_in` (seconds). On failure: **402** (transaction not found or invalid—if you just paid, the indexer may still be catching up; wait 2–5 seconds and retry the POST), **409** (tx id already used), or **401** (unknown audience).\n\n---\n\n### Step 4: Unlock the protected route with the token\n\nUse **Accept: application/json** to get JSON; omit it for HTML.\n\n**PowerShell:**\n\n```powershell\n$token = \"PASTE_THE_TOKEN_FROM_STEP_3\"\nInvoke-RestMethod -Uri \"https://xpr.testsitout.com/protected-route\" -Method Get -Headers @{\"Authorization\"=\"Bearer $token\"; \"Accept\"=\"application/json\"}\n```\n\n**curl:**\n\n```bash\ncurl -sS -H \"Accept: application/json\" -H \"Authorization: Bearer PASTE_THE_TOKEN\" \"https://xpr.testsitout.com/protected-route\"\n```\n\nExpected response: JSON with `paid: true`, `resource`, `currency`, `message` (e.g. \"Hello World, premium edition!...\").\n\n---\n\n### Step 5: Reuse the same tx id (should fail)\n\n- Call the facilitator **again** with the same `tx_id` and `audience`. You should get **409** (already used).\n- Call the main Worker again with the same Bearer token (before it expires). It should still return 200 for that token, but the **tx id** is already marked used, so a **new** token for the same tx id cannot be issued.\n\nUsing the same token again: the main Worker will accept it until it expires (e.g. 5 min), but each tx id can only be used once to **obtain** a token and once to **unlock** (the main Worker marks the tx id used on first successful Bearer unlock).\n\n---\n\n## Testing the full flow (curl / PowerShell)\n\nUse the steps above (get 402 with `Accept: application/json`, pay on Proton, POST to facilitator with retry on 402, then GET with Bearer). For a copy-paste curl sequence that includes the facilitator and retry logic, see **[Client & agent samples](/docs/samples)** on the site.\n\n---\n\n## Quick reference: URLs and roles\n\n| What              | URL / value |\n|-------------------|-------------|\n| Main site         | https://xpr.testsitout.com |\n| Protected route   | https://xpr.testsitout.com/protected-route |\n| Facilitator base  | https://facilitator-xpr.testsitout.com |\n| POST verify       | https://facilitator-xpr.testsitout.com/verify |\n| POST register     | https://facilitator-xpr.testsitout.com/register (admin) |\n| PUT rotate secret | https://facilitator-xpr.testsitout.com/keys/:audience (admin) |\n| Example audience  | mpc-test |\n\n---\n\n## Rotating a partner secret (multi-tenant)\n\n**PowerShell:**\n\n```powershell\nInvoke-RestMethod -Uri \"https://facilitator-xpr.testsitout.com/keys/mpc-test\" -Method Put -ContentType \"application/json\" -Headers @{\"X-Admin-Key\"=\"YOUR_ADMIN_SECRET\"} -Body '{\"secret\":\"new-strong-secret\"}'\n```\n\nThen update the main Worker’s `FACILITATOR_JWT_SECRET` to the new value and redeploy. Existing tokens (signed with the old secret) will stop validating after you rotate.\n\n---\n\n## Troubleshooting\n\n- **402 from main site but no `facilitatorUrl`:** The main Worker doesn’t have `FACILITATOR_URL` set or it’s empty. Set it in `wrangler.toml` (vars) or dashboard and redeploy.\n\n- **402 with `facilitatorUrl` but no `facilitatorAudience`:** You’re in single-tenant mode (one shared JWT secret). Omit `audience` when calling POST /verify. If you want multi-tenant, register a partner and set `FACILITATOR_AUDIENCE` on the main Worker.\n\n- **401 from facilitator on POST /verify:** In multi-tenant, the `audience` you sent is not registered, or the facilitator has no partner secrets in KV. Register the partner first (POST /register with admin key).\n\n- **402 from facilitator (“invalid_payment”):** The transaction id doesn’t exist on Proton or the transaction doesn’t include a valid transfer to the configured receiver for the required amount. Check receiver and amount match the main Worker config; wait a few seconds for the chain to index.\n\n- **409 from facilitator:** A token was already issued for this transaction id. Each tx id can only be used once to get a token. Use a new payment (new tx id) to test again.\n\n- **401 or 403 from main site when using Bearer:** The JWT is expired, invalid, or signed with a different secret than the main Worker’s `FACILITATOR_JWT_SECRET`. In multi-tenant, the JWT `aud` must match the main Worker’s `FACILITATOR_AUDIENCE`. Ensure the main Worker has the same secret as the one the facilitator used for that audience.\n"}