Skip to content

Errors

Seven codes, and that is the whole set. Every failed request answers with the same envelope, whatever route it hit:

{
"error": {
"code": "VALIDATION_FAILED",
"message": "Validation failed",
"requestId": "req_01J8Z…",
"fields": [
{ "path": "set.note", "message": "every patch path must start with \"custom.\" — got \"note\"" }
]
}
}

requestId is worth logging: it is what identifies a single request in support.

Code HTTP Typical cause Retry?
VALIDATION_FAILED 400 Malformed body — a custom over 64 KB, a refs entry that is not type:id, a patch path outside custom.. fields says which. No. It will fail identically forever.
UNAUTHENTICATED 401 Missing, malformed, expired or revoked token; wrong apiKey. Once, after minting a fresh token. The client SDK already does this for you.
FORBIDDEN 403 A valid token for the wrong thing — a user token writing another user’s feed, patching an activity that is not its own, or calling a server-only route (an object write, or any batch.*). Object reads are open to any token. No. Fix the token kind or the target.
NOT_FOUND 404 No such activity, object, or reaction. No.
CONFLICT 409 The (foreign_id, time) identity you posted was deleted. The identity is burned; a live duplicate is not an error (see below). No. Pick a new foreign_id or time.
RATE_LIMITED 429 A rate-limit window is full. Yes — after sleeping retryAfterSeconds.
INTERNAL 500 Our fault. Also the fallback for any status not in this table (a 502 from a proxy in front of us). Yes, with backoff. Not a poison pill — an INTERNAL that repeats identically for the same body is worth reporting with its requestId.

Auth failures never say more than UNAUTHENTICATED or FORBIDDEN. The specific reason is logged on our side and deliberately not returned — telling a caller which check failed is a gift to someone probing.

Both SDKs throw the same class, DropInApiError, built from the same parse:

import { DropInApiError } from '@dropinnodex/server' // or '@dropinnodex/client'
try {
await dropin.feed('user', 'alice').addActivity({ verb: 'post', object: 'w:1' })
} catch (err) {
if (!(err instanceof DropInApiError)) throw err // a network failure or an abort
err.code // 'RATE_LIMITED' — branch on this, never on the message
err.status // 429
err.requestId // 'req_01J8Z…' — log it
err.retryAfterSeconds // 30 on a 429, undefined otherwise
err.fields // [{ path, message }] on a VALIDATION_FAILED that had detail
err.url // the request that failed — the message only carries what we said
}

err instanceof DropInApiError means the API answered. An aborted request rejects with an AbortError and a network failure with whatever fetch throws — neither is a DropInApiError, so a catch that only inspects .code should re-throw anything else.

const RETRYABLE = new Set(['RATE_LIMITED', 'INTERNAL'])
async function emit(fn: () => Promise<void>, attempts = 3) {
for (let i = 1; ; i++) {
try {
return await fn()
} catch (err) {
const retryable = err instanceof DropInApiError && RETRYABLE.has(err.code)
if (!retryable || i === attempts) throw err
const wait = err.retryAfterSeconds ?? 2 ** i
await new Promise((r) => setTimeout(r, wait * 1000))
}
}
}

Make the retry safe before you make it aggressive: send foreign_id and time on every activity, and a replay dedupes into the original instead of double-posting. See Idempotency.

  • A duplicate (foreign_id, time) write. It returns 201 with the existing activity, unchanged, and fans out nothing. That is what makes retries safe.
  • An activity whose actor was never upserted. The write succeeds — but actor_user is null and the response carries warnings: ["actor_user_unresolved"]. See Add an Activity.

An INTERNAL at 2am is only useful if you can answer that in one step.

Start here: status.getnodex.cloud. It probes the API from your own browser and from a second network, so it can tell “we are down” apart from “you cannot reach us” — and it is hosted away from our infrastructure, so it still answers when everything else of ours is gone. Every INTERNAL we return names it, too.

If you would rather have the raw signal, or you are scripting this, the same information is two unauthenticated endpoints — no key, no token, safe to curl from anywhere or point an uptime monitor at:

Terminal window
# Liveness. `version` is the git commit of the running build, so you can tell
# whether behaviour changed because we deployed.
curl https://api.getnodex.cloud/v1/health
# {"status":"ok","version":"45f4f5f…"}
# Readiness — 200 when every dependency answers, 503 when one doesn't.
curl https://api.getnodex.cloud/v1/health/ready
# {"status":"ready","checks":[{"name":"postgres","ok":true},{"name":"redis","ok":true},{"name":"feed","ok":true}]}

How to read it:

  • /v1/health/ready is 503 — it’s us, and checks names which dependency. Nothing you change on your side will help; retry with backoff and wait.
  • It’s 200 and your call still fails — it’s reaching us and we’re answering, so the request itself is the suspect. Send the requestId from the error rather than a description; that’s what identifies the single request in our logs.
  • version changed since your last good call — worth mentioning in the report, even if you don’t suspect it.

Use these for triage and monitoring, not as a gate. Health-checking before every write doubles your round trips and tells you nothing the write itself won’t.