Skip to main content

Paywalls

6 operations under /v1/paywalls (GET POST PATCH DELETE): parameters, status codes, refusal bodies, and the same request in curl, HTTP, Python and TypeScript.

21 min read
View MarkdownEdit on GitHub

Base URL: https://api.codespar.dev

Every operation below requires a Bearer token. See Authentication.

REST API for Gate, the x402 gateway. A paywall charges agents per call for an endpoint you own, settling in USDC over x402.

Base URL: https://api.codespar.dev

All endpoints require authentication. See Authentication. Endpoints marked admin require a bearer api key OR service auth with an x-codespar-user header for an account admin/owner. Paywalls are project-scoped.

Paywall object

FieldTypeDescription
idstringPaywall ID, pw_<...>
project_idstringOwning project
slugstringURL slug; global namespace. /^[a-z0-9_-]+$/
namestringDisplay name, max 128
upstream_urlstringWhere paid calls are proxied
pricestringUSDC per call, decimal (min "0.01")
price_atomicstringSame price in atomic USDC (6 dp)
currencystringUSDC
pricing_modelstringflat (default), tiered, dynamic, or metered
payto_kind"provisioned" | "byo"Where settlement lands
payto_addressstring | nullThe 0x address for byo
consumer_idstring | nullThe consumer whose wallet receives for provisioned
activebooleanPaused paywalls return 402 with no settlement
gateway_urlstringhttps://gw.codespar.dev/<slug>
created_atstringISO 8601

Create a paywall

POST /v1/paywalls (admin)

Body

{
  "slug": "market-data",
  "name": "Market data API",
  "upstream_url": "https://api.yourservice.com/quote",
  "price": "0.01",
  "currency": "USDC",
  "consumer_id": "your-consumer",
  "payto": { "kind": "provisioned" }
}
  • payto.kind: "provisioned" — settles to the CodeSpar wallet of consumer_id (required for this kind).
  • payto.kind: "byo" — settles to payto.address (a 0x EVM address).

Response — 201 Created with the paywall object.

Pricing models

pricing_model selects how the per-call price is resolved:

ValuePrice per callStatus
flatprice, the same on every call. The defaultLive
tieredprice steps down as the paywall's settled count crosses configured thresholdsLive
dynamicResolved per call from the paywall's dynamic ruleLive
meteredA ceiling is charged up front, then the actual cost is metered from the upstream response and the difference is refunded on-chain. See MeterBeta

Gateway protocol

The 402 challenge is delivered base64-encoded in the PAYMENT-REQUIRED response header (x402Version 2). The JSON body of the 402 is a human-readable hint: clients read the header, not the body.

Payment goes back in the PAYMENT-SIGNATURE request header. X-PAYMENT is still accepted for x402 v1 compatibility, and a v2 client may send both.

The settlement result comes back in the PAYMENT-RESPONSE response header (also mirrored to X-PAYMENT-RESPONSE for v1 clients). Both are listed in Access-Control-Expose-Headers, so a browser client can read them.

An optional Idempotency-Key request header makes a paywall call settle at most once, even across re-signed retries. A retry that arrives while the first is still in flight gets 409 idempotency_in_progress; a completed call replays the stored response with Idempotent-Replay: true.

List paywalls

GET /v1/paywalls

Response

{ "paywalls": [/* Paywall */] }

Get a paywall

GET /v1/paywalls/:id — the paywall object, or 404 paywall_not_found.

Update a paywall

PATCH /v1/paywalls/:id (admin)

Body (at least one field)

{ "name": "...", "upstream_url": "...", "price": "0.02", "active": false }

Set active: false to pause (callers get 402, nothing settles); true to resume.

Delete a paywall

DELETE /v1/paywalls/:id (admin) — 204 No Content. Paywalls are config, not audit data; settled receipts are retained independently.

Earnings

GET /v1/paywalls/:id/stats

Read-only aggregate over the paywall's settled payments.

Response

{
  "paywall_id": "pw_...",
  "slug": "market-data",
  "currency": "USDC",
  "settled_count": 128,
  "gross_atomic": "1280000",
  "gross": "1.28",
  "refunded_atomic": "0",
  "refunded": "0.00",
  "net_atomic": "1280000",
  "net": "1.28",
  "last_settled_at": "2026-07-10T21:04:11Z"
}

