apibase@prod:~$ cat /srv/apibase/integrator/docs | render

APIbase Integrator (merchant) guide

Registration & terms

A merchant goes pending → active in three calls. Everything is authenticated by the merchant wallet's EIP-191 signature; the mk_live_ API key is issued once, at the end.

| Step | REST (/api/v1/shop) | MCP tool | | ---- | ------------------------------------------------------------ | ------------------------------ | | 1 | GET /auth/nonce?wallet=0x…&purpose=register | — | | 2 | POST /merchants | shop.merchant.register | | 3 | GET /auth/nonce?wallet=0x…&purpose=accept_terms | — | | 4 | POST /merchants/me/acceptances | shop.merchant.accept_terms | | — | POST /merchants/me/keys/rotate (Bearer) | shop.merchant.rotate_key | | — | POST /merchants/me/keys/reissue (no Bearer, wallet-signed) | — | | — | POST /merchants/me/deactivate (Bearer, orders:write) | shop.merchant.deactivate | | — | GET /merchants/me/orders (Bearer, orders:read) | shop.merchant.orders_list | | — | POST /merchants/me/orders/:id/confirm (orders:write) | shop.merchant.order_confirm | | — | POST /merchants/me/orders/:id/ship (orders:write) | shop.merchant.order_ship | | — | POST /merchants/me/orders/:id/document (orders:write) | shop.merchant.order_document | | — | PUT /merchants/me/webhooks (webhooks:write) | shop.merchant.webhook_set | | — | GET /merchants/me/events?since=<cursor> (orders:read) | — |

  1. Nonce. GET /auth/nonce returns {nonce, issued_at, expires_in, message}; message is the exact
  2. text to sign for that purpose. A nonce is single-use and lives 300 s.

  3. Register. Sign the register message:

`` apibase.pro wants you to sign in with your wallet. Address: <wallet> Purpose: register Nonce: <nonce> Issued At: <ISO-8601 time> ``

Send the merchant fields plus message and signature. The reply is {merchant_id, slug, status: "pending", docs_to_accept: [{doc_id, version, sha256, url}]}. Without a signature the call is 401. The IP country is taken from the edge (trusted header), form/IP mismatch never blocks.

  1. Accept the four documents (merchant-agreement, aup, dpa, refund-framework) by signing,
  2. with a fresh accept_terms nonce, exactly this text (one line; hashes are the sha256 values from docs_to_accept):

`` I accept APIbase documents: merchant-agreement v<version> sha256:<h1>; aup v<version> sha256:<h2>; dpa v<version> sha256:<h3>; refund-framework v<version> sha256:<h4>. Wallet: <wallet>. Nonce: <nonce>. Time: <ISO-8601 time> ``

Send {wallet, doc_hashes: [{doc_id, version, sha256}], message, signature}. All four documents are required and the message must match the template byte for byte. On success the merchant is active and the response is {status: "active", api_key: "mk_live_…"} — the key is shown once. Repeating the call returns {status: "active"} without a new key. The message and signature are stored whole as the acceptance record.

  1. New document versions. When a document is republished you have 30 days to accept it again:
  2. merchant-tool responses carry terms_update_pending: {docs: [...]} during that window; afterwards new quotes are refused with 428 terms_not_accepted.

  3. Deactivate. deactivate stops new quotes and shows the storefront as 410. Open orders stay
  4. serviceable: existing keys keep orders:read/orders:write; catalog:write calls get 410.

Errors

Every error carries error_code, message, suggested_action, documentation_url.

<a id="country_not_supported"></a>

country_not_supported (403) — the entity country or the sign-up IP's country/region is not served. See /legal/aup.

<a id="category_prohibited"></a>

category_prohibited (403) — the category is not supported; alternatives[] lists the nearest allowed categories. See /legal/aup#categories.

terms_not_accepted (428) — the merchant is pending, a document hash/version in the request is not the current one, fewer than four documents were sent, or a re-acceptance is overdue. docs[] holds the current {doc_id, version, sha256, url} of all four documents.

<a id="merchant_unavailable"></a>

merchant_unavailable (410) — the merchant is not available (deactivated or otherwise). The reason is not disclosed. Policy: /legal/refund-framework.

Other codes: unauthorized (401, bad/missing signature, nonce used or expired), rate_limited (429), validation_failed (422), slug_taken/wallet_registered (409), payout_wallet_sanctioned (403), payout_wallet_platform (400). The payout wallet must be the merchant's own; platform addresses are refused with payout_wallet_platform (400). Limits per IP: nonce 30/min, POST /merchants 5/hour, acceptances 10/min.

Lost key

If you lost your mk_live_ key there is no Bearer to call keys/rotate with. Recover with the merchant wallet instead:

  1. GET /api/v1/shop/auth/nonce?wallet=0x…&purpose=reissue returns a message to sign.
  2. Sign it with the merchant (identity) wallet (EIP-191) and call
  3. POST /api/v1/shop/merchants/me/keys/reissue with {wallet, message, signature} and no Authorization header.

  4. The response is {api_key}: a fresh mk_live_ key, shown once. All earlier keys are revoked
  5. immediately. The nonce is single-use and expires after 5 minutes; a bad or replayed signature is 401.

The route is rate-limited to 5 requests per hour per address. A merchant.keys_reissued event is recorded (subscribable through webhooks, also in GET /merchants/me/events). This route is an addition to §6.3 of the spec.

