DEV Community

Cover image for Our $0.01 API hung for three days — the culprit was a GET request
Pennyforge
Pennyforge

Posted on

Our $0.01 API hung for three days — the culprit was a GET request

We run a small paid API endpoint: SendCheck, a pre-send verification service for EVM addresses, exposed over the x402 protocol (HTTP 402 + USDC on Base) at $0.01 per check. For three days, its paid routes would intermittently hang forever — no error, no timeout, no log line. Free routes on the same isolate stayed fast the whole time.

This post is two things: the war story (because the root cause is a genuine trap for anyone running x402 on Cloudflare Workers), and the API itself (because it's live, and a curl away).

The symptom pattern

The shape of our Worker is the common one — free discovery/health endpoints plus paid routes:

app.use('/check', paymentMiddlewareFromHTTPServer(...));  // paid
app.get('/', c => c.text('SendCheck x402 API'));           // free
app.get('/.well-known/x402', ...);                         // free discovery doc
Enter fullscreen mode Exit fullscreen mode

What we saw in production:

  • Right after a deploy, paid routes answered 402 Payment Required in under a second. Our smoke tests passed.
  • Minutes to hours later, paid POSTs would hang indefinitely (verified past 45 seconds). The client just... waits.
  • Simultaneously, free routes on the same isolate answered in ~100ms.
  • A manual fetch() from inside the Worker to the facilitator's /supported endpoint returned in ~90-300ms — the network was fine.
  • It never reproduced locally. wrangler dev was always green.
  • Re-deploying "fixed" it. For a while.

If you've debugged anything like this, you know the feeling: every probe says healthy, every real customer call says dead.

The microscope

The breakthrough came from wrapping the middleware's internals with breadcrumbs and watching wrangler tail:

const raw = new x402HTTPResourceServer({ ... });
const httpServer = {
  initialize: async () => {
    dbg('init#1 start');
    try { await raw.initialize(); dbg('init#1 done'); }
    catch (e) { dbg('init#1 fail', e.message); }
  },
  processHTTPRequest: async (...a) => { dbg('proc#1 start'); const r = await raw.processHTTPRequest(...a); dbg('proc#1 done'); return r; },
};
Enter fullscreen mode Exit fullscreen mode

On a wedged isolate, the tail looked like this:

GET  /.well-known/x402 - Ok          ← free route, returns immediately
  (log) [x402dbg] init#1 start       ← handshake started under THIS request
  (log)                               ← ...no "done", ever
POST /check           - Canceled     ← later paid requests: no proc logs at all
Enter fullscreen mode Exit fullscreen mode

The init handshake started, and never finished. Paid requests never even reached processHTTPRequest — they were stuck earlier, waiting on a promise that would never settle.

The root cause: workerd cancels work when the request context ends

In @x402/hono (v2.24.0 at time of writing), paymentMiddlewareFromHTTPServer fires the facilitator handshake at middleware construction time — with syncFacilitatorOnStart = true, the default:

if (syncFacilitatorOnStart && !isInitialized) {
  initializeHttpServer();   // not awaited; promise memoized in initPromise
}
Enter fullscreen mode Exit fullscreen mode

Middleware construction happens lazily inside the Worker's fetch() handler. So that handshake fetch() runs under whichever request first lands on a cold isolate.

And in workerd, when a request finishes, everything still pending under its context — in-flight fetches, timers, all of it — gets cancelled.

Now it clicks:

  1. A crawler (or a curious human) hits /.well-known/x402 or robots.txt or any 404 — a short free GET that returns immediately.
  2. That request is the isolate's first. Middleware construction starts the facilitator handshake under it.
  3. The GET returns; workerd cancels the in-flight /supported fetch.
  4. The memoized initPromise stays pending forever. No rejection, no error, nothing to catch.
  5. Every later paid request on that isolate does await initPromise → infinite hang.

No timeout saves you. AbortSignal.timeout and any setTimeout race are cancelled with the same request context. That's why the hang showed no timeout ever firing. (A timeout guard around await initPromise on the paid request only converts the infinite hang into a timeout error — the init was started under a context that is already dead. The repair has to keep the init's own context alive.)

Free routes never await init, so they stay fast. A cold isolate whose first request is a paid POST works fine, because that request's context stays alive through init. Hence the maddening pattern: works after deploy (your smoke test is a POST), wedges minutes later (a crawler GET got there first).

The fix

Keep the first request's context alive until the construction-time init settles, via ctx.waitUntil:

let app = null;
let initPromise = null; // captured from the wrapped initialize()

export default {
  fetch(request, env, ctx) {
    if (!app) {
      app = createApp();                        // init starts here
      if (initPromise) ctx.waitUntil(initPromise.catch(() => {}));
    }
    return app.fetch(request, env, ctx);
  },
};
Enter fullscreen mode Exit fullscreen mode

With that in place, we ran a 12-minute adversarial loop — short free GETs interleaved with paid POSTs, the exact poison scenario — plus real paid round-trips at 0 and ~90 minutes after deploy. Clean, every time. Paid calls now answer 402 in ~0.4s and complete the full pay→200 cycle in ~1.3-2.1s.