gross is what payers authorized, refunded is what went back to them, and net is what the paywall kept. On flat, tiered, and dynamic paywalls there are no refunds, so gross equals net. On a metered paywall gross is the sum of the signed ceilings and net is the actual metered revenue, so the two read as ceiling versus actual.

Errors

HTTPcodewhen
400invalid_bodybody failed validation
403forbiddennon-admin caller on an admin endpoint
404paywall_not_foundunknown id in this project
409slug_conflictthe slug is already taken

See also

  • Monetized MCP servers: per-tool pricing for an MCP server you already run, managed on /v1/mcp-servers and served at gw.codespar.dev/mcp/<slug>.
  • Getting discovered: the public manifest at gw.codespar.dev/.well-known/x402 that lists your active paywalls with their x402 terms.

Every operation, from the spec

Generated from the published OpenAPI document, so it never drifts from what the API actually serves. The section above is written by hand and carries what a schema cannot: the object model, field rules, and the order to call things in.

GET /v1/paywalls

GEThttps://api.codespar.dev/v1/paywalls

List paywalls

Every paywall of the calling credential's org AND project, newest first by creation time.

There is no pagination and no filter: the query carries no LIMIT and no predicate beyond the two tenancy columns, so a project with thousands of paywalls returns thousands of rows in one body. Read one paywall by id when that is what you need.

Responses

StatusBodyDescription
200objectOK

Response 200

FieldTypeRequiredDescription
paywallsarray of objectyes—
Example request
curl -X GET https://api.codespar.dev/v1/paywalls \
  -H "Authorization: Bearer $CODESPAR_API_KEY"
GET /v1/paywalls HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
import os
import requests

res = requests.get(
    "https://api.codespar.dev/v1/paywalls",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/paywalls", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
  },
});

const data = await res.json();
const result = await cs.api.get("/v1/paywalls");
Example response 200
application/json
{
  "paywalls": [
    {
      "id": "obj_0000000000000000",
      "project_id": "prj_0000000000000000",
      "slug": "example",
      "name": "Example",
      "upstream_url": "https://example.com/hook",
      "price": "string",
      "price_atomic": "string",
      "currency": "BRL",
      "environment": "live",
      "payto_kind": "byo",
      "payto_address": "string",
      "consumer_id": "csm_0000000000000000",
      "description": "string",
      "category": "string",
      "methods": [
        "string"
      ],
      "pricing_model": "flat",
      "pricing_tiers": [
        {
          "up_to": 0,
          "price_atomic": "string"
        }
      ],
      "dynamic_price_url": "https://example.com/hook",
      "metered_config": {
        "basis": "string",
        "base_atomic": "string",
        "min_mult": 1,
        "max_mult": 1,
        "units_header": "string"
      },
      "active": true,
      "gateway_url": "https://example.com/hook",
      "gateway_unavailable_reason": "gateway_origin_not_configured",
      "created_at": "2026-01-15T12:00:00.000Z"
    }
  ]
}

POST /v1/paywalls

POSThttps://api.codespar.dev/v1/paywalls

Create a paywall

Creates an x402 paywall in front of an origin of yours.

The slug is global. The uniqueness index covers the slug alone, so it is first-come across ALL tenants, and one already taken comes back as slug_conflict, even if nobody in your organization uses it.

The upstream_url is resolved here, not only validated. The gateway fetches that origin on every paid call, so a name that does not resolve, or that resolves to a private or metadata address, is refused at creation instead of becoming a fetch at request time.

Requires the admin role in the organization, so that a member cannot open a billing surface on their own.

Request body

FieldTypeRequiredDescription
categorystringno—
consumer_idstringnoRequired when payto.kind is provisioned: it is the wallet that receives.
currencystringno—
descriptionstringno—
dynamic_price_urlstring (uri)noFor dynamic: the gateway GETs this per request and quotes the price it returns.
metered_configobjectnoFor metered.
methodsarray of stringnoThe HTTP methods this paywall fronts. Absent means every method; a method outside the list gets 405 at the gateway.
namestringyes—
paytoobjectyes—
pricestringyesUSDC decimal, e.g. "0.001". Up to 6 decimal places.
pricing_modelstringnoflat, tiered, dynamic or metered.
pricing_tiersarray of objectnoFor tiered. The up_to bounds increase strictly and the LAST one is required to be open (up_to: null).
slugstringyesLowercase letters, digits, _ and -. It is the public gateway key and it is UNIQUE GLOBALLY, not per project: a slug taken by another tenant is refused with slug_conflict.
upstream_urlstring (uri)yesThe origin the gateway fetches on every paid call. The host is RESOLVED at creation time, not merely parsed: a public name whose A record points at 169.254.169.254 is the attack this check exists to close, and a name that does not resolve is 400 here instead of a fetch at request time.

