Reading a World API refusal

The World API is deliberately terse when it refuses a signed request. This is the guide to getting from a refusal to a cause — and the first thing it has to say is that the endpoint that tells you why is not the one you are holding a credential for.

The uniform 403 is a decision, not a gap

All three signed doors answer the same 403 on a bad signature:

403  {"error":"world mint requires the provider signature (POUCHY-SOURCE-V1 …)"}

They do not say which of the four things is wrong — a wrong secret, a wrong key id, a stale clock, a body that changed between signing and sending. That is on purpose: an endpoint that names the failing credential is an oracle, and it would let anyone who can reach it narrow a guess for free.

So the wire will never diagnose you. The audit trail will.

GET …/environments/{envId}/preflight — the actual diagnostic entry point

GET https://pouchy.ai/v1/projects/{projectId}/environments/{envId}/preflight
Authorization: Bearer <OwnerToken>

It takes an OwnerToken — a signed-in project admin's Firebase ID token. Not the pchy_sk_… Secret Key your backend is already holding, and not the pchy_admin_… Admin key either: preflight is deliberately absent from the /admin mirror, because a machine credential reading a stream of "which credential is wrong" is a different risk from one reading world state. An OwnerToken is browser-minted and expires within the hour; you get one by being signed in to the dashboard, which is where you are standing when you need this.

There is no SDK method for it, for the same reason. This answers a question a developer asks once while debugging, not one a backend asks in a loop.

The response:

{
  "environmentId": "env_…",
  "generatedAt": 1756100000000,
  "refusals": [                      // newest first, at most 20
    { "at": "2026-08-25T06:12:03.114Z", "door": "session", "reason": "bad_signature" }
  ],
  "byReason": { "bad_signature": 3, "stale": 1 },
  "scanned": 200,                    // audit rows walked
  "truncated": false
}

An empty refusals is not a clean bill of health. A backend that has never called produces exactly the same emptiness as one that works. Check scanned: if it is 0 you did not look at anything; if your request should be in the window and is not, your request never reached the door at all — a wrong host, a wrong project, a proxy, or a rate limit ahead of the route.

The refusal vocabulary

Closed on purpose — you can switch on it, and a new member is a deliberate act rather than a new string appearing in a log line.

reason What actually happened What to change
missing No X-Pouchy-Source-Signature header at all You are sending the Secret Key alone. The machine lane needs BOTH.
malformed The header is not t=…,kid=…,v1=… Usually a stray space, a missing part, or a header your framework re-cased and truncated.
unknown_key kid names no key on this source The key was rotated away or dropped, or you are signing for one project and calling another.
stale t is outside ±5 minutes Your clock, or a signature you cached. Sign at SEND time, every attempt — a retry days later needs a fresh t.
bad_signature The HMAC did not match The canonical string or the body bytes differ from what the server reconstructed. See below — this is the one that is almost never a wrong secret.
no_keys The source has no provisioned key, or was revoked Provision one in the dashboard. A revoked source is permanent — it does not come back by re-provisioning.

bad_signature is usually the id slot or the bytes

The canonical string is five newline-joined lines:

POUCHY-SOURCE-V1
<unix seconds>
<the declared source>
<the ID SLOT>
<sha256 of the exact raw body, or "-" when empty>

Two things go wrong here, and both look identical on the wire:

1. The id slot is per-door, and there are three doors.

Door Id slot
POST /sessions the body's own world.request_id
POST …/instances/{id}/turns turnId
POST /events/ingest eventId

A session mint has neither a turn nor an event. Signing an empty string or an invented id there verifies perfectly on your side and comes back bad_signature, which reads exactly like a wrong secret — and /sessions is the first door an integrator reaches. createWorldSession in the SDK passes it for you; if you are signing by hand, this is the line to check first.

2. You signed bytes you did not send. Serializing twice — once to hash, once to fetch — signs a different string whenever key order or whitespace differs by so much as a byte. Build the body string ONCE and use it for both. The SDK does this by construction.

The typed errors the SDK throws

