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.