Responses

StatusBodyDescription
201objectOK
400objectThe body did not pass. The codes say which rule: invalid_body (schema, or a price outside the USDC format), invalid_upstream_url (does not resolve, or resolves to a private host), slug_conflict (the slug already exists, globally), invalid_payto (byo without an EVM address, or provisioned without consumer_id), invalid_tiers (bounds that do not increase, or a bounded last one), pricing_model_unsupported, invalid_dynamic_price_url, and the three of metered: turned off in the deployment, no provisioned payto (the refund leaves a wallet of ours), or an invalid config.
403objectForbidden. The acting user does not satisfy the org-role guard in front of this route. With an OAuth access token this is the live behaviour when x-codespar-user is absent or names a user below admin; with an API key it appears only where the deployment enforces the same guard.

Response 201

FieldTypeRequiredDescription
activebooleanyesThe gateway resolves ACTIVE rows only; an inactive paywall stops serving.
categorystring,nullyes—
consumer_idstring,nullyesThe consumer whose wallet receives, for a provisioned payTo. Null for 'byo'.
created_atstring (date-time)yes—
currencystringyes'USDC' on every row the create route writes; the column has no CHECK.
descriptionstring,nullyes—
dynamic_price_urlstring,nullyesSeller price hook for 'dynamic': the gateway GETs it per request and quotes what it returns. It is fail-safe, not fail-closed: an unsafe, unreachable, non-2xx, malformed or timed-out hook falls back to price_atomic, so the gateway can always quote a price.
environment"live" | "test"yesInherited from the calling credential's project at create time.
gateway_unavailable_reason"gateway_origin_not_configured" | "gateway_origin_is_production"yesWhy the gateway URL is null: this deployment is not production and names no gateway origin of its own (gateway_origin_not_configured), or names a production host (gateway_origin_is_production). Null when the URL is present. Production's gateway is never used as a fallback outside production.
gateway_urlstring,nullyes<gateway origin>/<slug> (production: https://gw.codespar.dev/<slug>), built from the slug on the way out. The id does not appear in it. Null where this deployment has no gateway; see gateway_unavailable_reason.
idstringyespw_ followed by a 16-character nanoid.
metered_configobject,nullyesConfig for 'metered'. Null for every other model.
methodsarray,nullyesHTTP methods this paywall fronts. Null means any method. A method that is not listed is refused by the gateway with 405 method_not_allowed and an Allow header, BEFORE any 402 challenge, so it cannot be paid for and then proxied.
namestringyes—
payto_addressstringyesThe 0x address that receives settlement.
payto_kind"byo" | "provisioned"yes'byo': the seller supplied the receiving 0x USDC address. 'provisioned': CodeSpar derived and owns a CDP address for the tenant's consumer.
pricestringyesprice_atomic rendered as a decimal USDC string with trailing zeros trimmed ('1000' becomes '0.001'). Derived on the way out, not stored.
price_atomicstringyesThe stored price: USDC atomic units (6 decimals) as an integer string, to keep the x402 UintString convention exact.
pricing_model"flat" | "token" | "dynamic" | "time" | "per_unit" | "tiered" | "metered"yesThe full set a row may hold. Only 'flat', 'tiered', 'dynamic' and 'metered' can be created today; the create route refuses the other three with 400 pricing_model_unsupported rather than storing a model that would bill as flat.
pricing_tiersarray,nullyesAscending price curve for 'tiered'. Null for every other model.
project_idstringyes—
slugstringyesThe public gateway key. UNIQUE GLOBALLY, not per project: the uniqueness index covers the slug alone, so a slug is first-come across all tenants and a taken one is refused with 400 slug_conflict on create.
upstream_urlstringyesOrigin the gateway proxies to once a call is paid for.
Example request
curl -X POST https://api.codespar.dev/v1/paywalls \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "slug": "example",
       "name": "Example",
       "upstream_url": "https://example.com/hook",
       "price": "string",
       "currency": "BRL",
       "consumer_id": "csm_0000000000000000",
       "description": "string",
       "category": "string",
       "methods": [
         "string"
       ],
       "pricing_model": "string",
       "pricing_tiers": [
         {
           "up_to": 0,
           "price_atomic": "string"
         }
       ],
       "dynamic_price_url": "https://example.com/hook",
       "metered_config": {
         "basis": "string",
         "base_atomic": "string",
         "min_mult": 1,
         "max_mult": 1,
         "units_header": "string"
       },
       "payto": {
         "kind": "byo",
         "address": "string"
       }
     }'
