Architecture

Status: describes the code on main as of 2026-10-17. File paths are relative to the repository root.

On this page (12 sections)

System#

DiagramFlow
Mermaid source
flowchart LR
  OA["Owner's agent"] -- MCP --> PB[PayBox]
  BA["Buyer's agent"] -- MCP --> PB
  PB -- "use_plugin (fresh sandbox per call)" --> PL["Awning plugin<br/>plugin/dist/plugin.mjs"]
  PL -- "HTTPS to api.awning.sh<br/>+ brokered x-awning-plugin-key" --> API
  PB -- "x402: unpaid quote, then paid retry" --> X4

  subgraph WEB["web/ : one Next.js 15 app on Vercel"]
    API["/v1/* JSON API"]
    X4["/x402/* seller"]
    SF["Storefronts by Host<br/>&lt;slug&gt;.awning.sh, custom domains"]
    RT["awning.sh: /, /live, /directory"]
    VO["/v1/voice/:slug TwiML"]
  end

  API --> DB[("Postgres (Neon)<br/>or PGlite locally")]
  X4 --> DB
  SF --> DB
  X4 -- "/verify, /settle" --> FA["x402 facilitator<br/>PayAI by default"]
  FA -- "transferWithAuthorization" --> CH[("USDC on Base")]
  X4 -- "authorizationState, refunds" --> CH
  API -- "draft" --> AN["Claude: Anthropic or OpenRouter (else Vercel AI Gateway)"]
  API -- "photos, after the response" --> AG["Vercel AI Gateway images"]
  API -- "check prices" --> PK[Porkbun]
  API -- "search numbers" --> TW[Twilio]
  X4 -- "register, DNS" --> PK
  X4 -- "attach domain" --> VC["Vercel Domains API"]
  X4 -- "buy number, owner SMS" --> TW
  X4 -- "create agent, import number" --> EL["ElevenLabs"]
  CA(("Caller")) -- phone --> TW
  TW -- "voice webhook (no ElevenLabs agent)" --> VO
  TW -- "media (with an agent)" --> EL