One thing that is not a fix: setting syncFacilitatorOnStart = false. The middleware then never awaits initialization at all, and processHTTPRequest fails with "no supported payment kinds" — a 500 on every paid route. (Yes, we shipped that for two minutes. Our test suite caught it. Run your tests before you deploy, not after.)

If you run x402 on Workers with any free GET route — and the quickstart's shape gives you several — you likely have this bug. We'd love to see it fixed upstream; until then, the waitUntil pattern above is a complete workaround.

The API itself

SendCheck answers one question before you send funds: is this address what I think it is? You give it an address and a chain; it returns a verdict with the on-chain facts behind it — EIP-55 checksum validation, wallet-vs-contract detection (is that "wallet" actually a smart contract?), outgoing activity, native + USDC balances, and a plain-language verdict.

The base endpoint is $0.01 per check; the deep scan ($0.05) runs the same verification across all five chains (Base, Ethereum, Arbitrum, Optimism, Polygon) including wrong-network detection.

Here's the whole flow. A plain POST gets you the 402:

curl -i -X POST https://sendcheck-x402.pennyforge.workers.dev/check \
  -H 'Content-Type: application/json' \
  -d '{"address":"0x9504A5939AB5be2B2B1F8beA7D7ebeCcd96c485D","chain":"base"}'
Enter fullscreen mode Exit fullscreen mode
HTTP/2 402
payment-required: eyJ4NDAyVmVyc2lvbiI6Miwi...
Enter fullscreen mode Exit fullscreen mode

The payment-required header is base64; decoded, it's a machine-readable challenge:

{
  "x402Version": 2,
  "error": "Payment required",
  "resource": {
    "url": "https://sendcheck-x402.pennyforge.workers.dev/check",
    "description": "SendCheck: pre-send verification of any EVM address on one chain (EIP-55 checksum, wallet-vs-contract, outgoing activity, balances, verdict). POST {address, chain}",
    "mimeType": "application/json",
    "serviceName": "sendcheck",
    "tags": ["evm", "crypto", "security", "usdc", "address-verification"]
  },
  "accepts": [{
    "scheme": "exact",
    "network": "eip155:8453",
    "amount": 10000,
    "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
    "payTo": "0x9504A5939AB5be2B2B1F8beA7D7ebeCcd96c485D",
    "maxTimeoutSeconds": 60,
    "extra": { "name": "USD Coin", "version": "2" }
  }]
}
Enter fullscreen mode Exit fullscreen mode

With the x402 client stack, payment is a wrapper around fetch:

import { wrapFetchWithPayment, x402Client } from '@x402/fetch';
import { ExactEvmScheme, toClientEvmSigner } from '@x402/evm';

const client = new x402Client();
client.register('eip155:8453', new ExactEvmScheme(toClientEvmSigner(account)));
const fetchWithPay = wrapFetchWithPayment(fetch, client);

const res = await fetchWithPay(
  'https://sendcheck-x402.pennyforge.workers.dev/check',
  {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ address: '0x9504…485D', chain: 'base' }),
  }
);
Enter fullscreen mode Exit fullscreen mode

The wrapper intercepts the 402, reads the challenge, signs an EIP-3009 transfer for exactly $0.01 of USDC on Base, replays the request with the payment header, and hands you the real response:

{
  "verdict": {
    "level": "ok",
    "headline": "Looks good — safe to send"
  },
  "meta": { "service": "sendcheck", "version": "1.8", "ms": 205 }
}
Enter fullscreen mode Exit fullscreen mode

No API key to provision, no account to create, no invoice to chase. The request is the payment. That's the whole reason we built on x402: for a $0.01 call, every other billing stack (cards, invoicing, subscriptions) costs more to operate than the product earns.

Honest limits

We'd rather over-disclose than oversell:

  • It's a sanity check, not a guarantee. The verdict summarizes on-chain facts at call time: checksum validity, contract-vs-wallet, activity, balances. It will not tell you the purpose behind an address, and it doesn't do ML risk scoring.
  • /check is single-chain by design. Give it the chain you actually care about; use /deep for cross-chain and wrong-network detection.
  • The facilitator settles on Base. Payments are exact USDC amounts on Base mainnet; settlement takes a few seconds.
  • Young service. The verification engine has been live as a free web tool (sendcheck.surge.sh) for a few weeks with a full test suite; the paid API went live this week. If anything looks off, the free web tool shows the same engine's output — you can cross-check us for $0.
  • Price is honest about scale. $0.01 buys a verification, not insurance.

Try it

If you have a Base wallet with a little USDC, the whole flow above is copy-pasteable — first payment included. And if you're building on x402 on Cloudflare Workers, go check your free routes right now: hit them, then try a paid call. If it hangs, the waitUntil fix above is yours.

We're a small studio — feedback, bug reports, and "this verdict was wrong" mails all get read.

Top comments (0)