POST /v1/paywalls HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json

{
  "slug": "example",
  "name": "Example",
  "upstream_url": "https://example.com/hook",
  "price": "string",
  "currency": "BRL",
  "consumer_id": "csm_0000000000000000",
  "description": "string",
  "category": "string",
  "methods": [
    "string"
  ],
  "pricing_model": "string",
  "pricing_tiers": [
    {
      "up_to": 0,
      "price_atomic": "string"
    }
  ],
  "dynamic_price_url": "https://example.com/hook",
  "metered_config": {
    "basis": "string",
    "base_atomic": "string",
    "min_mult": 1,
    "max_mult": 1,
    "units_header": "string"
  },
  "payto": {
    "kind": "byo",
    "address": "string"
  }
}
import os
import requests

res = requests.post(
    "https://api.codespar.dev/v1/paywalls",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
    json={
      "slug": "example",
      "name": "Example",
      "upstream_url": "https://example.com/hook",
      "price": "string",
      "currency": "BRL",
      "consumer_id": "csm_0000000000000000",
      "description": "string",
      "category": "string",
      "methods": [
        "string"
      ],
      "pricing_model": "string",
      "pricing_tiers": [
        {
          "up_to": 0,
          "price_atomic": "string"
        }
      ],
      "dynamic_price_url": "https://example.com/hook",
      "metered_config": {
        "basis": "string",
        "base_atomic": "string",
        "min_mult": 1,
        "max_mult": 1,
        "units_header": "string"
      },
      "payto": {
        "kind": "byo",
        "address": "string"
      }
    },
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/paywalls", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "slug": "example",
    "name": "Example",
    "upstream_url": "https://example.com/hook",
    "price": "string",
    "currency": "BRL",
    "consumer_id": "csm_0000000000000000",
    "description": "string",
    "category": "string",
    "methods": [
      "string"
    ],
    "pricing_model": "string",
    "pricing_tiers": [
      {
        "up_to": 0,
        "price_atomic": "string"
      }
    ],
    "dynamic_price_url": "https://example.com/hook",
    "metered_config": {
      "basis": "string",
      "base_atomic": "string",
      "min_mult": 1,
      "max_mult": 1,
      "units_header": "string"
    },
    "payto": {
      "kind": "byo",
      "address": "string"
    }
  }),
});

const data = await res.json();
const r = await cs.api.response("post", "/v1/paywalls", {
  body: {
    slug: "example",
    name: "Example",
    upstream_url: "https://example.com/hook",
    price: "string",
    currency: "BRL",
    consumer_id: "csm_0000000000000000",
    description: "string",
    category: "string",
    methods: [
      "string"
    ],
    pricing_model: "string",
    pricing_tiers: [
      {
        up_to: 0,
        price_atomic: "string"
      }
    ],
    dynamic_price_url: "https://example.com/hook",
    metered_config: {
      basis: "string",
      base_atomic: "string",
      min_mult: 1,
      max_mult: 1,
      units_header: "string"
    },
    payto: {
      kind: "byo",
      address: "string"
    }
  }
});
// r.status is one of the documented statuses (200, 403),
// each with its own body shape in r.data; nothing here throws on 403.
if (r.ok) {
  console.log(r.data);
}
Example response 201
application/json
{
  "id": "obj_0000000000000000",
  "project_id": "prj_0000000000000000",
  "slug": "example",
  "name": "Example",
  "upstream_url": "https://example.com/hook",
  "price": "string",
  "price_atomic": "string",
  "currency": "BRL",
  "environment": "live",
  "payto_kind": "byo",
  "payto_address": "string",
  "consumer_id": "csm_0000000000000000",
  "description": "string",
  "category": "string",
  "methods": [
    "string"
  ],
  "pricing_model": "flat",
  "pricing_tiers": [
    {
      "up_to": 0,
      "price_atomic": "string"
    }
  ],
  "dynamic_price_url": "https://example.com/hook",
  "metered_config": {
    "basis": "string",
    "base_atomic": "string",
    "min_mult": 1,
    "max_mult": 1,
    "units_header": "string"
  },
  "active": true,
  "gateway_url": "https://example.com/hook",
  "gateway_unavailable_reason": "gateway_origin_not_configured",
  "created_at": "2026-01-15T12:00:00.000Z"
}