Open full size
ComponentCodeRuns
Pluginplugin/src/*.ts, bundled to plugin/dist/plugin.mjsIn the PayBox sandbox, one fresh process per call
JSON APIweb/app/v1/**Vercel functions
x402 sellerweb/app/x402/**, web/lib/x402/*Vercel functions, maxDuration = 90
Payment reconcilerweb/lib/x402/reconcile.ts, web/app/v1/cron/reconcile/route.tsAfter health checks, a daily Vercel cron, a 10-minute GitHub workflow
Opening orchestratorweb/lib/orchestrator/machine.ts, steps in web/lib/services/open.tsInside the paid /x402/open request
Provider adaptersweb/lib/adapters/*Called from services; mock when keys are missing
Trust and safetyweb/lib/policy/* (screening, quotas, reports, takedown)Inside /v1/plan, /v1/open-free, offer creation, the x402 quotes, /v1/reports, /v1/admin/*
Storefront photographyweb/lib/services/images.ts, web/lib/plan/photos.ts, web/app/img/[id]/route.tsScheduled with Next.js after() once the plan response is sent
Storefronts, landing, /live, /directoryweb/app/**, routing in web/middleware.ts and web/lib/host.tsSame Vercel project
Databaseweb/lib/db/schema.ts, migrations in web/drizzle/Neon over HTTP (@neondatabase/serverless), or embedded PGlite when DATABASE_URL is unset

The plugin sandbox#

The plugin is a standard MCP server (@modelcontextprotocol/server 2.0.0, protocol 2026-07-28) built from PayBox starter 1.0.2. The PayBox contract (developer guide) sets its shape:

  • Stateless. Every operation runs in a fresh sandbox. Nothing in memory or on disk survives between calls. All state lives in the Awning API.
  • 10-second budget per hosted operation, including startup. The plugin does no network I/O at startup or during tool discovery.
  • One network host: api.awning.sh. The API base can be overridden with AWNING_API_URL, which must be a plain https:// URL (or http://localhost/127.0.0.1 for local runs) without credentials, query or fragment (plugin/src/awning.ts apiBase).
  • Brokered header. PayBox adds x-awning-plugin-key to requests for api.awning.sh at egress. The plugin never reads or sends it, and PayBox withholds any output containing its literal value.
  • Bounded requests. 4 s timeout per request (FETCH_TIMEOUT_MS), 8 s for awning_plan (PLAN_TIMEOUT_MS), redirect: "error", responses over 1,000,000 characters rejected.
  • Money only through proposals. awning_open and awning_buy return one io.paybox/transactionProposal of kind x402 built by the starter's proposeX402. The sandbox invocation ends before approval; the paid response reaches the agent through the PayBox request, not through the plugin.
  • Proposal amount is checked twice. Before proposing, the paid tools read Awning's stored price and the unpaid 402 challenge in parallel and refuse if they differ. PayBox then checks the live 402 against maxAmount again before asking for approval.
  • Errors are isError: true results with one sentence that says what to do next. Upstream payloads, headers and tokens are never returned in errors.

Tool reference: The PayBox plugin.

The API#

All routes are Next.js route handlers wrapped by handler() (web/lib/api.ts), which turns an unexpected exception into 500 {error: "internal_error", message: "Something went wrong (err_…)."} with no stack or secret. JSON bodies are parsed by parseBody (64 KiB limit, empty body = {}, first zod issue as the message).

Auth classCheckRoutes
Publicnone; CORS * on GETsGET /v1/health, /v1/businesses, /v1/businesses/:slug, /v1/businesses/:slug/offers, /v1/live, /img/:id; POST /v1/reports
Operatorx-awning-admin-key equals AWNING_ADMIN_KEY (constant-time); unset key → 503POST /v1/admin/businesses/:slug/status
CronAuthorization: Bearer <CRON_SECRET> (constant-time); unset secret → 503GET /v1/cron/reconcile
Pluginx-awning-plugin-key equals AWNING_PLUGIN_KEY (constant-time). Unset key: allowed in development, 503 misconfigured in productionPOST /v1/plan, GET /v1/plans/:planId, POST /v1/open-free
Plugin + ownerplugin key, and x-awning-owner-token for that businessPOST /v1/businesses/:slug/offers, GET /v1/orders
x402none (PayBox cannot send custom headers on payment requests); the payment is the authorization. Every response, and an OPTIONS preflight, carries CORS headers/x402/open/:planId, /x402/b/:slug/:sku
TwilioX-Twilio-Signature HMAC-SHA1 when TWILIO_AUTH_TOKEN is setPOST /v1/voice/:slug

Endpoint reference: API reference.

Planning (POST /v1/plan)#

Everything slow happens here, so the paid opening only has to provision. A sentence that breaks the acceptable use policy is refused before any provider call; a draft that breaks it is refused before anything is stored (web/lib/services/plans.ts createPlan).

DiagramSequence
Mermaid source
sequenceDiagram
  participant P as Plugin
  participant A as API
  participant L as Anthropic
  participant R as Porkbun
  participant T as Twilio
  P->>A: POST /v1/plan (8 s client timeout)
  A->>A: screen the sentence, check the wallet's plan quota (block → 422 / 429, nothing stored)
  A->>A: template draft (instant)
  par in parallel
    A->>R: checkDomain(template name candidates), 8 s
    A->>T: search local numbers, 5 s
    A->>L: LLM draft (Anthropic, OpenRouter or AI Gateway), AWNING_LLM_TIMEOUT_MS = 5 s
  end
  alt LLM renamed the business
    A->>R: checkDomain(new candidates), max(1.2 s, 6.5 s − elapsed)
    Note over A: no answer in time → fall back to the template so name and domains agree
  end
  A->>A: screen the draft (name for brand impersonation, copy, offers)
  A->>A: finalize offers, pick 3 domains, receptionist prompt, DNS records
  A->>A: insert business (draft), offers, plan (quoted)
  A-->>P: 201 plan
  A->>A: after the response: generate two photos (capped per hour)
Open full size

What a plan stores in plans.prepared: the receptionist name, system prompt and first message; the DNS records (A @ 76.76.21.21, CNAME www cname.vercel-dns.com, overridable); up to three phone number candidates and the country; and whether the draft came from the LLM or the template. plans.domains stores, per domain, the registrar price in cents and atomic USDC and the Pro total in atomic USDC.

The LLM (Anthropic's Messages API with a forced tool call, or Claude Haiku 5.5 through OpenRouter, or the Vercel AI Gateway, both with a strict JSON schema and reasoning off; web/lib/adapters/drafter.ts) proposes and fixed rules decide (finalizeDraft): lengths are clipped, the category must come from the catalogue, the palette must reach contrast 7:1 (ink on paper) and 3:1 (paper on primary) or the template palette is used, prices are rounded to cents and clamped to $1–$10,000, duplicate titles are dropped, prices the owner stated are added if missing, and offers are padded to at least 3 and cut to 5.

The opening orchestrator#

A Pro opening is a saga that runs inside the paid x402 request, between payment verification and settlement (web/lib/services/open.ts openPro, web/lib/orchestrator/machine.ts runSaga).

Quote-bound. The amount charged is plans.domains[].totalAtomic, fixed at plan time. The preflight step re-checks the registrar and fails if the domain is gone or its price in cents changed; register_domain passes the quoted cents to Porkbun, which refuses a different live price. A price change can therefore only cause a failure before settlement, never a different charge.

Idempotent. Four layers:

  1. Per payment. The x402 idempotency key is the signed authorization (network:asset:payer:nonce). A key seen before replays its stored response (below).
  2. Per plan. The work starts by claiming the plan with a conditional update: quoted, or opened + free (upgrade), or opening older than 3 minutes (a crashed attempt). A plan already opened + pro is never provisioned again; that payment is answered alreadyOpen: true, charged: false and not settled. A plan being opened by another payment answers 409 open_in_progress and releases the key so the payer can retry.
  3. Per provider. Porkbun registration uses Idempotency-Key: awning-open-<planId>-<domain>, DNS records use awning-dns-<planId>:<i>, and Vercel's "already attached to this project" counts as success. A preflight that finds the domain already in Awning's registrar account continues, and the registration call replays the earlier one.
  4. Per business. The owner token is derived from the business id, so a retried open returns the same token.

Steps (in order; each has its own timeout, capped by what is left of the shared deadline AWNING_OPEN_DEADLINE_MS; a step that would start with 250 ms or less left fails):

#StepProvider callTimeoutUndo
1preflightregistrar check of the chosen domain6 snone needed
2phoneTwilio: buy a plan-time candidate, else search again (falls back to US); voice webhook → /v1/voice/:slug9 srelease the number
3receptionistElevenLabs: create the agent from the prepared prompt, pointed at the chosen domain9 sdelete the agent
4connect_phoneElevenLabs: import the Twilio number with agent_id. Skipped when either side is a mock9 sdelete the import
5hostingVercel: add domain and www.domain (308 → apex)8 sremove both
6register_domainPorkbun domain/create at the quoted cents15 snone: irreversible, so it runs last among the costly steps
7dnsPorkbun: delete parking records, create A and CNAME (1 retry)8 snone
8go_liveDB: business live, package = pro, plan opened, opened event——

go_live runs inside commit, after settlement. Every step records {name, status, ms}; the response returns them as steps.

Compensation. When a step fails, the completed steps are undone in reverse order, each with an 8 s timeout. An undo that fails marks its step compensation_failed and logs an error; the resource stays and needs manual cleanup. The same undo runs if settlement fails, or if the commit fails after settlement. The plan returns to quoted (or opened + free for an upgrade) with last_error set. A registered domain is not undone; it stays in Awning's registrar account, and the next opening attempt for that plan reuses it.

Deadline. AWNING_OPEN_DEADLINE_MS, default 26000 ms (web/lib/env.ts): paying the registrar in USDC is its own x402 round trip, so register_domain gets up to 22 s; preflight (availability, price, balance and a no-charge dryRun registration that catches an unverified registrar account or a spent monthly limit before any provider is touched) gets 9 s. See latency budgets.

x402 seller#

Both paid endpoints use one lifecycle, handlePaidRequest in web/lib/x402/flow.ts, with the 402 construction and payment matching in web/lib/x402/seller.ts (@x402/core and @x402/evm 2.28.0).

POST /x402/open/:planIdPOST /x402/b/:slug/:sku
Amountthe plan's totalAtomic for the domainoffer price × qty
payToAWNING_TREASURY_ADDRESSthe owner's wallet (businesses.owner_wallet)
Work before settlementthe provisioning sagainsert the order (pending)
Commit after settlementbusiness live, plan opened, eventorder paid, sales_count + 1, event, owner SMS (capped at 3 s)
Refundable by Awningyes, from the treasuryno: the money is in the owner's wallet
DiagramSequence
Mermaid source
sequenceDiagram
  participant PB as PayBox
  participant S as Awning /x402
  participant DB as payments table
  participant F as Facilitator
  participant B as USDC on Base
  PB->>S: POST (no payment header)
  S-->>PB: 402 PAYMENT-REQUIRED (v2 header) + v1 JSON body
  Note over S: quote only: no writes, no provider calls
  PB->>S: POST + PAYMENT-SIGNATURE (v2) or X-PAYMENT (v1)
  S->>DB: key seen? → replay stored response (or 409 in progress)
  S->>S: match scheme, network, asset, amount, payTo, validity window
  S->>F: /verify
  S->>DB: insert key, status working (unique: a concurrent duplicate loses)
  S->>S: work (saga, or order row)
  S->>F: /settle (only after the work succeeded)
  alt facilitator answer is not a clean success
    S->>B: authorizationState(payer, nonce)
  end
  S->>S: commit
  S->>DB: store status + response
  S-->>PB: 200 + PAYMENT-RESPONSE, X-PAYMENT-RESPONSE
Open full size

Unpaid request. quoteOpen / quoteBuy read the plan or offer and build the charge. If the request is valid, the response is 402 with the v2 PAYMENT-REQUIRED header (base64 JSON, x402Version: 2, amount, network eip155:8453) and a v1 JSON body (x402Version: 1, maxAmountRequired, network base) plus a readable awning quote. Building it does no writes and calls no provider (tested in web/tests/x402-quote.test.ts). The same quote answers GET.

Paid request, in order:

  1. Replay first. Before the quote is recomputed, a payment key with a stored response gets that response again, so a later change (plan expired, offer removed, Pro no longer configured) cannot turn a completed payment into an error. A key without a stored response gets 409 payment_in_progress until the request finishes or, if it was interrupted, until the reconciler stores a definitive answer.
  2. Exact match. For v2, the declared accepted terms must equal the live quote (scheme exact, network eip155:8453, asset USDC, amount, payTo). For both versions the signed authorization's to and value must equal payTo and the amount, validBefore must be more than 3 s away, validAfter at most 5 s ahead, and the nonce 32 bytes. Any mismatch returns a fresh 402 whose error names the reason (amount_mismatch, payto_mismatch, wrong_network, …).
  3. Verify with the facilitator against the server's own requirements, never the client's. Invalid → 402 with the facilitator's reason.
  4. Claim the key: insert into payments with status working. If the insert conflicts, another request owns it: 409 payment_in_progress.
  5. Work. Never moves the customer's money. A thrown error becomes 500 work_failed, charged: false.
  6. Settle only if the work succeeded. If the facilitator does not return a clean success, USDC's on-chain authorizationState(payer, nonce) decides: used → treat as paid (unconfirmed); not used → one more settle attempt for transient reasons; definitively refused → failed.
  7. Commit, store the response, return it with PAYMENT-RESPONSE and X-PAYMENT-RESPONSE.

Pay only on success. Work failure → the authorization is never settled; 502 provisioning_failed (open) with charged: false, the name of the failed step and the steps. Provider error text is logged, never returned. Settlement failure → the work is undone; 402 settlement_failed, charged: false.

Refund after settlement. If the commit fails after the money moved, the work is undone and, for openings (payTo = treasury), the refunder sends the same amount of USDC from the treasury key to the payer and waits for the receipt: 502 {refunded: true, refundTx, reason, charged: true, paidUsd, tx}, payment status refunded. reason is a fixed public sentence; the internal reason is stored in payments.error. If the refund transfer fails: 502 {refunded: false, reason, charged: true, paidUsd, tx, message} saying the operator has been alerted, status stays settled, and the log line REFUND FAILED — manual action required names the payer and amount.

Refunds are never simulated with real payments. ViemRefunder is used only when AWNING_TREASURY_PRIVATE_KEY is a 32-byte key whose address equals AWNING_TREASURY_ADDRESS (treasuryRefundProblem in web/lib/adapters/refunder.ts). Otherwise the refunder is UnavailableRefunder, which always fails, so the answer is refunded: false and the operator is alerted. Pro is not sold without a working refunder (web/lib/services/readiness.ts). The mock refunder exists only with the mock facilitator. For orders, a commit failure after settlement marks the order failed and answers 502 delivery_failed, charged: true; the buyer must contact the seller.

Expiry. Unpaid open requests after expiresAt → 410 quote_expired. Paid requests get MAX_TIMEOUT_SECONDS (300 s) of grace so a payment signed just before expiry completes.

CORS. Every x402 response, including 402s, replays, 409s and paid answers, has access-control-allow-origin: * and exposes PAYMENT-REQUIRED, PAYMENT-RESPONSE, X-PAYMENT-RESPONSE. OPTIONS answers a preflight that allows content-type, payment-signature and x-payment.

Facilitator. HttpFacilitator uses @x402/core's HTTPFacilitatorClient against AWNING_FACILITATOR_URL (default https://facilitator.payai.network) with a 12 s timeout. MockFacilitator (AWNING_FACILITATOR_URL=mock) verifies the EIP-3009 signature with viem against the USDC-on-Base EIP-712 domain and settles in memory; it refuses to start in production.

Reconciler#

A paid request can stop before it stores an answer: the function is killed, or settlement ends unconfirmed (the facilitator's answer was unclear and the chain could not be read). web/lib/x402/reconcile.ts finishes those payments, using USDC's on-chain authorizationState(payer, nonce) as the truth.

It looks at payments in working or unconfirmed whose updated_at is older than 3 minutes (longer than any paid request can live), at most 25 per run (10 when triggered by the health check).

Payment statusMoney moved on chain?PurposeResultStored answer for a replay
workingnoeitherfailed; an open's plan is released back to quoted (or opened if the business is already live on Free)503 {error: "work_interrupted", charged: false, refunded: false}: the payer may pay again
workingyesopentreasury refund → refunded; plan released502 {refunded: true, refundTx, reason, charged: true, paidUsd}
workingyesopen, refund failssettled; OPERATOR: REFUND REQUIRED logged; plan released502 {refunded: false, charged: true, paidUsd, message}
workingyesordersettled; OPERATOR: order settled to the seller but not recorded logged502 {error: "delivery_failed", charged: true, refunded: false, paidUsd}
unconfirmedyeseithersettledunchanged (the original success)
unconfirmedno, and more than 15 minutes oldeitherfailed; OPERATOR: delivered without settlement logged. The business stays open or the order stays recordedunchanged
unconfirmedno, youngereitherunchanged; checked again next run—
anychain read failseitherunchanged—

Each actionable row is claimed first with an optimistic update on updated_at, so two runs (cron and health) never act on the same payment twice. An interrupted open also logs OPERATOR: interrupted paid open — check provider resources for this plan with the plan's steps and provider ids: undoing provider resources created before the interruption is left to the operator.

It runs from three places: after every GET /v1/health that reached the database (scheduled with after()), a daily Vercel cron (17 4 * * *, web/vercel.json), and a GitHub workflow every 10 minutes (.github/workflows/reconcile.yml). The cron and the workflow call GET /v1/cron/reconcile with Authorization: Bearer $CRON_SECRET.

Multi-tenant hosting#

One deployment serves every host. web/middleware.ts resolves the tenant from x-forwarded-host (or host) with the pure function resolveHost in web/lib/host.ts:

HostTenant/ is rewritten to
awning.sh, www., api., app., localhost, IPs, *.vercel.app, AWNING_EXTRA_ROOT_HOSTSrootunchanged; /?b=<slug> → /s/<slug> (local preview)
<slug>.awning.sh, <slug>.localhostthat business/s/<slug>
anything elsecustom domain (www. stripped)/s/<domain>; the page looks it up with getBusinessByDomain (Pro businesses only)

/v1/*, /x402/*, /_next/* and a few static files pass through on every host, so the API works on api.awning.sh and on every storefront host. /s/* is the internal rewrite target: on a business host it answers 404 (blockedOnTenantHost), so one business's domain can never render another business. web/app/s/[slug]/page.tsx renders the business with its palette: hero, offers with prices, a copyable "Buy with your AI agent" prompt and the x402 URL per offer, the phone line, and "Opened with Awning". Drafts get a "Preview — not open yet" ribbon and robots: noindex. Custom domains reach the project because the orchestrator attaches them through the Vercel API and points their DNS at Vercel.

Data model#

Tables in web/lib/db/schema.ts. Amounts are atomic USDC in bigint columns; all money math is bigint in web/lib/money.ts. Ids are random base-36 strings with a prefix (pln_, biz_, off_, ord_, evt_; about 128 bits, web/lib/ids.ts). There are no foreign-key constraints; references are by id.

DiagramData model
Mermaid source
erDiagram
  plans ||--|| businesses : "business_id / plan_id"
  businesses ||--o{ offers : business_id
  businesses ||--o{ orders : business_id
  offers ||--o{ orders : offer_id
  payments ||--o| orders : "key = payment_key"
  payments }o--|| plans : "ref_id = plan id (open)"
  businesses ||--o{ events : slug
  businesses ||--o{ business_images : business_id
Open full size
TableKey columnsNotes
plansid, business_id, sentence, owner_wallet, owner_name, owner_email, owner_phone, country, status (quoted/opening/opened), domains (jsonb), recommended_domain, prepared (jsonb), open_package, open_payment_key, open_domain, open_steps, last_error, expires_atOne per POST /v1/plan
businessesid, slug (unique), plan_id, name, tagline, description, category, city, palette, owner_wallet, owner_name, owner_phone, status (draft/live/suspended), package, domain (unique), phone, receptionist_name, owner_token_version, provisioning (jsonb: provider ids for undo, mocks used), sales_count, opened_at
offersid, business_id, sku (unique per business), title, description, kind, price_atomic, active, position
ordersid, business_id, offer_id, slug, sku, qty, unit_atomic, total_atomic, payer, pay_to, network, payment_key (unique), status, tx, buyer_note, buyer_contact, fulfillment (jsonb), error, paid_at
eventsid, type (opened/order/call/report/moderation), slug, name, amount_atomic, tx, data, at/v1/live returns only opened, order and call, never data. Reports (reason, details, contact, reporter id) and moderation actions are internal
business_imagesid, business_id, role (hero/detail, unique per business), prompt, model, mime, width, height, bytes, data_b64, created_atGenerated WebP photos, schema in web/lib/db/images.ts
paymentskey (PK: eip155:8453:<usdc>:<payer>:<nonce>), purpose (open/order), ref_id, status, x402_version, network, asset, payer, pay_to, amount_atomic, tx, refund_tx, error, response (jsonb: status, body, headers)One row per accepted authorization; the idempotency store

Migrations are generated with drizzle-kit into web/drizzle/ and applied automatically on first connection unless AWNING_AUTO_MIGRATE=0.

Latency budgets#

PathBudgetWhere it is set
Hosted plugin operation10 sPayBox
Plugin → API, every tool except awning_plan4 s per request (the paid tools make two in parallel)FETCH_TIMEOUT_MS, plugin/src/awning.ts
Plugin → POST /v1/plan8 sPLAN_TIMEOUT_MS
POST /v1/plan, server sidetarget under about 6.8 s: LLM 5 s (AWNING_LLM_TIMEOUT_MS), second domain check max(1.2 s, 6.5 s − elapsed), phone search 5 sweb/lib/services/plans.ts
First domain check inside /v1/plan5 scheckFor(…, 5000)
/v1/plan function30 smaxDuration
/x402/* functions90 smaxDuration
Reconciler run60 s function; payments stale after 3 minweb/app/v1/cron/reconcile/route.ts, STALE_AFTER_MS
Facilitator /verify, /settle12 s each; settle up to 2 attemptsHttpFacilitator
authorizationState read6 s per attemptHttpFacilitator.authorizationUsed
Opening sagaAWNING_OPEN_DEADLINE_MS (default 26 s), per-step caps aboveweb/lib/env.ts, proSteps()
Each undo8 scompensate
Owner SMS after an orderwaits at most 3 sbuy commit
Refund transferRPC 10 s, receipt wait 20 sViemRefunder
Signed authorization300 sMAX_TIMEOUT_SECONDS
PayBox proposalat most 4 min, never past the plan's expiry; refused with under 60 s leftproposalExpiry

Worst case for a paid open before the commit: verify (12 s) + saga (26 s) + two settle attempts with chain checks (2 × (12 s + 6 s)) = 74 s, inside the 90 s maxDuration. A refund after a failed commit can add up to 30 s more. If a function is killed anyway, the reconciler finishes the payment.

Failure modes#

FailureWhat happensMoneyWhere
Plugin cannot reach the API or it times outisError with "Nothing was changed or paid; retry once". For awning_plan a draft may still have been created server sidenone movesplugin/src/awning.ts
Sentence or draft breaks the acceptable use policy422 policy_blocked {category}; nothing storednonecreatePlan, web/lib/policy/screen.ts
Wallet over its plan or free-opening quota429 rate_limited, retry-afternoneweb/lib/policy/safety.ts
Business suspendedquotes, opening and offers refused with 403 business_suspendednonequoteOpen, quoteBuy, openFree
LLM slow or failingtemplate draft; domains match the template namenonecreatePlan
Photo generation fails or hits the hourly caplogged; the storefront keeps its drawn artnonegenerateBusinessImages
Registrar check fails during planningno domains resolve; all three options are unavailable; Pro cannot be bought; Free still worksnonecheckFor, pickDomains
First domain check slowcut off at 5 s; the plan uses the names that answerednonecreatePlan
Quote expiredunpaid: 410 quote_expired; paid within 300 s: completesnone / normalquoteOpen
Pro providers not all real, or no working refunder (real facilitator)503 pro_unavailable {missing}, no challengenonereadiness.ts
Treasury address missing503 misconfigured on /x402/opennonequoteOpen
Domain taken or price changed between plan and paymentpreflight fails, nothing to undo, 502 provisioning_failednot settledproSteps
Twilio number taken since planningnext candidate, then a fresh search, then US numbersnoneTwilioTelephony.buy
Provider error mid-sagacompleted steps undone in reverse; 502 provisioning_failed with stepsnot settledrunSaga
An undo failsstep marked compensation_failed, error logged, resource left in placenot settledcompensate
DNS fails after registrationother steps undone; the domain stays registered in Awning's account and is reused on the next attemptnot settled; Awning bears the domain costproSteps
Settlement refusedwork undone; 402 settlement_failed, charged: falsenot movedhandlePaidRequest
Facilitator timeout and chain unreadabletreated as paid; payment unconfirmed; the reconciler confirms it on chain later, or flags it to the operator if it never settledunknown until reconciledsettleWithTruth, reconcile.ts
Commit fails after settlement (open)work undone, treasury refund, 502 refunded: truerefundedhandlePaidRequest
Refund transfer fails, or refunds unavailable502 refunded: false, operator alerted, status settledheld by Awning until a manual refundhandlePaidRequest
Commit fails after settlement (order)order failed; 502 delivery_failed, charged: truewith the ownerbuy
Same paid request sent twicestored response replayed byte for byte; in flight → 409 payment_in_progresssettled oncereplayIfSeen
Second payment for an open plan200 alreadyOpen: true, charged: false; owner token only to the original payernot settledopenPro
Two payments open the same plan at oncethe loser gets 409 open_in_progress; its key is released for retryloser not settledopenPro
Function killed mid-requestafter 3 minutes the reconciler reads the chain: not paid → failed, plan released, replay answers work_interrupted; paid open → refunded; paid order → flagged to the operator. Provider resources from completed steps are logged for the operator to clean upnot charged, refunded, or with the ownerreconcile.ts
Database downGET /v1/health 503; routes 500 internal_errornone moves before the DB claimhandler
Twilio webhook with a bad signature403none/v1/voice/:slug
Owner SMS slow or failingreceipt returned after at most 3 s; failure loggednormalbuy

Deployment#

  • One Vercel project, root web/, Node 24. Domains awning.sh, www.awning.sh, api.awning.sh and the wildcard *.awning.sh. Custom business domains are added by the orchestrator.
  • Secrets in environment variables (see .env.example); locally, the repo-root .env is loaded by web/next.config.ts.
  • Vercel cron: GET /v1/cron/reconcile daily at 04:17 UTC; GitHub workflow reconcile payments every 10 minutes (needs the CRON_SECRET repository secret; skips when the API is unreachable).
  • CI (.github/workflows/ci.yml) on Ubuntu 24.04 with Node 24.21.0 and pnpm 11.11.0: the plugin's official starter test (type-check, two builds with identical bytes, conformance), the plugin unit checks, and a deterministic-build check; then the web type-check, vitest suite (with AWNING_FORCE_MOCKS=1 and the mock facilitator) and next build.