---
name: nibgate-sdk
description: Complete guide for integrating @nibgate/sdk into a creator-owned site. Covers widget installation, resource definition, server gating, payments (Circle Gateway and direct USDC transfer rails on Arc mainnet and testnet), browser unlock UI, admin panel for managing gating settings, onchain reputation/ratings, manifest/discovery metadata, and common gotchas.
---

# Nibgate SDK

Use `@nibgate/sdk` in the creator-owned site that serves the content. The Nibgate Hub verifies the site, indexes public metadata, and records events. The creator site keeps the protected payload, payment receiver logic, and admin configuration.

## Install

```bash
npm install @nibgate/sdk
```

## Agent Handoff

When an AI agent is helping install Nibgate, give it this file first:

- package copy: `node_modules/@nibgate/sdk/SKILL.md`
- public copy: `https://nibgate.xyz/skill.md`

Read this entire file before editing any routes or checkout code. The guide follows the full integration order.

---

## 0. Networks (mainnet vs testnet)

Nibgate runs on two Arc networks. Unless you are staging, integrate against **mainnet**:

| | Mainnet (default) | Testnet (staging) |
|---|---|---|
| Hub API | `https://api.nibgate.xyz` | `https://testnet-api.nibgate.xyz` |
| Payment network | `eip155:5042` | `eip155:5042002` |
| Chain ID | 5042 | 5042002 |
| Gateway API | `https://gateway-api.circle.com` | `https://gateway-api-testnet.circle.com` |
| Explorer | `https://explorer.arc.io` | `https://testnet.arcscan.app` |
| Browser wallet chain | `NEXT_PUBLIC_NIBGATE_NETWORK=mainnet` | (default testnet) |

The examples below show testnet values (`eip155:5042002`, `rpc.testnet.arc.io`) because testnet USDC is free from the faucet. For production, swap in the mainnet column. USDC is `0x360000...0000` and the Gateway domain is 26 on both networks. Balances and unlocks do NOT cross networks. Identity does: verify a custom domain once and both hubs agree, and the same wallet owns the same account on both stacks (sign-in is find-or-create by wallet).

---

## 1. Site Widget (Analytics & Ownership Proof)

Paste the dashboard widget into the verified site layout:

```html
<script async src="https://www.nibgate.xyz/widget.js"
  data-nibgate-site="site_..."
  data-nibgate-token="ngv_..."
  data-nibgate-api="https://api.nibgate.xyz">
</script>
```

**The widget is ONLY for analytics.** It proves domain ownership, detects resources from `data-nibgate-resource` attributes or meta tags, tracks page views, scroll depth, and time spent. It does NOT pop up a wallet or handle checkout. Wallet/payment logic comes from the SDK browser helpers.

If the widget loads after app code, the SDK queues events and flushes them when the widget is ready.

**Adblocker bypass:** The widget now sends events to `/hub/evt` instead of `/hub/track` to avoid adblocker filter lists that target the word "track". The old `/hub/track` endpoint is preserved for backward compatibility. If you self-host the backend, ensure both routes are registered.

**Hosted mode (zero backend code):** If you don't want to run your own access route, mark premium content with a data attribute and the widget handles everything:

```html
<div data-nibgate-premium="0.01" data-nibgate-recipient="0xYourWallet">
  <p>Teaser text shown to everyone...</p>
  <div data-nibgate-unlock-card>
    <span data-nibgate-wallet-label>No wallet</span>
    <button data-nibgate-connect>Connect</button>
    <button data-nibgate-unlock-btn>Unlock for 0.01 USDC</button>
    <p data-nibgate-status></p>
  </div>
  <!-- WARNING: This hidden pattern is NOT secure. Content is in the HTML.
       Use only for teasers or non-sensitive content.
       For real paid gating, serve content from a protected server endpoint. -->
  <div data-nibgate-unlocked hidden>
    Full premium content here...
  </div>
</div>
```

No SDK import, no server access route, no env vars. The default wallet is set once in the Nibgate dashboard — or override per-post with `data-nibgate-recipient`. Custom price per-post: change `data-nibgate-premium="0.05"`.

> **⚠️ Security warning:** Hosted mode puts premium content in the page HTML (hidden by CSS). Anyone can view-source and read it. For real paid content, use server-side gating with the `accessResponse` or `protect` methods. See the Security section below.

---

## 2. Resource Shape (Minimal)

The SDK auto-derives most fields from meta tags and defaults. You only need:

```ts
const resource = {
  id: post.id,
  title: post.title,       // auto-fallsback to og:title → document.title
  path: `/posts/${post.slug}`,
  price: '0.01',          // sets access: { humans: 'paid', agents: 'paid' } automatically
  paymentRail: 'gateway'  // 'gateway' (Circle EIP-3009, default) or 'transfer' (direct USDC)
}
```