GET /v1/paywalls/{id}

GEThttps://api.codespar.dev/v1/paywalls/{id}

Read one paywall

The paywall, if it belongs to the calling credential's org and project. The handler has exactly two outcomes, 200 and 404.

Path parameters

NameTypeRequiredDescription
idstringyes—

Responses

StatusBodyDescription
200objectOK
404objectNot Found. A paywall owned by another org or another project is indistinguishable from one that does not exist: the lookup filters on id AND org AND project in the same query, so a cross-tenant read is 404 and never 403.

Response 200

FieldTypeRequiredDescription
activebooleanyesThe gateway resolves ACTIVE rows only; an inactive paywall stops serving.
categorystring,nullyes—
consumer_idstring,nullyesThe consumer whose wallet receives, for a provisioned payTo. Null for 'byo'.
created_atstring (date-time)yes—
currencystringyes'USDC' on every row the create route writes; the column has no CHECK.
descriptionstring,nullyes—
dynamic_price_urlstring,nullyesSeller price hook for 'dynamic': the gateway GETs it per request and quotes what it returns. It is fail-safe, not fail-closed: an unsafe, unreachable, non-2xx, malformed or timed-out hook falls back to price_atomic, so the gateway can always quote a price.
environment"live" | "test"yesInherited from the calling credential's project at create time.
gateway_unavailable_reason"gateway_origin_not_configured" | "gateway_origin_is_production"yesWhy the gateway URL is null: this deployment is not production and names no gateway origin of its own (gateway_origin_not_configured), or names a production host (gateway_origin_is_production). Null when the URL is present. Production's gateway is never used as a fallback outside production.
gateway_urlstring,nullyes<gateway origin>/<slug> (production: https://gw.codespar.dev/<slug>), built from the slug on the way out. The id does not appear in it. Null where this deployment has no gateway; see gateway_unavailable_reason.
idstringyespw_ followed by a 16-character nanoid.
metered_configobject,nullyesConfig for 'metered'. Null for every other model.
methodsarray,nullyesHTTP methods this paywall fronts. Null means any method. A method that is not listed is refused by the gateway with 405 method_not_allowed and an Allow header, BEFORE any 402 challenge, so it cannot be paid for and then proxied.
namestringyes—
payto_addressstringyesThe 0x address that receives settlement.
payto_kind"byo" | "provisioned"yes'byo': the seller supplied the receiving 0x USDC address. 'provisioned': CodeSpar derived and owns a CDP address for the tenant's consumer.
pricestringyesprice_atomic rendered as a decimal USDC string with trailing zeros trimmed ('1000' becomes '0.001'). Derived on the way out, not stored.
price_atomicstringyesThe stored price: USDC atomic units (6 decimals) as an integer string, to keep the x402 UintString convention exact.
pricing_model"flat" | "token" | "dynamic" | "time" | "per_unit" | "tiered" | "metered"yesThe full set a row may hold. Only 'flat', 'tiered', 'dynamic' and 'metered' can be created today; the create route refuses the other three with 400 pricing_model_unsupported rather than storing a model that would bill as flat.
pricing_tiersarray,nullyesAscending price curve for 'tiered'. Null for every other model.
project_idstringyes—
slugstringyesThe public gateway key. UNIQUE GLOBALLY, not per project: the uniqueness index covers the slug alone, so a slug is first-come across all tenants and a taken one is refused with 400 slug_conflict on create.
upstream_urlstringyesOrigin the gateway proxies to once a call is paid for.
Example request
curl -X GET https://api.codespar.dev/v1/paywalls/{id} \
  -H "Authorization: Bearer $CODESPAR_API_KEY"
GET /v1/paywalls/{id} HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
import os
import requests

res = requests.get(
    "https://api.codespar.dev/v1/paywalls/{id}",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/paywalls/{id}", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
  },
});