Catalog

Write path (merchant key mk_live_…, scope catalog:write, 60 writes/min per key; merchants must have accepted the current documents — pending → 428, deactivated/suspended → 410):

Item (zod schema CatalogItemSchema, src/shop/catalog.service.ts):

| field | rule | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | sku | [A-Za-z0-9._:-], ≤64 | | title | ≤120 after HTML/control/zero-width stripping | | description | ≤2 000 after stripping | | price_usd | decimal, ≤2 fractional digits, $1.00 … limits.max_order_usd | | is_test | only for the test SKU (below) | | stock | integer ≥0; null/absent = not tracked | | fulfillment_mode | instant · merchant · physical · stream | | fulfillment.instant.payload | required for instant; stored encrypted with the server key, never returned or logged | | tax_included, tax_note, shipping_options[], delivery_slots[], requires_pii[], refund_window_days, returns_accepted, currency_display, images[] (https URLs), variants[] | as in the spec; no discounts or coupons | | subscription | {period_unit: day\|week\|month, period_count >= 1, max_periods? <= 120, trial: none} for instant/merchant items; price_usd is the price of ONE period (>= $1.00). No stock count, variants, buyer data or test SKU. See Subscriptions | | category | one of config/integrator/prohibited-categories.json → allowed |

Response: { upserted, flagged[{sku,reason}], rejected[{sku,reason,category,status:422}], errors[{index,sku,status:422,message}] }. Items are judged one by one: a bad item never blocks the rest of the batch. More than 500 items → 422 for the whole call.

Feed import (F-2, UC-11). POST /api/v1/shop/merchants/me/catalog/import (scope catalog:write, answer 202) with { "source": "csv" | "gmc" | "shopify", "url"?: "https://…", "body"?: "<CSV text, ≤1 MB>", "category"?: "<slug for records without one>" } returns import_job_id; a worker job downloads and imports it in batches of ≤500 through the same upsert and layer-1 moderation as PUT …/catalog. Poll GET /api/v1/shop/merchants/me/catalog/import/:id → {status: queued|running|done|failed, total, upserted, rejected[{sku, reason}], rejected_count, error} (the list shows the first 1000). One import per merchant per 10 minutes (429). Downloads follow the webhook rules: https only, public addresses only (DNS pinned), redirects are not followed, ≤10 MB, 20 s; a refused URL is 400.

A feed category must be one of the allowed slugs, otherwise the record is rejected by moderation (the category request field only fills in records that have none).

Test SKU. Exactly one item per merchant may be sku: "__apibase_test", is_test: true, price_usd: 0.01 (any other price or a second test item → 422). It is not listed by shop.catalog.search, but shop.catalog.get returns it, so an agent can run an end-to-end purchase. No fee is taken on it.

Moderation. Order per item: category in allowed (else rejected, category_prohibited) → prohibited-category keywords and the platform content filter (rejected) → hidden characters and instruction-like text (ignore previous, you must, system:, non-https URL schemes → flagged). Every check writes a shop_moderation_reviews row (scope=product, layer=rules). A flagged product is saved but invisible to buyers (search omits it, get → 404) until a layer-3 or human review of that item returns ok; a flagged item is re-checked within 24 h of the change; re-uploading the item moderates it again. A call with rejected items also emits one shop.catalog.rejected event. The scanned fields are title, description, category, variants[].title and variants[].attributes (keys and values), shipping_options[].label, delivery_slots[].label, tax_note and stream.content_ref; hidden characters are checked on the same fields. The shop name and the site_url host at registration, and the Provek insurance.note and claims_url host, pass the same text rules. Repeated rejected writes temporarily block catalog writes for this merchant (403 moderation_banned with retry_after; one write call with any rejected item counts once). When the platform rules change, listed items are re-checked and may be hidden or rejected; the merchant receives the same category notice. Re-uploading a rejected item puts it back to review, not on the shelf: after a layer-3 or human reject, the item stays invisible (flagged) until the next layer-3 or human review returns ok.

Buyer path (/mcp, free, read-only, merchant = slug is required, only active merchants — otherwise 410 merchant_unavailable):

Quotes

Buyer path. A quote is a price snapshot plus a stock hold; nothing is charged by it (payment is a separate step).

Response (201): quote_id, order_id, items[{sku, variant?, title, qty, unit_price_usd, line_total_usd}], total_usd, fee_disclosed: false, expires_at, requires_pii[], requires_human_confirmation, pay. Prices come from the server catalog, never from the request.

pay contains only the rails the quote offers: x402: {payTo, amount, network, asset, extra: {quote_id}} — payTo is the merchant's payout_wallet_base (never the platform wallet), amount is micro-USDC (toMicroUsdc(total_usd)); mpp: {url} = POST /api/v1/shop/quotes/{id}/pay. On /mcp orders are paid by x402 only, so pay.mpp is omitted there. Tempo memo is bytes32: 16 zero bytes followed by the 16 bytes of quote_id; decode it to find the quote on-chain.

TTL. 15 minutes by default (limits.quote_ttl_s, merchant range 5–60 min). Past expires_at the quote is expired: GET returns 410 quote_expired with a fresh quote in quote (same items, current prices); if the items have meanwhile sold out, 410 carries alternatives instead.