The rest fills in automatically from your page's HTML:
- `url` from `window.location.origin + path`
- `type` from `<meta property="og:type">` (defaults to `article`)
- `imageUrl` from `<meta property="og:image">`
- `description` from `<meta property="og:description">` → `<meta name="description">`
- `tags` from `<meta name="keywords">`
- `currency` defaults to `USDC`
- `access` defaults to `paid` when `price > 0`, otherwise `free`
- `recipient` is optional (server's 402 challenge provides the real wallet)

Allowed types: `article`, `image`, `music`, `video`, `document` (aliases like `audio→music`, `photo→image` are normalized). Use stable IDs — changing IDs breaks continuity for Explore, receipts, and reputation.

**Important:** The `image`, `description`, and `title` fields become the public thumbnail and card copy on the Explore page. Use a teaser preview, not the actual paid file.

---

## 3. Discovery Metadata (nibgate.json)

Expose public metadata at `/nibgate.json` so the Hub can index your content:

```ts
import { manifestResponse } from '@nibgate/sdk/server'

export function GET() {
  return manifestResponse({
    origin: process.env.NIBGATE_SITE_ORIGIN,
    resources: [resource]
  })
}
```

Never include the protected payload in `nibgate.json`. It is only for public cards, indexing, and agent-readable discovery.

---

## 4. Server Access Route (Payment Challenge & Unlock)

Protect paid content in a server route, API handler, or middleware:

```ts
import { createCircleGatewayServer } from '@nibgate/sdk/server'

const nibgate = createCircleGatewayServer({
  origin: process.env.NIBGATE_SITE_ORIGIN,
  secret: process.env.NIBGATE_SECRET,
  network: process.env.NIBGATE_PAYMENT_NETWORK || 'eip155:5042002' // mainnet: 'eip155:5042'
})

export function GET(request: Request) {
  return nibgate.accessResponse(request, resource, async ({ access, resource }) =>
    new Response(protectedHtml, {
      headers: { 'content-type': 'text/html; charset=utf-8' }
    })
  )
}
```

**How `accessResponse` works:**
1. Checks for an existing unlock proof via `x-nibgate-payment-proof` header
2. If valid → returns content (200)
3. If not and `humans: 'blocked'` → returns 403
4. If not and `humans: 'paid'` → returns a `402 Payment Required` challenge with the creator's wallet, price, network, and `paymentRail` (`gateway` or `transfer`)
5. Gateway rail: if the request has a `payment-signature` header AND `NIBGATE_PAYMENT_MODE=circle-gateway` is set → verifies the payment and returns unlock proof + content
6. Transfer rail: if the request has an `x-nibgate-transfer-tx` header → verifies the on-chain USDC transfer to the creator (mined success, `Transfer` log to the seller for at least the price) and returns unlock proof + content

**The `accessPath` is queried exactly as-is.** The SDK does not auto-append `?id=` or route params. If your access route needs a content identifier, include it in the path (e.g., `/api/nibgate/access/my-content-id`) or pass it as a query param in the resource's `accessPath` field.

**After successful gateway payment, `accessResponse` automatically emits `payment_completed` and `unlock_completed` events to the Hub** (via `emitHubEvent`). This requires `NIBGATE_SITE_ID`, `NIBGATE_SITE_TOKEN`, and `NIBGATE_API_BASE` to be set — see [Required Env Vars](#11-required-env-vars). Without those, the payment still processes and the unlock proof is returned, but unlock counts and revenue on the Hub will not update.

**Custom backends:** If you build your own access route instead of using `accessResponse()`, your route must:
- Return 402 with the challenge JSON when no payment proof is present
- Check for `payment-signature` header — if present and valid, return 200 with `{ ok: true, unlockProof: "...", payment: {...} }`
- Check for `x-nibgate-payment-proof` header — if present and valid, return 200 with the protected content

---

## 5. Browser Page Wiring (Unlock UI)

Track the resource page and wire the unlock button:

```ts
import { setupResourcePage, trackResourcePage } from '@nibgate/sdk'

trackResourcePage(resource, { source: 'creator-site' })

setupResourcePage(resource, {
  source: 'creator-site',
  accessPath: '/api/nibgate/access',
  payPath: '/api/nibgate/pay',
  button: '[data-nibgate-unlock]',
  status: '[data-nibgate-status]',
  createPaymentSignature: walletGatewayAdapter.pay
})
```

For the browser wallet checkout flow (user signs with MetaMask):

```ts
import { createEvmGatewayUnlock } from '@nibgate/sdk'

createEvmGatewayUnlock(resource, {
  accessPath: '/api/nibgate/access',
  unlockButton: '#unlock-btn',
  status: '#status',
  connectButton: '#connect-btn'
})
```

The browser flow:
1. `checkResourceAccess()` calls the access route
2. If 402 → prompts wallet to sign the Gateway payment (or broadcast a direct USDC transfer)
3. Sends signature (or transfer tx hash) back to access route
4. Server verifies and returns unlock proof
5. The unlock proof is wallet-bound: it is stored in localStorage but the unlock UI only replays it while a wallet is connected, and it is cleared on disconnect

**Access follows the wallet, not the device.** Hosted Nibshare/Subblogs retain a paid entitlement backed by the receipt while the resource remains reachable and current policy permits access. Share expiry, revoke/ban and invite-only restrictions still apply. Generic SDK integrations must provide their own persistent entitlement store. Reconnect the wallet on any device and the server re-verifies the receipt and ban status and re-serves the content — no re-pay. Disconnecting relocks the content (payload torn down, proof cleared).

For custom flows, use `checkResourceAccess(resource, options)` directly.

---

## 6. Server-Side Pay Route (Alternative to Browser Wallet)

For testing or server-driven payments, add a pay endpoint that uses the server's buyer key:

```ts
import { nibgateServer } from './nibgate-resource'

export function POST({ request }) {
  return nibgateServer.payAndUnlockResponse(request, resource, {
    origin: process.env.NIBGATE_SITE_ORIGIN,
    accessPath: '/api/nibgate/access'
  })
}
```

This requires `NIBGATE_BUYER_PRIVATE_KEY` to be set. In production, prefer browser wallet checkout so the user pays from their own wallet.

---

## 7. Admin Panel (Manage Gating Settings From Your Site)

The SDK ships a ready-to-use admin panel that lets creators manage gating, pricing, and settings from the creator's own site instead of editing code.

### Setup

```ts
import { createFileStore, createAdminApi } from '@nibgate/sdk/server'
import express from 'express'

const store = createFileStore({ path: './nibgate-settings.json' })
const admin = createAdminApi({ store })

const app = express()
app.use(express.json())

// Mount admin API routes (list, get, update, delete resource settings)
app.use(admin.router(express))

// Or use individual handlers for non-Express frameworks:
// app.get('/admin/nibgate/resources', admin.handleList)
// app.post('/admin/nibgate/resources/:id', admin.handleUpdate)
```

### Postgres store

For production (Render, Railway, Fly), use the Postgres store instead of the file store (file changes get wiped on redeploy):

```ts
import pkg from 'pg'
const { Pool } = pkg

const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const store = createPostgresStore(pool, { table: 'nibgate_settings' })
const admin = createAdminApi({ store })
```

The Postgres store auto-creates the table. It uses JSONB for settings and `ON CONFLICT` upserts. Works with any `pg` Pool-like interface.

### Protect the admin route

Pass an `authorize` function to `createAdminApi`:

```ts
const admin = createAdminApi({
  store,
  authorize: (req) => {
    // Check session, API key, admin token, etc.
    return Boolean(process.env.ADMIN_KEY) && req.headers['x-admin-key'] === process.env.ADMIN_KEY
  }
})
```

### Using settings in your resource definitions

The admin panel stores per-resource settings. Build resource objects from stored settings:

```ts
import { settingsToAccessPolicy, settingsToUnlockPolicy } from '@nibgate/sdk/server'

function getResource(id) {
  const settings = store.get(id) || {}
  return {
    id,
    title: settings.title || post.title,
    type: settings.type || 'article',
    price: settings.price || '0.01',
    currency: settings.currency || 'USDC',
    recipient: settings.recipient || process.env.NIBGATE_SELLER_ADDRESS,
    access: settingsToAccessPolicy(settings),
    unlock: settingsToUnlockPolicy(settings),
    ratingsEnabled: settings.ratingsEnabled !== false
  }
}
```

### Admin page

Visit `/admin/nibgate` in your browser. The page shows all configured resources with controls for:
- Human access (free / paid / blocked)
- Agent access (free / paid / blocked)
- Unlock mode
- Payment rail
- Price & currency
- Recipient wallet
- Ratings toggle
- Content type
- Publish to discovery toggle

---

## 8. Payments (Gateway and direct transfer rails on Arc mainnet and testnet)

For v1, treat `unlock.mode: 'one_time'` with the Gateway rail or the direct USDC transfer rail as the production path. Payments settle in USDC on the configured Arc network: mainnet (5042) or testnet (5042002). Set NIBGATE_NETWORK=mainnet on the server and NEXT_PUBLIC_NIBGATE_NETWORK=mainnet before building a production React client; the wallet package defaults to testnet when neither variable is set. Keep the Hub API, payment network, and wallet target on the same network. The testnet examples below are for staging.

### How payment works

1. Browser requests access → server returns 402 challenge with creator's wallet, price, network
2. Browser prompts user to sign a Gateway payment payload (EIP-712 typed data via viem's `walletClient.signTypedData()` through MetaMask)
3. Signed payload sent to server as `payment-signature` header
4. Server verifies with `createGatewayMiddleware` from `@circle-fin/x402-batching/server`
5. On success, server creates an HMAC-signed unlock token and returns it directly with the **premium content body**
6. Browser stores the token as `x-nibgate-payment-proof` — a wallet-bound cache, never a device-only pass
7. On return visits, the browser sends the stored proof (only while a wallet is connected) → server re-verifies the wallet's receipt and ban status → returns content without payment; reconnect on any device re-serves the same content, no re-pay

> **⚠️ Important:** Use viem's `createWalletClient` + `custom(selectedProvider)` transport instead of raw `eth_signTypedData_v4`. This ensures consistent EIP-712 hashing across wallets (MetaMask, Rabby, etc.). Raw `eth_signTypedData_v4` can produce hashes that don't match what the server expects.


### Browser wallet selection and recovery

For `createEvmGatewayUnlock`, pass the explicitly selected `provider` or a
`getProvider()` callback. Explicit null does not fall back to `window.ethereum`.
Choose `network` exactly: `eip155:5042` for Arc mainnet or `eip155:5042002` for
testnet. The controller checks account, chain, RPC health, and signing-domain
chain before requesting a signature. An unrelated-network Gateway acceptance
is rejected, never used as a fallback.

A lost verification response does not authorize another payment. The same-tab
session journal retains the original proof; `retryVerification()` is an explicit
check of that proof, while fresh `unlock()` is blocked. `getPendingPayment()`
reports recovery state; `clear()` keeps signed/submitted proofs. Default UIs
expose retry and repair separately. `repairConnection()` only requests official
RPC settings and checks liveness; it never resubmits payment. Gateway deposits
retain their hashes and require successful matching approval and deposit
receipts. An unknown send without a hash remains blocked for wallet-activity
review. Closing a panel cannot undo a wallet request already submitted.

### Direct rail (USDC transfer)

The second rail sends USDC straight from the buyer's wallet to the creator's receiver — no Gateway facilitator. The 402 challenge carries `paymentRail: 'transfer'` and the seller's `payTo`. The browser broadcasts the transfer, then retries the access route with the tx hash as the `x-nibgate-transfer-tx` header; the server verifies the mined receipt on-chain (USDC `Transfer` log crediting the seller for at least the price) before minting the unlock proof.

**Ownership proof (required by default).** A broadcast transfer is public chain data, so the server also requires proof that *the paying wallet* owns the txHash and meant it for *this* resource: an EIP-191 signature over

```
Nibgate transfer ownership
tx:<txHash lowercased>
resource:<resource path or url>
```

sent on the retry as the `x-nibgate-tx-owner` header. A mismatched signer returns `transfer-owner-mismatch`; a missing header returns `transfer-ownership-proof-required`. Self-hosters can opt out with `NIBGATE_TX_OWNER_PROOF_OPTIONAL=true` (or `txOwnerProofOptional: true`). On Nibgate-hosted surfaces each txHash is additionally claimed single-use per content id — replaying it against another post or site is rejected (`txhash-claimed-elsewhere`).

```ts
import { payWithTransfer, createTransferCheckout } from '@nibgate/sdk'
import { encodeFunctionData, parseUnits, parseAbi } from 'viem'

const usdcTransferAbi = parseAbi(['function transfer(address to, uint256 amount) returns (bool)'])

await payWithTransfer(resource, {
  accessPath: '/api/nibgate/access',
  checkout: createTransferCheckout(resource, {
    // Broadcast the transfer from the connected wallet; return the txHash.
    sendTransfer: async ({ recipient, amount }) =>
      ({ txHash: await walletClient.sendTransaction({
        account,
        to: '0x3600000000000000000000000000000000000000', // USDC token contract
        value: 0n,
        data: encodeFunctionData({ abi: usdcTransferAbi, functionName: 'transfer', args: [recipient, parseUnits(amount, 6)] })
      }) })
  }).pay,
  signOwnership: ({ message }) => walletClient.signMessage({ account, message })
})
```

The resulting receipt is stored with `paymentProvider: 'direct-transfer'` and keyed by the transfer `txHash`. Set the rail per resource with `paymentRail: 'transfer'`, per request with `?rail=transfer`, or via `NIBGATE_PAYMENT_MODE=transfer`.

### Before testing locally

In a Nibgate repository checkout with its workspace dependencies installed, these staging commands deposit testnet USDC and read the Gateway balance. The in-repository CLI is not a separately published npm release; external integrations can use the Circle Agent Stack commands below.

```bash
pnpm --filter @nibgate/cli exec node bin/nibgate.js deposit 10
```

Check your Gateway balance:

```bash
pnpm --filter @nibgate/cli exec node bin/nibgate.js balance
```

### Env vars

```bash
NIBGATE_PAYMENT_MODE=circle-gateway
NIBGATE_PAYMENT_NETWORK=eip155:5042002
NIBGATE_SELLER_ADDRESS=0xCreatorReceiver
NIBGATE_BUYER_PRIVATE_KEY=0x...   # Only for local testing
```

### Important

- A Gateway/x402 payment signature is a valid payment proof. A normal wallet message signature (`eth_sign` / `personal_sign`) is NOT payment — do not accept it.
- Use `NIBGATE_BUYER_PRIVATE_KEY` only in local demos. In production, the visitor or agent wallet signs the payment challenge.
- The `NIBGATE_SECRET` used by `createNibgateServer()` MUST match the secret used by your access and pay routes. If they diverge, unlock tokens signed by one route fail verification on the other. Set once, use everywhere.

### For plain HTML/JS sites without a bundler

For plain HTML/JS sites without a bundler, use the pre-built CDN bundle:

```html
<script src="https://unpkg.com/@nibgate/sdk/dist/nibgate.min.js"></script>
<script>
  const { nibgate, createGate, mountRatingUI, createEvmGatewayUnlock, createOnchainRating } = Nibgate
</script>
```

The bundle includes all dependencies (viem, x402) compiled into a single IIFE script. All browser exports are on the `Nibgate` global. No esbuild step needed.

If you prefer to build your own bundle:

```bash
esbuild --bundle --format=iife --global-name=Nibgate \
  --outfile=nibgate-bundle.js \
  node_modules/@nibgate/sdk/src/browser/index.js
```

### Smart Contract Wallets

Some smart contract wallets (Gnosis Safe, Argent, etc.) may not support `eth_signTypedData_v4` required by the Gateway payment signing. Test with an EOA (MetaMask, Rabby, Coinbase Wallet) first. If your users primarily use smart contract wallets, use the server-side pay route (`payAndUnlockResponse`) with `NIBGATE_BUYER_PRIVATE_KEY` instead.

---

## 9. AI Agent Content Discovery & Purchase (x402)

AI agents can discover and purchase content on Nibgate through the same x402 protocol used by browser wallets — no browser, HTML, or widget needed. Full payer guide: `https://nibgate.xyz/discovery.md`.

All settled payments are recorded server-side at the payment layer — receipts, metrics, and the public ledger treat machine payers exactly like browser users, with no client-side event reporting required.

### Agent Flow

1. **Discover** — agent hits the Hub's explore API or polls creator `/nibgate.json` manifests:

   ```bash
   GET https://api.nibgate.xyz/hub/explore/content?type=article&limit=100
   Accept: application/json
   ```

   Response includes content with `price`, `currency`, `access` policy, `websiteDomain`, and the creator's `recipientWallet`.

   Standalone share links work without any manifest: `GET https://api.nibgate.xyz/ns/{slug}` — free shares return the body directly; paid shares return a 402 challenge.

2. **Pay** — easiest with the Circle Agent Stack CLI (wallet, policies, Gateway deposits handled for you):

   ```bash
   circle services inspect "https://creator.example/api/nibgate/access?path=my-article" --output json
   circle services pay   "https://creator.example/api/nibgate/access?path=my-article" \
     --address <agent-wallet> --chain ARC-TESTNET --output json
   ```

   Nanopayments spend the **Gateway balance** (not the on-chain balance); if you hit
   `Insufficient Gateway balance`, deposit first:
   `circle gateway deposit --address <addr> --chain ARC-TESTNET --amount 1 --method direct`.

3. **Access** — or hit the content's access URL identifying as an agent:

   ```bash
   GET https://creator.example/api/nibgate/access?path=my-article
   x-nibgate-actor: agent
   Accept: application/json
   ```

4. **402 Challenge** — if payment is required, server returns HTTP 402 with a `PAYMENT-REQUIRED` header containing a base64-encoded x402 v2 challenge with the `extra.verifyingContract` field (Gateway Wallet address).

5. **Sign Payment** — agent signs an EIP-3009 `TransferWithAuthorization` using an EVM wallet with USDC in Circle Gateway:

   ```ts
   import { BatchEvmScheme } from '@circle-fin/x402-batching/client'
   import { privateKeyToAccount } from 'viem/accounts'

   const wallet = privateKeyToAccount('0x...')
   const scheme = new BatchEvmScheme(wallet)
   const payload = await scheme.createPaymentPayload(2, gatewayOption)
   ```

6. **Retry** — agent retries the request with the signed payment:

   ```bash
   GET https://creator.example/api/nibgate/access?path=my-article
   payment-signature: <base64 payload>
   Accept: application/json
   ```

7. **Unlocked** — server returns `200` with `{ ok: true, unlockProof, payment, resource }`. Re-requesting a paid resource returns it again without a new charge.

### Headless GatewayClient (Full Auto Flow)

The `GatewayClient` from `@circle-fin/x402-batching/client` handles steps 3-5 automatically:

```ts
import { GatewayClient } from '@circle-fin/x402-batching/client'

const agent = new GatewayClient({
  chain: 'arcTestnet',
  privateKey: '0x...',
  rpcUrl: 'https://rpc.testnet.arc.io'
})

const result = await agent.pay('https://creator.example/api/nibgate/access?path=my-article', {
  headers: { 'x-nibgate-actor': 'agent' }
})
// { data: { ok: true, unlockProof, ... }, formattedAmount: '0.01', transaction: '0x...' }
```

### Agent Wallet Requirements

- **USDC in Circle Gateway** on Arc Testnet (chain ID 5042002) — `agent.deposit('10')` moves USDC from wallet → Gateway
- **Gas** — on Arc Testnet the native token IS USDC, so the wallet needs USDC for both Gateway deposits and transaction fees
- **Private key** — must be accessible to the agent process (env var, secure store, or derived from a mnemonic)

### Agent Identification

Agents identify themselves via headers (in priority order):

| Header | Value | Detected When |
|--------|-------|---------------|
| `x-nibgate-actor` | `agent` | Explicit agent declaration |
| `Accept` + `x402` | `application/json` + `true` | Accept header includes JSON and x402 is set |
| `User-Agent` | bot pattern | Matches `/bot|crawler|spider|agent|llm|gpt|claude|perplexity|anthropic|openai|mistral|gemini|firecrawl/i` |

### Agent-Specific Pricing

Creators can set different prices for humans and agents in the resource's `access` policy:

```ts
const resource = {
  access: { humans: 'free', agents: 'paid' },    // agents pay, humans free
  price: '0.002',
  // or
  access: { humans: 'paid', agents: 'paid' },    // both pay
  // or
  access: { humans: 'paid', agents: 'blocked' },  // agents blocked entirely
}
```

### Stress Test Script

An internal stress test script (not shipped with the npm package) simulates N agents discovering content from the Hub and purchasing via x402. Deriving deterministic wallets from a mnemonic, funding agents from a master wallet, and driving `GatewayClient.pay()` per agent is all public API shown above — the script only orchestrates those calls in a loop.

---

## 10. Onchain Reputation & Ratings

After a verified unlock, users can rate content onchain through the reputation contract for the selected network. The SDK reputation constants describe the testnet deployment. For mainnet, use the contract returned by the mainnet Hub's ratings prepare endpoint and explicitly configure matching mainnet wallet and RPC settings.

### How it works

1. User unlocks content (has a valid unlock proof)
2. rating UI calls `rateContentOnchain(resource, { rating, paymentId })`
3. User signs an onchain transaction with their rating
4. Rating is submitted to the hub with its on-chain proof and indexed
5. Rating contributes to content/creator reputation scores on Explore and leaderboards

Read ratings back via `GET /hub/reputation/ratings/stats?contentId=` (hub-authoritative average 1–5 + count). Never recompute the content hash locally — stored url/domain forms drift across stacks and renames, which silently orphans the lookup.

### Browser rating UI

```ts
import { createOnchainRating } from '@nibgate/sdk'

createOnchainRating(resource, {
  ratingButtons: '[data-rating]',
  status: '#rating-status',
  payment: latestPayment  // from the unlock result
})
```

Or buttons with `data-nibgate-rating-value="4"` attributes. Ratings are 1-5.

### Reputation requirements

- Only ratings from wallets that have an unlock receipt for the same content are counted
- Ratings are onchain (requires gas on the selected Arc network)
- Content reputation (0-5 stars) and creator reputation (0-100 score) update after indexing

---

## 11. Hardcoded Or File-Based Content

Hardcoded content, MDX files, Markdown files, static JSON files, and repo-local media can be gated when the protected payload stays server-side until access is allowed.

**Safe pattern:**
1. Keep the public teaser, title, price, and metadata in the page
2. Keep the full paid body in a server-only module, route handler, CMS fetch, DB row, private file, or signed URL endpoint
3. Run `accessResponse()`, `accessFor()`, middleware, or a framework guard before returning the full payload

**Unsafe pattern (will not protect your content):**
- Rendering the paid body into public HTML and hiding it with CSS
- Bundling the paid body into client JavaScript
- Putting private text into `nibgate.json`, JSON-LD, meta tags, page source, or static exports
- Exposing an open API route that returns the protected body without checking payment proof

---

## 12. Required Env Vars

```bash
NIBGATE_SITE_ORIGIN=https://creator.example
NIBGATE_SITE_ID=site_...             # Hub website UUID — required for emitHubEvent to report unlocks
NIBGATE_SITE_TOKEN=ngv_...           # Hub website verify token — required for emitHubEvent
NIBGATE_API_BASE=https://api.nibgate.xyz # Hub API base — required for emitHubEvent
NIBGATE_SECRET=server_only_unlock_secret
NIBGATE_PAYMENT_MODE=circle-gateway
NIBGATE_PAYMENT_NETWORK=eip155:5042002
NIBGATE_SELLER_ADDRESS=0xCreatorReceiver
```

**Without `NIBGATE_SITE_ID` and `NIBGATE_SITE_TOKEN`**, `accessResponse()` still processes x402 payments and returns unlock proofs, but `payment_completed` and `unlock_completed` events are NOT sent to the Hub. Unlock counts, revenue, and volume on Explore and leaderboards will not update. Set these to enable automatic event reporting after every successful gateway purchase.

Only expose values with `NEXT_PUBLIC_` or client-side env names when they are intentionally public (site id, public token, API base, reputation contract address). Never ship `NIBGATE_SECRET`, private keys, R2 credentials, Resend keys, database URLs, or privileged payment credentials to browser code.

---

## 13. Common Gotchas

- **Widget is only for analytics.** It does not pop up wallets or handle checkout. Use the SDK browser helpers for that.
- **accessPath is literal.** The SDK does not append `?id=` or route params. Include identifiers in the path or query string manually.
- **Custom backends must handle `payment-signature` header.** If you build your own access route without `accessResponse()`, your route must detect `payment-signature` header and return 200 on valid proof, not 402.
- **Cookie `[object Object]` bug.** The unlock proof must be a string (the token from `createUnlockToken`), not an object. `storePaymentProof` now handles both, but ensure your server returns `unlockProof` as a string.
- **Secrets must match.** `NIBGATE_SECRET` must be the same in `createNibgateServer()`, the access route, and the pay route. Mismatch causes unlock tokens to fail verification silently.
- **Unlock events need three env vars.** `accessResponse()` emits `payment_completed` and `unlock_completed` to the Hub after gateway payment, but only if `NIBGATE_SITE_ID`, `NIBGATE_SITE_TOKEN`, and `NIBGATE_API_BASE` are set. Without them, the x402 payment still works and the unlock proof is returned, but unlock counts and volume on Explore will not update.
- **Smart contract wallets** may not support `eth_signTypedData_v4`. Use an EOA or the server-side pay route.
- **Wallet extension conflicts** can cause signature corruption if multiple wallets are installed. Advise users to disable other wallet extensions when testing.
- **Deposit before testing locally.** Run `pnpm --filter @nibgate/cli exec node bin/nibgate.js deposit 10` to fund the Gateway facilitator balance.

---

## 14. Webhooks (Server-to-Server Notifications)

Receive notifications when payments, unlocks, or ratings happen — without polling.

### Setup

```ts
import { createWebhookManager, createWebhookApi } from '@nibgate/sdk/server'
import express from 'express'

const manager = createWebhookManager({
  webhookUrl: 'https://my-server.com/nibgate-webhook',
  webhookSecret: process.env.NIBGATE_WEBHOOK_SECRET
})

// Mount admin API for subscribing webhooks
const webhookApi = createWebhookApi(manager, { adminKey: process.env.ADMIN_KEY })
app.use(webhookApi.router(express))

// Emit events from your code
await manager.emit('payment.completed', { contentId: 'post-1', amount: '0.01', payer: '0x...' })
await manager.emit('content.unlocked', { contentId: 'post-1', actor: 'human' })
await manager.emit('content.rated', { contentId: 'post-1', rating: 4, rater: '0x...' })
```

### Webhook payload format

```json
{
  "event": "payment.completed",
  "timestamp": "2026-07-09T12:00:00.000Z",
  "data": { "contentId": "post-1", "amount": "0.01" }
}
```

The `x-nibgate-webhook-signature` header contains an HMAC-SHA256 signature of the body using your webhook secret. Verify it on the receiving end to confirm the webhook is authentic.

Events emitted automatically by `createNibgateServer`: `payment_completed`, `unlock_completed`.

---

## 15. Never Do These

- Do not hide paid HTML with CSS or client state after rendering it publicly
- Do not fake payment IDs, receipts, unlocks, or successful access
- Do not replace Gateway/x402 payment signatures with ordinary wallet message signatures
- Do not store backend secrets in browser env vars
- Do not let mutable titles or slugs be the only resource identity
- Do not expose private content in `nibgate.json`, meta tags, JSON-LD, page source, or static builds

---

## 16. Security: Content Must Never Be in HTML

This is the single most important rule for paid content:

**❌ Wrong (insecure):**
```html
<div data-nibgate-premium>
  <div data-nibgate-unlocked hidden>
    <!-- Premium content visible in page source -->
  </div>
</div>
```

**✅ Correct (secure):**
```
1. Backend: Public endpoint returns post WITHOUT body for paid content
2. Frontend: Shows unlock button (no content in HTML)
3. After payment: Backend returns content ONLY via the protected access endpoint
4. Frontend: Renders content from the server response (never in source)
```

### Architecture

```
[Public endpoint]              [Protected endpoint]
GET /api/posts/:slug           GET /api/nibgate/access
  ├── free post → return body    ├── Has valid proof → return body + unlock token
  └── paid post → return teaser  └── No proof → 402 challenge
       (NO body in response)          (browser signs, retries)
```

### Frontend pattern (React)

```tsx
function NibgateUnlock({ resource }) {
  const [content, setContent] = useState('');
  const { address, isConnected } = useAppKitAccount(); // connected wallet

  // On mount (and when the wallet connects): check access BY WALLET, so the
  // server re-issues content from the wallet's paid receipt (no re-pay).
  useEffect(() => {
    if (!address) return setContent(''); // no wallet → nothing on this device
    fetch(`/api/nibgate/access?wallet=${address}`, { headers: { accept: 'application/json' } })
      .then(r => r.json()).then(data => {
        if (data?.ok) setContent(data.content);
      });
  }, [address]);

  // Disconnect tears down the body — the device holds nothing.
  useEffect(() => { if (!isConnected) setContent(''); }, [isConnected]);

  if (content) return <div>{content}</div>;   // Paid + wallet verified → show content
  return <button onClick={handleConnectAndUnlock}>Connect & unlock</button>;  // Need wallet/payment
}
```

### Backend pattern (Express)

```js
// Public — NEVER returns body for paid content
app.get('/posts/:slug', async (req, res) => {
  const post = await db.post.findUnique({ where: { slug } });
  if (post.price) {
    const { body, ...teaser } = post;
    return res.json({ post: { ...teaser, isLocked: true } }); // NO body
  }
  res.json({ post }); // free post → body OK
});

// Protected — returns body after proof verification
app.get('/nibgate/access', async (req, res) => {
  const access = server.accessFor(request, resource);
  if (access.allowed) {
    const post = await db.post.findUnique({ where: { slug } });
    return res.json({ ok: true, content: post.body }); // body ONLY here
  }
  // ... 402 flow
});
```

### Key rules

- **Never** include paid content in the initial HTML, even with `hidden` or `display:none`
- **Never** return the body from a public API endpoint for paid posts
- **Always** serve content through the protected access endpoint after proof verification
- **Access is wallet-bound** — a device-stored proof never grants content on its own; require the connected wallet and let the server re-verify the receipt (reconnect → no re-pay, disconnect → relock)
- **Always** use viem's `walletClient.signTypedData()` with MetaMask, not raw `eth_signTypedData_v4`

## 17. Framework Notes

- **Next.js**: Use route handlers for `/nibgate.json` and `/api/nibgate/access`. Protect content before rendering paid payloads. Use `server-only` for private modules.
- **Express/NestJS**: Use middleware, controllers, or guards around protected routes. Mount the admin router at `/admin/nibgate`.

  For a one-line payment verification middleware:

  ```ts
  import { verifyPayment } from '@nibgate/sdk/server'

  app.get('/api/content/:id', verifyPayment({ resource: { id: 'my-post', price: '0.01' } }), (req, res) => {
    // req.nibgate.verified is true
    res.json({ content: 'Protected content here' })
  })
  ```

  The middleware checks `x-nibgate-payment-proof` (existing unlock) and `payment-signature` (new Gateway payment). Returns 402 with a challenge if neither is present.
- **Astro/SvelteKit/Remix**: Use SSR/server routes or endpoints. Plain static builds need a protected API or signed URL for private payloads.
- **CMS apps**: Save Nibgate resource settings beside each content record. Use `settingsToAccessPolicy()` and `settingsToUnlockPolicy()` to convert stored settings to resource shapes. Mount the admin panel for easy management.
- **Static/vanilla HTML/JS**: Compile the SDK into a bundle with esbuild (see section 8). Use `gate()` and `nibgate` from the global `Nibgate` namespace.

## 18. Nib Tips (tips, holding, refunds)

Nib Tip lets anyone tip the creator of a page. Tips are additive revenue � no
locked content, the receipt is the product.

- **Creator already on Nibgate (resolved):** the tip settles instantly to your
  payee (fee wallet when hosted, else your wallet). Nothing to claim.
- **Creator unresolved / external page:** the tip is held in a **no-key,
  per-domain onchain box** (deterministic address, factory owner/keeper authorizes exits) until the
  site owner claims. Claiming requires site ownership (your `verifyToken`) and
  releases net to the creator with the held-tier fee.

Browser helpers (`@nibgate/sdk/browser`):

```ts
import { tipContent, holdTipContent, refundTip } from '@nibgate/sdk/browser'

// Resolves the creator and tips them; falls back to the domain holding box
// automatically when the creator cannot be resolved.
await tipContent({ contentUrl, title, amount: '0.5', signer, hubApi })

// Explicit hold for an external/unclaimed creator.
await holdTipContent({ contentUrl, amount: '0.5', signer, hubApi })

// Payer refund of an unclaimed held tip (full amount, no fee).
// The helper creates a one-shot network/domain/payer/exact-amount authorization. Both EOA
// personal_sign and deployed ERC-1271 smart-contract wallets are supported.
await refundTip({ domain, signer, hubApi, network: 'testnet' })
```

Server helpers (`@nibgate/sdk/server`):

```ts
import { holdingDeployment, buildHoldingRefund, submitHoldingRefund } from '@nibgate/sdk/server'

const dep = holdingDeployment('mainnet') // or 'testnet'
const call = buildHoldingRefund({ domain, payer, amountUsdc, factoryAddress: dep.factoryAddress })
await submitHoldingRefund(call, { privateKey, rpcUrl, chainId: dep.chainId })
```

Hub endpoints (canonical bare form): `POST /hub/tips/challenge`,
`POST /hub/tips/verify`, `POST /hub/tips/hold`, `GET /hub/tips/held`,
`POST /hub/tips/claim`, `POST /hub/tips/refund`. See
https://nibgate.xyz/discovery.md for the payer/agent view.

Funded held tips appear in the public ledger with `status: "held"` while
awaiting a claim. They do not count as completed tips; a payer refund appears as
a negative tip entry so the refunded amount nets out.

Creators can check pending tips for any domain, including an external site that
is not registered with Nibgate, using `GET /hub/tips/held?domain=<domain>`.
Use the API host for the intended network: the mainnet API and testnet API have
separate tip records. The public Pending tip checker searches both networks and
links to the matching network; claiming requires a connected, verified site and
is available from its creator-dashboard site card. The checker also links to a
claim guide at https://nibgate.xyz/pending-tips/claim.
Metadata depends on caller-supplied fields and matching indexed content; JEV is
not a guaranteed metadata or cover-image generator for arbitrary external pages.

Fee policy: resolved/self-hosted member tips 100 bps (1%); non-member tips that
are held then claimed 500 bps (5%). Payer refunds of unclaimed holds are always
full amount with no fee. Refunds do **not** apply once a tip has settled or been
claimed.

## Payment confirmation and safe retries

SDK 0.4.41 and wallet 0.4.32 support Arc mainnet (`eip155:5042`,
`https://api.nibgate.xyz`) and Arc testnet (`eip155:5042002`,
`https://testnet-api.nibgate.xyz`). Install `@nibgate/sdk@latest` and
`@nibgate/wallet@latest`. Configure the application network and matching Hub
together; an npm tag does not configure the network automatically.

A wallet transaction hash or Gateway signature means a proof was submitted,
not that the Hub recorded a successful tip. A standalone SDK tip without a Hub
verification response returns `status: 'pending'`. Only a successful Hub response
confirms `settled` (resolved tip) or `holdStatus: 'held'` (funded hold).

On an interrupted response, keep the original proof and exact request fields,
including the network, amount, payee and content metadata. Retry verification
with that same proof; never send a second payment to repair a missing receipt.
The matching completed proof returns HTTP 200 with `replayed: true` and its
existing receipt, without another Tip row or another Gateway settlement. Reusing a proof with different request fields
is rejected. Legacy proofs without the new verified binding fail closed.

HTTP 409 `payment-proof-already-used` means the proof is reserved or recorded
with a different or unverifiable binding. HTTP 409 `payment-verification-pending`
means its durable reservation has not completed. Stop automatic payment attempts;
inspect the existing transaction/receipt and reconcile ambiguous settlement with
the operator. Pending reservations are not automatically cleared or settled
again. A transport or server error after submission is not evidence that no
payment occurred. An unconfirmed direct-transfer receipt returns HTTP 402;
retrying its read-only verification is safe. This change uses the existing
`PaymentTxClaim` table, with no new schema migration. It does not recover a
facilitator settlement lost before database completion or add held-tip
claim/refund recovery.

### SDK integration changes

- Recipient-resolution HTTP, network and JSON failures stop before payment; they
  do not silently select the hold path.
- The challenge network and payee must match the selected flow. Configure the
  testnet Hub and network together; do not reuse mainnet proofs.
- Gateway adapters receive `pay({ challenge })`; their `paymentSignature` is sent
  on the Gateway rail. A direct transaction hash uses the transfer rail.
- Verification errors retain available `txHash`, `paymentSignature` and
  `paymentRail` for recovery. Treat payment signatures as sensitive proof material;
  do not expose them in analytics or public logs.
- The standalone browser bundle does not require a CommonJS `require` runtime.
- `createAdminApi` denies the admin page and all API operations unless an explicit
  `authorize(req)` callback returns a truthy result. Async callbacks are awaited.
  Use server-validated authentication and return false when credentials are absent.

### React pending-proof recovery

`useNibgateTip()` retains the submitted proof in memory while Hub confirmation is
incomplete. `tip()` and `retryVerification()` reuse that exact proof rather than
send another payment. The pending state is a component `useRef`, not durable
storage: a reload, unmount or remount loses this protection. Before paying again
after navigation, inspect the original transaction and existing Hub receipt.

`payment-proof-already-used` blocks the local attempt so the existing receipt can
be checked. `payment-verification-pending` keeps the proof and asks for
reconciliation; retrying verification remains possible, but cannot clear or
resettle an ambiguous backend reservation. The hook reports a verified receipt
only after a successful Hub response. A later UI callback failure must not prompt
another payment. Pending proof material must not be published or sent to analytics.

## React dependency identity

For a React host, the validated companion stack is Reown AppKit/adapter
`1.8.21`, wagmi `3.6.21`, React/ReactDOM `19.2.4`, and React Query `5.101.2`.
Keep React and the wallet provider in one module graph. The application root
should pin transitive wallet integrations with these npm `overrides` (or the
same keys under `pnpm.overrides` when using pnpm 10):

```json
{
  "overrides": {
    "viem": "2.54.1",
    "@wagmi/connectors": "8.0.20",
    "@coinbase/cdp-sdk": "1.51.2"
  }
}
```

This keeps fresh installations on the tested connector/core and x402 peer graph.

Google wallet compatibility: wallet 0.4.32 checks already-authorized accounts before SIWE, closes the AppKit sheet before the embedded signature prompt, and configures Reown's Arc RPC routes on both networks. This avoids overlapping dialogs and Magic iframe restrictions on direct Arc RPC hosts. Nibgate's application RPC proxies remain read-only; wallet signing still requires user approval.

### Held-tip authorization boundary

A holding box has no EOA private key. Its factory owner/keeper can authorize release and refund with recipient arguments; website ownership and original-payer checks are enforced by the Hub, not independently by the wallet contract. Keep the original payment evidence and reconcile an uncertain relay outcome before submitting another relay. See https://docs.nibgate.xyz/tipping/contracts.