const data = await res.json();
const result = await cs.api.get("/v1/paywalls/{id}", {
  path: {
    id: "paywall_0000000000000000"
  }
});
Example response 200
application/json
{
  "id": "obj_0000000000000000",
  "project_id": "prj_0000000000000000",
  "slug": "example",
  "name": "Example",
  "upstream_url": "https://example.com/hook",
  "price": "string",
  "price_atomic": "string",
  "currency": "BRL",
  "environment": "live",
  "payto_kind": "byo",
  "payto_address": "string",
  "consumer_id": "csm_0000000000000000",
  "description": "string",
  "category": "string",
  "methods": [
    "string"
  ],
  "pricing_model": "flat",
  "pricing_tiers": [
    {
      "up_to": 0,
      "price_atomic": "string"
    }
  ],
  "dynamic_price_url": "https://example.com/hook",
  "metered_config": {
    "basis": "string",
    "base_atomic": "string",
    "min_mult": 1,
    "max_mult": 1,
    "units_header": "string"
  },
  "active": true,
  "gateway_url": "https://example.com/hook",
  "gateway_unavailable_reason": "gateway_origin_not_configured",
  "created_at": "2026-01-15T12:00:00.000Z"
}

PATCH /v1/paywalls/{id}

PATCHhttps://api.codespar.dev/v1/paywalls/{id}

Update a paywall

Changes name, origin, price or the active state. Only the fields sent change.

The slug is NOT here: it is the public key payers already use, and changing it would break every client pointed at the old one. Requires the admin role.

Path parameters

NameTypeRequiredDescription
idstringyes—

Request body

FieldTypeRequiredDescription
activebooleanno—
namestringno—
pricestringno—
upstream_urlstring (uri)no—

Responses

StatusBodyDescription
200objectOK
400objectEmpty body or a body outside the schema, a price outside the USDC format, or an origin that does not resolve to a public host.
403objectForbidden. The acting user does not satisfy the org-role guard in front of this route. With an OAuth access token this is the live behaviour when x-codespar-user is absent or names a user below admin; with an API key it appears only where the deployment enforces the same guard.
404objectNot Found. A paywall owned by another org or another project is indistinguishable from one that does not exist: the lookup filters on id AND org AND project in the same query, so a cross-tenant read is 404 and never 403.

Response 200

FieldTypeRequiredDescription
activebooleanyesThe gateway resolves ACTIVE rows only; an inactive paywall stops serving.
categorystring,nullyes—
consumer_idstring,nullyesThe consumer whose wallet receives, for a provisioned payTo. Null for 'byo'.
created_atstring (date-time)yes—
currencystringyes'USDC' on every row the create route writes; the column has no CHECK.
descriptionstring,nullyes—
dynamic_price_urlstring,nullyesSeller price hook for 'dynamic': the gateway GETs it per request and quotes what it returns. It is fail-safe, not fail-closed: an unsafe, unreachable, non-2xx, malformed or timed-out hook falls back to price_atomic, so the gateway can always quote a price.
environment"live" | "test"yesInherited from the calling credential's project at create time.
gateway_unavailable_reason"gateway_origin_not_configured" | "gateway_origin_is_production"yesWhy the gateway URL is null: this deployment is not production and names no gateway origin of its own (gateway_origin_not_configured), or names a production host (gateway_origin_is_production). Null when the URL is present. Production's gateway is never used as a fallback outside production.
gateway_urlstring,nullyes<gateway origin>/<slug> (production: https://gw.codespar.dev/<slug>), built from the slug on the way out. The id does not appear in it. Null where this deployment has no gateway; see gateway_unavailable_reason.
idstringyespw_ followed by a 16-character nanoid.
metered_configobject,nullyesConfig for 'metered'. Null for every other model.
methodsarray,nullyesHTTP methods this paywall fronts. Null means any method. A method that is not listed is refused by the gateway with 405 method_not_allowed and an Allow header, BEFORE any 402 challenge, so it cannot be paid for and then proxied.
namestringyes—
payto_addressstringyesThe 0x address that receives settlement.
payto_kind"byo" | "provisioned"yes'byo': the seller supplied the receiving 0x USDC address. 'provisioned': CodeSpar derived and owns a CDP address for the tenant's consumer.
pricestringyesprice_atomic rendered as a decimal USDC string with trailing zeros trimmed ('1000' becomes '0.001'). Derived on the way out, not stored.
price_atomicstringyesThe stored price: USDC atomic units (6 decimals) as an integer string, to keep the x402 UintString convention exact.
pricing_model"flat" | "token" | "dynamic" | "time" | "per_unit" | "tiered" | "metered"yesThe full set a row may hold. Only 'flat', 'tiered', 'dynamic' and 'metered' can be created today; the create route refuses the other three with 400 pricing_model_unsupported rather than storing a model that would bill as flat.
pricing_tiersarray,nullyesAscending price curve for 'tiered'. Null for every other model.
project_idstringyes—
slugstringyesThe public gateway key. UNIQUE GLOBALLY, not per project: the uniqueness index covers the slug alone, so a slug is first-come across all tenants and a taken one is refused with 400 slug_conflict on create.
upstream_urlstringyesOrigin the gateway proxies to once a call is paid for.
Example request
curl -X PATCH https://api.codespar.dev/v1/paywalls/{id} \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "name": "Example",
       "upstream_url": "https://example.com/hook",
       "price": "string",
       "active": true
     }'
