Errors
The answer envelope, the codes a records integration meets, what each one means, what to retry, and how each SDK hands a refusal to your code.
Every answer from the records API has one shape. A refusal carries a sentence that states the rule and, in almost every case, a code from the API's registry, in screaming snake case. Branch on the code, never on the sentence.
The envelope
A success:
{ "success": true, "data": { "...": "the answer" }, "message": "optional" }A refusal:
{
"success": false,
"error": {
"statusCode": 422,
"code": "RECORD_TITLE_TOO_LONG",
"message": "A title fits in 120 characters.",
"timestamp": "2026-10-05T14:09:48.102Z",
"path": "/api/v1/records/machine-action-record/eval-run-4417/transitions"
}
}Every answer that names a wait, a 202 IDEMPOTENCY_PENDING and every 429, carries retryAfterSeconds in error and the same number in a Retry-After header. The history route and the disclosure-source route return their files as the body, without the envelope.
Two refusals carry no code: a missing, unknown, or disabled key answers 401, and a body the API cannot read (a field missing or of the wrong type, or a field the route does not take) answers 400 with the validator's sentence.
A sentence that would name a key or a token reaches you as "An error occurred while processing your request.", with its code unchanged. That is one more reason to branch on the code.
Codes
Keys and workspaces
| Code | Status | The rule | What to do |
|---|---|---|---|
| none | 401 | A key is one your workspace lists under Settings, Developer, API keys, sent as x-api-key. | Check that the key is set and has not been disabled. |
RECORDS_API_NOT_ENABLED | 403 | Records are written from a records workspace. | Pick the records option at signup, or ask us to switch the workspace. |
RECORDS_CONTRACT_NOT_READY | 412 | A workspace writes once its recording environment is ready. Nothing was queued. | Wait for the dashboard to show ready, then send again. |
PLAN_LIMIT_REACHED | 402 | A Free workspace writes 100 entries a month. "A Free workspace writes 100 entries a month. Nothing was queued. Move to a paid plan to continue." | Move to a paid plan, then send again with the same idempotency key. |
PAID_ENTITLEMENT_REQUIRED | 402 | A paid plan writes while its payment stands. | Settle the subscription on the dashboard's Plan page, then send again with the same key. |
RATE_LIMIT_EXCEEDED | 429 | A workspace writes up to 120 entries a minute, shares up to 30 links a minute, and reads under a default budget. | Wait retryAfterSeconds; send a write again with the same idempotency key. The SDKs do this themselves. |
Writing an entry
| Code | Status | The rule | What to do |
|---|---|---|---|
RECORD_LAYOUT_UNKNOWN | 400 | A record type is one of the published names. | Use machine-action-record, or leave recordType out. |
RECORD_LAYOUT_NOT_ADMITTED | 422 | The record type is published but not yet open for writes in this environment. Nothing was queued. | Ask us when it opens here. |
RECORD_LAYOUT_MISMATCH | 409 | A record keeps the record type it started with. | Use a new record id for the new record type. |
RECORD_STATE_UNKNOWN | 400 | A state comes from the record type's declared list. | Use recorded, amended, or closed. |
RECORD_CONTENT_INVALID | 400 | The sealed record carries the fields the record type names, each in its declared form. | Fix the field the message names and send again. |
RECORD_PAYLOAD_ALG_UNSUPPORTED | 400 | A fingerprint is made with sha-256 or hmac-sha-256. | Fingerprint the file with the SDK's helper and send again. |
RECORD_BLINDING_INCLUDED | 400 | "This write includes a blinding file or its value. Send only the fingerprint's alg, digest and byteLength; the blinding file stays with you." | Send the fingerprint's ref as the payload, and keep the blinding file beside the file. |
RECORD_PAYLOADS_REQUIRED | 422 | "Every record carries at least one fingerprint." Each entry needs one. | Fingerprint the file or the payload and put the result in payloads. |
RECORD_TITLE_TOO_LONG | 422 | "A title fits in 120 characters." | Shorten the title; the rest belongs in description. |
RECORD_REFERENCE_NOT_FOUND | 422 | "A reference names an entry that is already committed in your workspace." | Check the record id and entry number, or wait for that entry to reach committed. |
RECORD_AMENDS_NOT_FOUND | 422 | "An amendment names a committed entry in the same record, or in a closed record this entry references." Nothing was queued. | Check the record id and the entry number. |
RECORD_AMENDS_NOT_PRECEDING | 422 | "An amendment comes after the entry it amends." Nothing was queued. | Wait for that entry to commit, or name the entry you meant. |
RECORD_INTAKE_FAILED | 500 | A write that did not land is always reported. | Send the same request again with the same idempotency key. |
IDEMPOTENCY_PENDING | 202 | One idempotency key is one write; a repeat waits for the first to finish. | Wait retryAfterSeconds, then send the same request again. The SDKs return this rather than throwing. |
IDEMPOTENCY_CONFLICT | 409 | One idempotency key is one write; a different body under the same key is a different write. | Send the new entry with a new key. |
The three amendment and reference refusals add a second sentence naming the entry, such as which entry of the record is the latest committed.
After the write was queued
These reach you as record.failed at your webhook and as status: failed with the code in the history, never in the write's own answer.
| Code | The rule | What to do |
|---|---|---|
RECORD_GENESIS_MISMATCH | "A record opens with recorded; amended and closed come after it." | Send the first entry as recorded, then the entry you meant, with a new idempotency key. |
Any other entry that queued and did not commit carries the sentence "This entry did not commit. Nothing was written, and your earlier entries are unchanged."
Reading
| Code | Status | The rule |
|---|---|---|
RECORD_NOT_FOUND | 404 | No record exists for this id in your workspace. |
RECORD_ENTRY_NOT_FOUND | 404 | No committed entry exists at this position for this record, or in the range asked for. |
RECORD_RECEIPT_NOT_READY | 409 | No committed entry exists at this position yet; the record has entries still being written. |
RECORD_BUNDLE_RANGE_INVALID | 400 | fromSeq and toSeq are whole numbers and toSeq is not below fromSeq. |
RECORD_RECEIPT_GRANT_REQUIRED, RECORD_BUNDLE_GRANT_REQUIRED | 403 | Reading a receipt or a history takes your workspace's key or a grant for this record. |
PROOF_MATERIAL_UNAVAILABLE | 409 | The sealed record content for this entry is no longer retained. A retention window never removes a record's sealed content, so this is not an answer you should meet; if you do, write to support with the record id. |
RECORD_HISTORY_TOO_LARGE | 413 | One answer carries a record's history up to 5000 entries on the ledger. The reason reads "This record has more entries than one history can carry." It answers the disclosure-source route and records.disclose; the history route pages instead, naming the next page in X-Next-From-Seq. |
Sharing
| Code | Status | The rule |
|---|---|---|
RECORD_ENTRY_NOT_FOUND | 409 | This record has no entries yet; a link can be issued once the first entry is queued. |
CUSTOMER_LINK_NOT_FOUND | 404 | The handle is not a link to this record. |
CUSTOMER_LINK_STEP_UP_REQUIRED | 401 | On the reader's side: the code that came with the link has not been entered. |
RECORDS_SHARE_LINK_EXPIRED | 410 | On the reader's side: the link has expired. The sentence names the day and who to ask for a new one. |
RECORDS_SHARE_LINK_REVOKED | 410 | On the reader's side: the link was turned off. The record it opened has not changed. |
What to retry
| Answer | Retry | How |
|---|---|---|
| A timeout or a dropped connection | Yes | Same request, same idempotency key. |
202 IDEMPOTENCY_PENDING | Yes | After retryAfterSeconds, same request. |
| 429 | Yes | After retryAfterSeconds, same request. |
| 500, 502, 503, 504 | Yes | Same request, same key, with a growing wait. |
| 400, 409, 422 with a code | No | The sentence names the rule. Fix the entry and send it with a new key. |
| 401, 403, 404 | No | Fix the key, the workspace, or the id. |
| 402 | No | Fix the plan, then send the same request with the same key. |
The SDKs retry a timeout, a dropped connection, a 408, a 429, and a 500, 502, 503 or 504 themselves, twice by default, waiting exactly the seconds a 429 names. A 202 they hand back to you, with its retryAfterSeconds, for your code to send again. Bound any loop of your own by attempts and by time.
Reading an error from code
Both SDKs raise a PacSpaceError carrying the status, a code, and the API's sentence. For a refusal with status 400, 403, 409, 410, 413, 422 or 500, the code is the API's code, exactly. For 401, 402, 404, 412, 429 and 503, each SDK raises its own typed error, such as RateLimitError for a 429, and the code it carries is the SDK's own name for that error; the API's code is not passed on. On those, branch on the status and read the API's sentence in the message.
import { PacSpace, PacSpaceError } from '@pacspace-io/sdk';
const pac = new PacSpace({ apiKey: process.env.PACSPACE_API_KEY! });
try {
await pac.records.emit({
record: 'eval-run-4417',
title: 'Agent ran a shell command that reached outside the test environment',
kind: 'tool-call',
occurredAt: '2026-10-05T14:09:47Z',
actorId: 'agent-7',
payloads: [ref], // from fingerprint(), as in the quick start
idempotencyKey: 'eval-run-4417:step-2',
});
} catch (err) {
if (!(err instanceof PacSpaceError)) throw err;
console.error(err.statusCode, err.code, err.message);
// a title over 120 characters: 422 RECORD_TITLE_TOO_LONG A title fits in 120 characters.
}