Reservations. Items with tracked stock are held atomically when the quote is made (all lines or none) until expires_at; the loser of a race gets 409 out_of_stock with available and up to 3 alternatives of the same category. Expiry or cancel releases the hold.

Limits. Order total ≥ $1.00 (INTEGRATOR_MIN_ORDER_USD), ≤ limits.max_order_usd; merchants younger than 30 days with fewer than 10 closed orders are capped at limits.new_merchant_cap_usd ($200) → 422. requires_human_confirmation is true above limits.human_confirm_above_usd (default $100). Quotes per buyer identity (agent, wallet hash or IP): 30/min, 300/hour → 429 rate_limited; a banned identity → 429 banned. The merchant must have accepted the current terms (428) and be active (410).

Test SKU. __apibase_test is bought alone, qty 1, no minimum, fee_usd = 0. At most INTEGRATOR_TEST_SKU_DAILY_CAP (3) paid test orders per merchant per 24 hours; the next quote → 429 test_sku_daily_cap.

Fee (internal, not in the response): when INTEGRATOR_FEE_ENABLED=true, max(total × INTEGRATOR_FEE_BPS / 10000, INTEGRATOR_FEE_MIN_USD) rounded up to the cent (150 bps, min $0.05); 0 otherwise and for the test SKU. With the fee on it must also be ≥ X402_GAS_ESTIMATE_USD × 10, else 422.

Error codes: quote_expired (410), out_of_stock (409), test_sku_daily_cap (429), below_minimum/above_max_order/new_merchant_cap (422), not_cancellable (409), quote_not_open (409).

Payment & fulfillment

Buyer path after a quote: pay with x402 (Base). Settle comes before delivery — the order is delivered only after the USDC transfer has a successful receipt.

Buyer PII (end-to-end encrypted)

Some products need buyer data (requires_pii: shipping_address, passport, phone, company). APIbase is a processor on the merchant's behalf and cannot read this data: the buyer's agent encrypts it to the merchant's public key and the platform stores only the ciphertext. There is no server-side decryption anywhere, and no way to ask the platform for a plaintext copy.

1. Generate the key pair (merchant). An X25519 pair, the public key as base64 of the raw 32 bytes:

# Node
node -e "const c=require('crypto');const k=c.generateKeyPairSync('x25519');console.log('pub',k.publicKey.export({type:'spki',format:'der'}).subarray(-32).toString('base64'));console.log('priv',k.privateKey.export({type:'pkcs8',format:'der'}).subarray(-32).toString('base64'))"
# Python (cryptography)
python3 -c "import base64;from cryptography.hazmat.primitives import serialization as s;from cryptography.hazmat.primitives.asymmetric import x25519;k=x25519.X25519PrivateKey.generate();r=lambda b:base64.b64encode(b).decode();print('pub',r(k.public_key().public_bytes(s.Encoding.Raw,s.PublicFormat.Raw)));print('priv',r(k.private_bytes(s.Encoding.Raw,s.PrivateFormat.Raw,s.NoEncryption())))"
# CLI (age-keygen prints an X25519 recipient; convert its key to raw bytes yourself)
age-keygen

The private key never leaves you. Keep it out of APIbase, out of logs and out of the repository.

2. Publish it. encryption_key {kid, alg, pub, sig_by_wallet} at registration (kid up to 64 characters). sig_by_wallet is an EIP-191 signature by the merchant identity wallet over exactly this text (the same text at registration and at rotation; --prepare of the seed script prints it):

apibase.pro merchant encryption key
kid: <kid>
alg: <alg>
pub: <pub>

The signature is stored and handed to the buyer's agent together with the key (merchant_encryption_key in shop.catalog.get and in every quote that has requires_pii), so the agent can check that the key belongs to the merchant wallet.