PATCH /v1/paywalls/{id} HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json

{
  "name": "Example",
  "upstream_url": "https://example.com/hook",
  "price": "string",
  "active": true
}
import os
import requests

res = requests.patch(
    "https://api.codespar.dev/v1/paywalls/{id}",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
    json={
      "name": "Example",
      "upstream_url": "https://example.com/hook",
      "price": "string",
      "active": True
    },
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/paywalls/{id}", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "name": "Example",
    "upstream_url": "https://example.com/hook",
    "price": "string",
    "active": true
  }),
});

const data = await res.json();
const r = await cs.api.response("patch", "/v1/paywalls/{id}", {
  path: {
    id: "paywall_0000000000000000"
  },
  body: {
    name: "Example",
    upstream_url: "https://example.com/hook",
    price: "string",
    active: true
  }
});
// r.status is one of the documented statuses (200, 403),
// each with its own body shape in r.data; nothing here throws on 403.
if (r.ok) {
  console.log(r.data);
}
Example response 200
application/json
{
  "id": "obj_0000000000000000",
  "project_id": "prj_0000000000000000",
  "slug": "example",
  "name": "Example",
  "upstream_url": "https://example.com/hook",
  "price": "string",
  "price_atomic": "string",
  "currency": "BRL",
  "environment": "live",
  "payto_kind": "byo",
  "payto_address": "string",
  "consumer_id": "csm_0000000000000000",
  "description": "string",
  "category": "string",
  "methods": [
    "string"
  ],
  "pricing_model": "flat",
  "pricing_tiers": [
    {
      "up_to": 0,
      "price_atomic": "string"
    }
  ],
  "dynamic_price_url": "https://example.com/hook",
  "metered_config": {
    "basis": "string",
    "base_atomic": "string",
    "min_mult": 1,
    "max_mult": 1,
    "units_header": "string"
  },
  "active": true,
  "gateway_url": "https://example.com/hook",
  "gateway_unavailable_reason": "gateway_origin_not_configured",
  "created_at": "2026-01-15T12:00:00.000Z"
}

DELETE /v1/paywalls/{id}

DELETEhttps://api.codespar.dev/v1/paywalls/{id}

Delete a paywall

HARD delete, and 204 with no body. The row is removed rather than deactivated, because a paywall is configuration and not audit data; if what you want is to stop serving while keeping the row, patch active to false instead.

WHAT IT DOES NOT DELETE: the settlement evidence and the metered refund attempts. Neither carries a foreign key to the paywall, so both survive the delete. What is lost is the way IN to them, since the stats operation resolves the paywall first and answers 404 once the row is gone. Read the stats before deleting if you need the totals.

The slug returns to the global namespace and any other tenant may then claim it, so a delete-and-recreate is not guaranteed to get the same gateway URL back.

AUTHORIZATION, as deployed rather than as named, and it is NOT the same for the two credentials this document describes.

With an OAUTH ACCESS TOKEN the org-role guard in front of this route is ordinary and live: the acting user must be forwarded in x-codespar-user and must hold at least the admin role in the organization. No header, or a user who is not an admin, is 403 insufficient_role before the handler runs.

With an API KEY the same guard is in a measurement window: it resolves the forwarded user, RECORDS the request when that user would not have sufficed, and lets it through anyway. So today a key carrying paywalls:write deletes without forwarding anybody. A deployment that closes the window refuses those calls with 403 as well, so forward x-codespar-user if you have it: it is already required on the token path, it is inert on the key path while the window is open, and it is what keeps the call working when the window closes.

Path parameters

NameTypeRequiredDescription
idstringyes—

Responses