WorldApiError carries code, status, detail (the server's own message when it sent one), serverCode (the server's own machine-readable code when the refusal carried one — SDK 0.28.0), errorId (the server's err_… lookup reference on a persisted 5xx — the message is sanitized, the reference is what support resolves; SDK 0.28.1), retryAfterSec, rejectedEffects, and .retryable.

.retryable is true only for rate_limited, server_error and network — retry those with the SAME idempotency key. A conflict (409) is not retryable: it means the world disagreed with the request, and repeating it will disagree again. forbidden and unauthorized are never retryable either; that is what this document is for.

code Status Read it as
unauthorized 401 The credential is missing or not for this lane.
forbidden 403 Wrong lane, a signature refusal, or a world/provider no longer live. Go to preflight.
not_found 404 No such project, world, instance, turn or draft.
conflict 409 A dead pin, an actor-runtime world, no selectable role, or an unreviewed draft. code maps the status, so every 409 reads conflict here; the sub-reason, when the server names one, is serverCode — see below.
payload_too_large 413 Over the endpoint cap.
unprocessable 422 Understood, and its effects refused. Read rejectedEffects — see below.
rate_limited 429 Back off by retryAfterSec.
server_error 5xx Retry the same request. A 503 whose body code is storage_unavailable means the instance could not be LOOKED UP (a storage fault) — it says nothing about whether the instance exists or the turn committed; retry with the same idempotency key.

serverCode — which conflict, when the server says

A 409 has several causes whose right response differs, and the world names the ones an integrator can act on with a body code. serverCode carries it verbatim (0.28.0); before that the client read error into detail and dropped code, so the only carrier was prose that varies per site.

serverCode Read it as
stale_deliberation The role deliberated against a worldline that has since moved. Re-run deliberate and select from the fresh set.
episode_ended This episode's end rule fired. Start the next episode (startNextEpisode); deliberation on an ended episode is refused.
episode_history_incomplete The instance predates episode history; the read you asked for cannot be reconstructed.
has_instances The environment still has instances; delete them first.
story_contract_v2_write_disabled / _v3_ / _v4_ The package needs a contract version this environment has not enabled for writes. Enable it, or drop the field that raised the version.

Absent means the server named no sub-reason — a dead pin, an actor-runtime world, no selectable role — not that there was none. Branch on serverCode when it is present and fall back to detail for the operator's eyes.

rejectedEffects — which op the world refused, and why

A rejected turn is the one refusal that does NOT answer with { error }: its body is the full turn result, and the reasons live in rejectedEffects. So detail is empty for exactly the refusal that has the most to say. Since 0.25.0 the SDK lifts that array onto the error:

try {
  await client.runTurn({ …, proposedPatches: patches });
} catch (e) {
  if (e instanceof WorldApiError && e.rejectedEffects) {
    for (const r of e.rejectedEffects) {
      console.error(`op ${r.index}: ${r.reason}`); // “node "n.b" requires "n.a" to be completed first”
    }
  }
}

Each entry carries index (which op in YOUR array), reason (the server's own sentence), and optionally code, plus roleId / kind when the refusal was a role's effect proposal rather than one of your ops.

Why this matters more than it looks: patch validation is ALL-OR-NOTHING. One bad op voids the whole batch — including the ops that were fine — so the turn moves nothing at all, and "which one" is the only question worth asking. Without these reasons a five-op batch is five guesses.

The two readings to keep apart, because the fix is completely different:

  • factId "…" is not a declared fact” / “is not a declared scene” — the id is not in the story package the instance is PINNED to. Usually the deployed revision is older than the code that built the patch: republish, or start a worldline on the current revision.
  • “node "x" requires "y" to be completed first” — the ids are right and the STATE is not there yet. Something earlier in the story never committed, and this op is the first place it becomes visible.

rejectedEffects is absent (not []) when the server named no reasons — "the server said nothing" and "the server refused nothing" are different answers.

When a turn ran but nothing arrived

Not an error path. executionStatus says whether the world moved; deliveryStatus says whether the audience has heard yet. A pending delivery is a durable outbox retrying, not a failed turn — GET …/turns/{turnId} is the authoritative recovery read when a response is lost.

When the world says it is not ready

GET …/environments/{envId}/overview returns ready plus a blockers array, each with a machine nextStep. A coordinated world with no story package pin is NOT ready — the coordinator refuses the first beat — and the overview says so rather than letting you find out at runtime.