3. Envelope (buyer's agent). Encrypt the JSON ({full_name, passport_number, nationality, dob, expiry} for a passport, the address object for shipping_address) to pub with HPKE (RFC 9180, X25519-HKDF-SHA256 + ChaCha20-Poly1305, alg: "hpke-x25519-sha256-chacha20") or libsodium crypto_box_seal (alg: "sealed-box-x25519"). AAD = the quote_id (HPKE; sealed boxes have no AAD, so the merchant checks the quote inside the decrypted JSON). Send one envelope per kind in shop.order.pay / POST /quotes/:id/pay:

{
  "pii": {
    "passport": {
      "kid": "<merchant kid>",
      "alg": "hpke-x25519-sha256-chacha20",
      "ciphertext_b64": "<base64>"
    }
  }
}

At most 16 KB decoded per envelope. The platform accepts nothing else: a string, a number, an object with readable fields (passport_number, address, …), any extra field, a bad base64 or an oversize envelope is 400 pii_plaintext_rejected ("encrypt with merchant key, see docs"); the value is never echoed, stored or logged. Other errors: 422 pii_required (a kind the quote needs is missing; extra.merchant_encryption_key and extra.required say what to send), 409 merchant_key_rotated (the kid is not the current one; extra.merchant_encryption_key is the new key; re-encrypt and pay, or request a new quote), 400 pii_unexpected_kind, 400 pii_alg_unsupported. The platform cannot check the AAD (it cannot decrypt): a wrong AAD only fails at the merchant.

4. Delivery to the merchant. The same bytes arrive in the order.paid webhook as data.pii[{kind, kid, alg, ciphertext_b64, sha256}] and on GET /api/v1/shop/merchants/me/orders/:id/pii (Bearer, scope orders:read, 60 per minute) as {order_id, envelopes[...]}; another merchant's order is 404. sha256 is the hash of the ciphertext bytes. The buyer's shop.order.get and the merchant's orders_list show only pii: {kinds, sha256}, never ciphertext.

5. Merchant duties. Fetch the envelopes (webhook or GET) before you rotate the key; decrypt with your private key; store what you need yourself — the platform keeps data only temporarily and cannot restore it. Retention (spec 10.3), enforced by the shop-sla-sweeper with a physical DELETE and a pii.purged order event:

The merchant gets a "stored temporarily, keep your own copy" e-mail on the first delivery. There is no early-purge request route: data is purged by rule only. Terms: the data-processing agreement at /legal/dpa (APIbase acts as a processor on the merchant's behalf).

6. Rotation. POST /merchants/me/keys/rotate / shop.merchant.rotate_key with encryption_key {kid, alg, pub, sig_by_wallet} (a NEW kid, signature as in step 2; this call also rotates the API key as before) replaces the published key and emits merchant.key_rotated. Stored envelopes are not re-encrypted: those not yet delivered get a pii.undeliverable order event and a pii_undeliverable e-mail (one per order) — fetch them and open them with the OLD private key. Open quotes keep their requires_pii; paying one with the old kid is 409 merchant_key_rotated.

For buyer agents

Passing a passport or an address to merchant X is a legal and privacy decision for your user: a human confirmation is recommended before you send it (UC-2). Always encrypt to the key in the quote, check sig_by_wallet if you can, send one envelope per required kind, and never log, echo or store the plaintext.

Streams (UC-7 / F-9)

A product with fulfillment_mode: "stream" is sold by the second over an MPP tempo.session payment channel. Catalog item: stream: {rate_per_s_usd (>= 0.0001), min_deposit_usd (>= 1), unit: "second", content_ref} plus the content in fulfillment.instant.payload; price_usd is the per-second rate (omit it, or set it equal to rate_per_s_usd) and the $1.00 order minimum applies to the deposit, not to the rate. The test SKU can never be a stream. shop.catalog.get returns stream.url = https://apibase.pro/api/v1/shop/m/<slug>/stream/<sku>; streams are not listed by shop.catalog.search, the storefront pages or quotes (shop.order.quote refuses a stream sku with 422).

GET|POST /api/v1/shop/m/:slug/stream/:sku?seconds=N (1 <= N <= 60, default 1; 120 requests/min per address) is the only route that issues the session challenge, MPP only: an X-Payment header answers 400 with pay.mpp.url. Without a credential: 402 with the challenge (unit amount = N x rate). open needs an on-chain deposit of at least min_deposit_usd (otherwise 402 deposit_below_minimum, no session is recorded); each accepted open/voucher request deducts N x rate and returns the next slice of the content (consumed_s, consumed_usd, highest_voucher); topUp raises the deposit; close settles the final amount. A channel of another shop or another product is 404 unknown_channel.

Who may settle: on-chain only the channel payee, which is always the merchant's payout_wallet_tempo. shop_merchants.stream_settler is apibase_pilot only for the APIbase demo shop (the pilot key INTEGRATOR_STREAM_PILOT_TEMPO_KEY must be the key of that payout wallet, else 500 stream_settler_misconfigured), merchant (settlement with the merchant's own key, INT-46) or empty (409 stream_unavailable). The worker job shop-stream-settle runs every minute: it settles at highest - settled >= $0.50, hourly while highest > settled, and at once when the payer requests a close on-chain (stream.close_requested). A settle that keeps failing for an hour raises STREAM_SETTLE_OVERDUE. The platform fee of a stream is a receivable (shop_fee_ledger, source = stream): the fee percentage of each settled increment, and at close the missing part of the minimum fee once per session; nothing while the fee switch is off.

Streaming for your shop: run the settler with your own key

A shop that sells streams (fulfillment_mode: "stream") is paid over an on-chain payment channel. Only the channel payee, your payout_wallet_tempo, can settle or close it on-chain. APIbase never holds your key: you run the settler yourself, and APIbase keeps the accounting and verifies the buyer's vouchers.

  1. Call the check (step 6), then PATCH /api/v1/shop/merchants/me/stream-settings with {"settler": "merchant"} (scope catalog:write). The shop must be active.
  2. Install the CLI (MIT, source in packages/stream-settler): apibase-stream-settler --key-env MERCHANT_TEMPO_KEY --api https://apibase.pro --mk mk_live_... --interval 60. The key of your payout wallet is read from your own environment variable or file and signs on your machine only; it is never sent to APIbase. Your merchant key needs the scopes orders:read and orders:write.
  3. Every pass reads GET /api/v1/shop/merchants/me/streams/settle-queue (scope orders:read): your channels with at least $0.50 unsettled, or an hour since the last settle, or a close requested. Each item is {channel_id, escrow_contract, chain_id, cumulative_amount, signature, action} with action = settle or close. The CLI sends the transaction from your wallet, then calls POST /api/v1/shop/merchants/me/streams/:channel_id/settled with {tx_hash} (scope orders:write); the CLI reports a transaction only after its receipt, and retries the report until APIbase has seen it. APIbase reads the chain: the transaction must come from your payout wallet and the channel's settled amount must have advanced (for a close, the channel must be finalized).
  4. When a buyer closes a channel, the reply is 202 {"status": "close_pending", "channel_id": ..., "note": ...}: you settle within the grace period; the buyer may request a close and withdraw on-chain after CLOSE_GRACE_PERIOD.
  5. A close_pending channel, or one the buyer asked to close on-chain, that you have not settled for an hour raises the stream.settle_due event (webhook and events feed) and a notice by e-mail. Keep the CLI running.

A close is proven by the ChannelClosed event of the reported transaction; getChannel() is zeroed after close.

The platform fee of settled amounts is a receivable, as for any stream.

Subscriptions (UC-9 / F-8)

A subscription is renewed by the buyer's agent with an explicit payment; by default funds are never debited automatically: the agent renews by paying. A Base payer can opt in to pre-signed authorizations that the platform executes period by period, see Base pull subscriptions. Each period is an ordinary order: it is quoted, paid on either rail, carries the platform fee of an order of that amount (§7.1) and is delivered like any other. The renewal is yours to honor: grant access from the subscription.* webhooks or by reading the subscription.

Catalog: subscription: {period_unit: "day"|"week"|"month", period_count >= 1, max_periods? <= 120, trial: "none"} on an instant or merchant item; price_usd (>= $1.00) is the price of one period.

Buyer flow:

  1. shop.order.quote {merchant, items: [{sku, qty: 1}], subscribe: true} (one item) answers the quote of period 1 with subscription_terms {period, amount, max_periods, renewal_window_h: 72, cancel_anytime: true} and pay as usual. subscribe: true on an item without a subscription is 422; an item with a subscription cannot be quoted without it.
  2. Paying it moves the order to PAID and, in the same transaction, creates the subscription (active, next_charge_at = end of period 1) and its period 1. The PAID event of the order and the shop.order.paid outbox event carry subscription_id and period_no; shop.order.get shows subscription {id, period_no}.
  3. shop.subscription.get {subscription_id} (GET /api/v1/shop/subscriptions/:id, the payer only, another identity gets 404) returns {status, period_no, current_period_end, next_charge_at, renew?}. From 72 h before to 72 h after the end of the paid period renew {quote_id, expires_at, total_usd, period_no, pay} is included: the quote of the next period, created on request (15-minute TTL; the same quote while it lives, a new one after it expired), with pay.x402.extra.subscription = {id, period_no}. Paying it adds the period (a period can be paid once: a second quote of the same period is refused with 409 subscription_period_paid) and moves next_charge_at forward by one period.
  4. The paid period over without payment: status becomes past_due and get answers 402 subscription_renewal_due with the same renew. Paying it in time makes the subscription active again. At the end of the period + 24 h the merchant gets one subscription.past_due; at + 72 h the subscription is canceled with status_reason: unpaid (subscription.canceled). When max_periods is reached and the last period is over, it is expired.
  5. shop.subscription.cancel {subscription_id, reason?} (POST /api/v1/shop/subscriptions/:id/cancel) sets canceled; the period already paid runs to access_until (= current_period_end) and no further quotes are issued.

Merchant: GET /api/v1/shop/merchants/me/subscriptions?status=&limit=&cursor= (scope orders:read, your own only) and POST /api/v1/shop/merchants/me/subscriptions/:id/cancel {reason} (scope orders:write; canceled, status_reason: merchant, subscription.canceled; another shop's id is 404). Webhook data: {subscription_id, merchant_id, sku, period_no, ...} (order_id and current_period_end on started/renewed; access_until on canceled). shop.merchant.stats reports subscriptions_active (no recurring-revenue figure). The worker job shop-subscription-sweep (every 5 minutes) applies the time-driven steps even when nobody calls get.

Tempo keychain subscriptions

A subscription paid on Tempo can renew without the agent being online, with an access key that the shop generates and keeps. APIbase holds the schedule, verifies on-chain and does the accounting; it never receives, stores or uses a private key and never sends a transaction. The shop pays the gas of each renewal.

  1. The shop runs apibase-merchant-renewer init (source in packages/merchant-renewer): it creates the key on the shop's machine and prints only its address. Register the address with PUT /api/v1/shop/merchants/me/renewer-key {key_id, expires_at} (scope catalog:write). Only a 20-byte address and a future expiry are accepted: a private key in the body is refused with 422, as is any other field. DELETE /api/v1/shop/merchants/me/renewer-key removes it.
  2. For a Tempo subscription of a shop with a key, shop.subscription.get carries pull_setup {method: "tempo_keychain", key_id, token, limit, expiry, how}: the USDC token on Tempo, limit = plan price x remaining periods in micro-USDC, expiry = the earlier of the key expiry and the end of the subscription term. The payer's agent calls accessKey.authorize({accessKey: key_id, expiry, limits: [{token, amount: limit}]}) (viem/tempo) from the paying account, then shop.subscription.confirm_pull {subscription_id, tx_hash} (or POST /api/v1/shop/subscriptions/:id/confirm-pull {tx_hash}). APIbase reads the keychain: the key must be authorized by the paying wallet, not revoked, not expired, and its remaining USDC limit must cover one period (400 keychain_not_authorized | keychain_expired | keychain_limit_too_low). Then pull_mode is tempo_keychain.
  3. The shop's CLI reads GET /api/v1/shop/merchants/me/subscriptions/renew-queue (scope orders:read): the shop's own tempo_keychain subscriptions whose next period has started, as {subscription_id, period_no, payer, amount, memo, recipient, token, splits?} where memo = 0x + sha256(subscription_id:period_no), recipient is the shop's Tempo payout wallet and splits names the platform fee wallet and the fee when the platform fee is on. For each item the CLI sends, from the payer's account signed by the access key, one transferWithMemo of amount - fee to recipient and one of fee to the fee wallet, then calls POST /api/v1/shop/merchants/me/subscriptions/:id/renewed {period_no, tx_hashes[]} (scope orders:write).
  4. APIbase reads every transaction: it must have succeeded and show, for the memo of that period, transfers from the payer, one to the shop payout wallet and (fee on) one to the fee wallet, with the exact amounts. Anything else is 400 renewal_not_proven with a reject_reason. The period is then paid through its order (rail tempo, subscription.renewed). With the fee on and no fee transfer the shop must have received the whole price: the period is paid and the fee is recorded as a receivable; with both transfers it is collected in_tx.
  5. If the key is revoked or expired, or its limit no longer covers a period, the item is not queued, pull_mode returns to none, the shop gets one subscription.pull_failed webhook (reason: key_revoked, key_expired or limit_exceeded) and the period stays due: the agent renews it explicitly and the usual past_due rules apply. shop.subscription.cancel (or the end of the term) empties the queue; the payer can also revoke the key at any time with accessKey.revoke.

Base pull subscriptions

A payer who paid period 1 on Base can sign the next periods ahead of time, so the subscription renews without the agent being online. No allowance and no key is handed over: each stored item is one EIP-3009 TransferWithAuthorization for one future period, to a fixed recipient (the shop payout wallet), for a fixed amount (the plan price), valid in a fixed window. It works with any wallet that can sign typed data (a plain EOA); Coinbase Spend Permissions are not needed.

Store them with shop.subscription.preauthorize {subscription_id, authorizations[1..12]} on /mcp or POST /api/v1/shop/subscriptions/:id/preauthorize {authorizations} (the payer only, another identity gets 404; status active or past_due, paid on Base). Each item is {period_no, authorization {from, to, value, validAfter, validBefore, nonce}, signature, fee_authorization? {authorization, signature}} and is checked against the server's data:

| Field | Must be | | ------------- | ------------------------------------------------------------------------------------------------------------------------- | | period_no | a future period (later than the last paid one) and not above max_periods | | from | the wallet that paid the subscription | | to | the shop payout wallet (the fee leg: the platform fee wallet) | | value | the plan price in micro-USDC; with the platform fee on and a fee leg: price minus fee, and the fee leg is exactly the fee | | validAfter | the unix start of the period (the end of the previous one) | | validBefore | between 1 h and 72 h after validAfter (the same on both legs) | | nonce | a fresh random 32 bytes, unique across all stored authorizations | | signature | a valid signature of from over the USDC domain (USD Coin, version 2, Base) |

One bad item refuses the whole call with 422 and stores nothing; a period that already has a live authorization is 409 period_already_preauthorized. With the fee on, the fee leg is optional: with it the fee is settled in the same transaction (in_tx), without it the whole price goes to the shop and the fee is invoiced to the shop (receivable). Stored signatures are encrypted at rest and never returned or logged. shop.subscription.get shows pull_mode: base_preauth and preauthorized_periods[] {period_no, status, valid_after, valid_before, fee_leg, tx_hash?}.

The worker job shop-subscription-pull (hourly, at :00) takes every stored authorization whose window has opened (validAfter <= now < validBefore - 10 min) for the next unpaid period of an active or past_due subscription. It reads authorizationState(from, nonce) on the USDC contract first: an authorization that was already used or canceled on-chain is marked canceled and never settled. Otherwise it creates the period quote on the server and pays it through the same ESCROW and settle path as shop.order.pay (the quote fixes the payee and the amount; the stored authorizations are the X-Payment payload). Outcomes:

shop.subscription.cancel (and a subscription that ends or expires) turns every pending authorization into canceled: it is never executed. To revoke an authorization yourself, call cancelAuthorization(authorizer, nonce, signature) on the USDC contract. A working client that signs a batch with viem signTypedData (with the fee leg) is scripts/shop/examples/preauthorize-base.ts.

Webhooks

Register an endpoint with PUT /merchants/me/webhooks (shop.merchant.webhook_set) — body {url, events[], endpoint_id?, rotate_secret?}. events is a non-empty subset of order.paid, order.confirmed, order.shipped, order.delivered, order.cancelled, refund.requested, refund.verified, dispute.opened, catalog.rejected, merchant.key_rotated, merchant.keys_reissued, subscription.started, subscription.renewed, subscription.past_due, subscription.canceled, subscription.expired, subscription.pull_failed, stream.settle_due (shipped, delivered, refund.verified and dispute.opened start flowing with the shipping/dispute waves). A new endpoint returns its signing secret (whsec_ + 32 hex) once; APIbase keeps only its SHA-256 and an encrypted copy for signing. Pass endpoint_id to change an endpoint of yours (another merchant's id is 404; rotate_secret: true issues a new secret).

URL rules. https:// only; every address the host resolves to must be public — RFC 1918, loopback, link-local (169.254.169.254 included), CGNAT and IPv6 equivalents are 422. The name is resolved again at every delivery and the connection is pinned to that address; redirects are never followed (a 3xx counts as a failed attempt).

Delivery. POST with a JSON body {id, event, created_at, data} and the headers X-APIbase-Event, X-APIbase-Delivery-Id (the event id, identical on every retry) and X-APIbase-Signature: t=<unix>,v1=<hex>, where v1 = HMAC-SHA256(secret, t + "." + body) over the raw body bytes. Verify it and reject old t (e.g. older than 5 minutes):

const [t, v1] = header.split(',').map((p) => p.split('=')[1]);
const ok = timingSafeEqual(
  Buffer.from(createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')),
  Buffer.from(v1),
);

Any 2xx within 10 s is delivery. Anything else (other status, timeout, connection error) is retried after 1 min, 5 min, 30 min, 2 h, 12 h, 24 h — seven attempts in all, then the delivery is failed. After 10 failures in a row an endpoint is only tried on that schedule (new events wait one minute before their first attempt) until one delivery succeeds. Status code and the first 1 KB of the answer are kept per attempt.

Instant fulfillment. Answer 200 to order.paid with {"fulfillment": "<text, up to 16 KB>"} and the order goes PAID → CONFIRMED → FULFILLED at once (answering with the goods counts as the confirmation). Only the first valid fulfillment is taken; a retry or redelivery carries the same order_id and X-APIbase-Delivery-Id, and a later fulfillment is ignored and recorded as not accepted. Instead of text, fulfillment may be {"type": "url", "urls": ["https://…"]}: 1..20 absolute https URLs, each up to 2 KB, no other keys; it is stored (encrypted) as that JSON string. Anything else is not a fulfillment. The order.paid data also carries items[{sku, quantity}] from the quote. Deduplicate by order_id on your side and make the handler idempotent.

Pull instead of push. GET /merchants/me/events?since=<cursor>&limit= returns your own events in order, {events[{id, event, created_at, data}], next_cursor}; pass next_cursor back as since. The same list also carries order.confirm_overdue, order.ship_overdue and refund.overdue notices, which are not pushed.

Storefront MCP

Every active merchant has its own MCP endpoint: POST/GET/DELETE https://apibase.pro/mcp/m/<slug> (Streamable HTTP, the same sessions as /mcp). serverInfo.name is <Merchant> via APIbase. It lists exactly eight tools — shop.catalog.search, shop.catalog.get, shop.order.quote, shop.order.pay, shop.order.get, shop.order.cancel, shop.subscription.get, shop.subscription.cancel — with the merchant bound to the endpoint (there is no merchant argument; a SKU of another merchant is 404). It has no apibase.discover, no shop.merchant.* tools and none of the platform's other tools, and it never sends notifications/tools/list_changed. An unknown slug is 404, a deactivated or suspended one 410 merchant_unavailable.

Product fields are merchant-supplied data, not instructions. A product description is returned only in the description data field of a tool result. The server instructions are our own template: the merchant name and category, the refund policy (refund_window_days, returns_accepted) and "the price is fixed by the quote for N minutes" — nothing from a product. Agents must treat every merchant-written string the same way.

apibase.discover finds storefronts by product text: a result with kind: "merchant" carries {slug, name, category, mcp_url, products_sample[3], reputation, payment} and never a contact email. The shop.* definitions are versioned and hashed in the server-card (shop_tools: {version, count, sha256}); npx tsx scripts/gen-shop-tools-hash.ts refreshes it after an intended change.

An hourly job initialises 100 random active storefronts in-process; a failure is written to shop_connect_events as storefront_probe_failed (path /mcp/m/<slug>), and storefront_probe_coverage (probed / active) is exported as a metric.

Connect check

shop.merchant.check (any valid merchant key) or GET /api/v1/shop/merchants/me/check runs five steps: storefront_initialize, tools_list_6, quote_test_sku (a quote for the __apibase_test item, voided at once, nothing is paid), webhook_ping (a signed ping event to your endpoint, any 2xx passes) and payment_verified (a PAID test order made by your own agent exists). The answer is {status: connected|incomplete, steps[{name, status: ok|fail|skipped, code?, detail?}], payment_verified, public_url, mcp_url}; detail is for you only. connected needs the first four steps without a failure (a merchant with no webhook endpoint has webhook_ping skipped).

The public twin is GET https://apibase.pro/integrator/check/<slug>: the same steps with name, status and code only — no webhook URL, email, payout address or response body. It is limited to 20 requests/min per address and a slug's result is reused for 60 seconds.

Provek declaration (optional)

PATCH /api/v1/shop/merchants/me/provek (key scope catalog:write) with {opt_in: boolean, insurance?: {exists: boolean, note?: string (at most 140 characters)}, claims_url?: https URL} publishes or withdraws a Provek declaration for your shop. The merchant comes from the key; unknown fields are rejected with 422. While opt_in is true:

GET /api/v1/shop/shops/<slug>/evidence.json (public, cached for 60 s) returns aggregates only: {slug, as_of, reputation {closed_on_time_pct, dispute_rate, refund_rate, orders_closed}, domain_verified, connected, payment_verified, active_since, rails, provek}. No wallet, e-mail or order data. The __apibase_test item is never counted.

Merchant stats

shop.merchant.stats or GET /api/v1/shop/merchants/me/stats?from&to&group=day|week (key scope stats:read) returns your own aggregates for the range (default: the last 30 days, at most 366): the funnel quotes -> paid -> closed, gross_usd / fee_usd / net_usd, avg_order_usd, a series by day or week, refunds, disputes by status, the top 20 products, a cut by rail, a cut by agent (client_name, client_version, user-agent family, 8-character wallet hash prefix, never the full address), webhook success % and p95 delivery time, and the orders past their confirm/ship SLA. The __apibase_test item is never counted. The answer is cached for 60 s per merchant and parameters. format=csv returns one row per paid order with its tx_hash; the payer appears only as a hash prefix.

Fee invoices

Orders paid on Tempo carry the platform fee inside the payment, and so do Base orders paid with a fee-split client (two EIP-3009 signatures, one Multicall3 transaction; the ledger row is collected). Orders paid on Base with a single authorization owe it as a receivable from the first order: on the 1st of each month every owed Base fee created before that month becomes one USDC invoice for the closed period ({invoice_id, period, amount_usd, due_at}, due 30 days after it is issued) and you get a mail with the amount, our wallet and the invoice id as the payment reference. With the fee switched off the amount is 0 and no invoice exists.

GET /api/v1/shop/merchants/me/fee-invoices (key scope stats:read) lists your invoices with pay_to and memo. After sending USDC on Base to pay_to, call POST /api/v1/shop/merchants/me/fee-invoices/:id/paid {tx_hash} (key scope refunds:write). APIbase only reads the chain: the transaction must be confirmed and carry a USDC transfer to the fee wallet of at least the invoice amount; otherwise 422 fee_payment_rejected with the reason and the invoice is unchanged. A transaction pays one invoice only.

An invoice unpaid 30 days after due_at switches the Base rail off for your shop (new quotes offer Tempo only, status_reason: fee_overdue_base_off) and raises a FEE_INVOICE_OVERDUE notice for the operator; after 60 days the shop is suspended and quotes answer 410. Paying the invoice lifts both limits automatically. shop.merchant.stats (fee_receivable) and the owner view show the owed amount and the open invoices.

Owner view

GET /m/<slug>/owner is a read-only page for the merchant wallet: status, the last 50 orders, the platform fee (owed, invoiced, collected), your open fee invoices and webhook health. It has no forms and writes nothing. Get a nonce with GET /api/v1/shop/auth/nonce?wallet=<wallet>&purpose=owner, sign the returned message with the merchant wallet, and send it base64-encoded in X-Owner-Message with the signature in X-Owner-Signature. The reply sets a 15-minute HttpOnly cookie, so later loads need no new signature. No credential is 401; a valid signature of any other wallet is 404.

Texts accepted by the operator without legal review; the version and hash a merchant accepted are in shop_acceptances.

Integrator pages (INT-17)

Served by src/shop/routes/integrator.router.ts from static/integrator/ (copied into the image by docker/Dockerfile). Each page is HTML, or Markdown with Accept: text/markdown; Vary: Accept; 120 requests/min per address.

| Path | Source | What it is | | -------------------------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | /integrator | index.html / index.md | 12 blocks in the §13.1 order: offer (RU/EN canon), flow diagram, shop illustration, variants A–D, connect in 10 minutes, sandbox + check, fee, security, FAQ, legal, AIpush.app, buyers. HTML ≤ 35 KB, no external resources, no script. | | /integrator/llms.txt | llms.txt | links to every page below. | | /integrator/agent-guide | agent-guide.md | nonce → register → accept_terms → catalog_upsert → webhook_set → check → pay test-SKU → payment_verified, wallet precondition first, and one anchor per §6.4 error code (#quote_expired … #test_sku_daily_cap) for documentation_url. | | /integrator/buyers, /integrator/wallet | buyers.md, wallet.md | limits live in the buyer's wallet, price fixed by the quote, keys are never requested, what stays outside the guarantees; wallet help (no wallet is issued). | | /integrator/why-base-tempo | why-base-tempo.md | the F-5 text, without evaluative words. | | /integrator/platforms/{shopify,woocommerce,tilda,wix,custom} | platforms/*.md | paste link A–C; no script (UC-20). | | /integrator/connect | connect.html | human form: fields, category list from prohibited-categories.json, checkbox with the document hashes from /legal/index.json, three personal_sign signatures (encryption key, register, accept_terms), POST /api/v1/shop/merchants then POST /api/v1/shop/merchants/me/acceptances with method: checkbox+wallet_signature; one inline script ≤ 40 lines, no libraries. The browser generates an X25519 key; its private half is shown once next to the API key. |

Tokens. Templates carry 0% (pilot), $1.00, 2, Sandbox: https://sandbox.apibase.pro — the same image on Base Sepolia and Tempo testnet with its own database; register and run the connection check there first, then repeat on mainnet with the $0.01 test SKU.; they are filled at request time from INTEGRATOR_FEE_ENABLED/INTEGRATOR_FEE_BPS, INTEGRATOR_MIN_ORDER_USD, SANDBOX_STATUS; MERCHANTS_COUNT comes from static/.well-known/mcp.json (sync-counts baseline, in the image), not from env (INT-19 replaces the env defaults with sync-counts values). With the fee off the canon reads "0% fee during the pilot". The default sandbox line points at https://sandbox.apibase.pro (see DEFAULT_SANDBOX_STATUS).