StatusBodyDescription
204—No Content
403objectForbidden. The acting user does not satisfy the org-role guard in front of this route. With an OAuth access token this is the live behaviour when x-codespar-user is absent or names a user below admin; with an API key it appears only where the deployment enforces the same guard.
404objectNot Found. A paywall owned by another org or another project is indistinguishable from one that does not exist: the lookup filters on id AND org AND project in the same query, so a cross-tenant read is 404 and never 403.
Example request
curl -X DELETE https://api.codespar.dev/v1/paywalls/{id} \
  -H "Authorization: Bearer $CODESPAR_API_KEY"
DELETE /v1/paywalls/{id} HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
import os
import requests

res = requests.delete(
    "https://api.codespar.dev/v1/paywalls/{id}",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/paywalls/{id}", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
  },
});

const data = await res.json();
const r = await cs.api.response("delete", "/v1/paywalls/{id}", {
  path: {
    id: "paywall_0000000000000000"
  }
});
// r.status is one of the documented statuses (200, 403),
// each with its own body shape in r.data; nothing here throws on 403.
if (r.ok) {
  console.log(r.data);
}

GET /v1/paywalls/{id}/stats

GEThttps://api.codespar.dev/v1/paywalls/{id}/stats

Earnings for one paywall

What this paywall has settled and what the seller kept, aggregated at read time from the evidence the gateway already writes. No counter is stored for it.

ATTRIBUTION is what the gateway wrote into the x402 intent, on both fields: the purpose is paywall:<slug> AND the resource URL is exactly https://gw.codespar.dev/<slug> or a path underneath it, within the paywall's organization and project. The purpose keeps a paywall slugged mcp or pay from counting MCP or payment-link settlements that live under its URL. GET /v1/gate/stats uses the same rule.

A metered refund counts against the settlement it refunds, joined by the settlement's transaction hash.

A slug cannot be edited, so attribution never moves under a live paywall. Deleting the paywall does end the reading: the evidence rows outlive it, and the id that reached them is gone.

Path parameters

NameTypeRequiredDescription
idstringyes—

Responses

StatusBodyDescription
200objectOK
404objectNot Found. A paywall owned by another org or another project is indistinguishable from one that does not exist: the lookup filters on id AND org AND project in the same query, so a cross-tenant read is 404 and never 403.

Response 200

FieldTypeRequiredDescription
currencystringyes—
grossstringyesgross_atomic as a decimal USDC string.
gross_atomicstringyesSum of what payers authorized, in USDC atomic units.
last_settled_atstring,null (date-time)yesMost recent settlement, UTC. Null when nothing has settled.
netstringyesnet_atomic as a decimal USDC string.
net_atomicstringyesgross_atomic minus refunded_atomic, clamped at zero rather than allowed to go negative. Flat, tiered and dynamic paywalls have no refunds, so net equals gross there; on the metered lane the payer signs a ceiling and the difference goes back on-chain, so gross alone would report money the seller does not keep.
paywall_idstringyes—
refundedstringyesrefunded_atomic as a decimal USDC string.
refunded_atomicstringyesSum of the metered refunds that are owed or already gone: attempts in status 'claimed', 'sent' or 'confirmed'. The refund lane writes five statuses and only these three are subtracted; the other two, 'waived' (below the dust floor, so no transfer is made) and 'failed' (the transfer or its transaction reverted), leave the money with the seller.
settled_countintegeryesSettled gateway calls attributed to this paywall.
slugstringyes—
Example request
curl -X GET https://api.codespar.dev/v1/paywalls/{id}/stats \
  -H "Authorization: Bearer $CODESPAR_API_KEY"
GET /v1/paywalls/{id}/stats HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
import os
import requests

res = requests.get(
    "https://api.codespar.dev/v1/paywalls/{id}/stats",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/paywalls/{id}/stats", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
  },
});

const data = await res.json();
const result = await cs.api.get("/v1/paywalls/{id}/stats", {
  path: {
    id: "paywall_0000000000000000"
  }
});
Example response 200
application/json
{
  "paywall_id": "paywall_0000000000000000",
  "slug": "example",
  "currency": "BRL",
  "settled_count": 1,
  "gross_atomic": "string",
  "gross": "string",
  "refunded_atomic": "string",
  "refunded": "string",
  "net_atomic": "string",
  "net": "string",
  "last_settled_at": "2026-01-15T12:00:00.000Z"
}
Paywalls | CodeSpar