Errors
The v3 error envelope, the full list of error slugs, and what is safe to retry.
v3 uses HTTP status codes correctly and returns one error shape everywhere.
The envelope
Every error carries a stable machine-readable error slug:
{ "error": "invalid-folder" }Anything caused by the request body also carries details, with one entry per problem:
{
"error": "validation-failed",
"details": [
{ "field": "title", "message": "is required" },
{ "field": "recipients[1].phone", "message": "is required" },
{ "field": "settings.reminders.amount", "message": "must be at least 1" }
]
}field is a path into your payload, including array indexes, so you can point straight at the offending value.
Branch on error, not on message. Slugs are stable; the human-readable text may be reworded.
Validation returns everything at once
Structural problems — wrong types, bad formats, unknown fields, out-of-range values — are collected and returned together, so you fix them in one round-trip instead of one per deploy.
Three checks read the structure and so run once it is sound: whether an open case actually has recipients and documents, whether a document's content decodes to a PDF or .docx, and whether a library document's tags match the fields that document defines. If the body also has a structural problem you will see these on the next request, after the structural fixes — never a case created with one still outstanding.
Tag values are checked field by field, so the field path reaches into them — documents[0].tags.address.city, or tags.terms[1].qty on the document-first endpoint:
{ "field": "documents[0].tags.start_date", "message": "must be a valid date" }unknown field appears for any key not in the documented schema — including a misspelled one:
{ "field": "recipeints", "message": "unknown field" }Query parameters are held to the same standard. On the endpoints that take them, field is the parameter name rather than a body path, and an unrecognised parameter is rejected exactly as an unknown body field is — a filter that was quietly ignored is the one mistake you could not spot in the response.
{ "field": "createdAfter", "message": "must be an ISO 8601 date, e.g. 2026-01-31 or 2026-01-31T09:00:00Z" }Status codes
| Status | Meaning | Safe to retry? |
|---|---|---|
200 | Success. Nothing was changed — this was a read | Yes, always |
201 | Created. The case exists | No — retrying creates a second case |
400 | Your request was rejected. Nothing was created | Yes, after fixing it |
401 | Authentication failed | Only after fixing the key |
403 | Authenticated, but not allowed | No |
404 | No such case or document, or not yours to read | No — it will not appear later |
500 | Something failed on our side | Yes, with backoff |
The one guarantee worth designing around
A 4xx from v3 always means nothing was created.
The case is written to the database only after authentication, validation, folder and template resolution, and document upload and conversion have all succeeded. Everything that can reject your request happens before that point.
Once the case exists, v3 will not turn around and report an error for it. Sending is isolated per channel and reported in delivery:
{
"case": {
"id": "65ab12cd34ef56ab78cd90b0",
"delivery": {
"attempted": true,
"email": { "ok": false, "error": "ECONNREFUSED" },
"eboks": { "ok": true },
"sms": { "ok": true },
"webhook": { "ok": true }
}
}
}This is the one guarantee to build your retry logic on. There is no window in which a case exists and the response says it failed, so you never have to ask "did that actually go through?" before retrying — the status code answers it.
Handle a failed channel as an operational event — alert, resend from the dashboard — not as a failed creation.
Authentication errors
| Status | error | Meaning |
|---|---|---|
401 | missing-token | No Authorization header |
401 | invalid-token | The key does not exist |
401 | key-revoked | The key was revoked |
401 | key-expired | The key passed its expiry date |
403 | company-disabled | The company account is disabled |
403 | key-has-no-user | The key has nobody to attribute cases to. Keys made in the dashboard always do — tell us if you see this |
See Authorization.
Request errors
error | Status | Meaning |
|---|---|---|
validation-failed | 400 | One or more fields are invalid. See details |
case-not-found | 404 | No case with that id in your company |
document-not-found | 404 | On the /v3/api/documents endpoints: no such document in your library, or not visible to this key. See What you see |
invalid-folder | 400 | No folder with that name or id |
message-template-not-found | 400 | messageTemplateId is not in your company |
preset-not-found | 400 | presetId is not in your company |
sender-user-not-found | 400 | senderId or senderEmail is not a member of your company |
invalid-signing-page-logo | 400 | settings.logo is not a PNG/JPEG, and not a NextSign URL |
document-not-found | 400 | On POST /v3/api/cases: a documentId is not in your library, or not visible to this key. details names the document |
document-version-not-found | 400 | versionId does not belong to that document |
document-conversion-failed | 400 | The .docx could not be converted |
document-production-failed | 400 | A library document could not be produced |
unsupported-file-type | 400 | A safety net behind the content check. You should never see it — tell us if you do |
Document errors name the offending item:
{
"error": "document-conversion-failed",
"details": [
{ "field": "documents[1]", "message": "the file could not be converted — is it a valid Word document?" }
]
}A file can be a well-formed .docx container and still be unopenable — a ZIP missing its relationship parts is a valid archive and a broken document. That is what this error means.
Notices are not errors
notices appears on successful responses and records what was defaulted, ignored or degraded:
code | Meaning |
|---|---|
sender-defaulted-to-key-creator | No senderId or senderEmail sent; the case was attributed to whoever created the API key |
default-signing-schemas | No settings.signingSchemas sent; the standard eIDs were used |
no-sharepoint-integration | settings.integrations.microsoft.returnPath was ignored |
contact-not-saved | A recipient could not be added to your contact list |
signing-page-logo-not-stored | The logo could not be stored; the case was created without it |
signing-urls-unavailable | The case exists — it was created, or you are reading it back — but its signing links could not be built |
Log them. Do not treat them as failures.
A notice always has a code and a message, and may carry extra fields describing what was used. default-signing-schemas reports the set it fell back to:
{
"code": "default-signing-schemas",
"message": "No settings.signingSchemas provided — the standard eIDs were used.",
"using": [
"urn:grn:authn:dk:mitid:low",
"urn:grn:authn:dk:mitid:substantial",
"urn:grn:authn:dk:mitid:business",
"urn:grn:authn:se:bankid",
"urn:grn:authn:de:personalausweis"
]
}Suggested client handling
const response = await fetch('https://api.nextsign.dk/v3/api/cases', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NEXTSIGN_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const body = await response.json();
if (response.status === 201) {
// The case exists. Never retry from here.
for (const [channel, result] of Object.entries(body.case.delivery)) {
if (result?.ok === false) alertOps(channel, result.error, body.case.id);
}
return body.case;
}
if (response.status === 400) {
// Nothing was created. Fix and resend.
throw new BadPayload(body.error, body.details);
}
if (response.status >= 500) {
return retryWithBackoff(); // safe: no case was created
}
throw new AuthError(body.error);response = requests.post(
"https://api.nextsign.dk/v3/api/cases",
headers={"Authorization": f"Bearer {os.environ['NEXTSIGN_API_KEY']}"},
json=payload,
)
body = response.json()
if response.status_code == 201:
# The case exists. Never retry from here.
for channel, result in body["case"]["delivery"].items():
if isinstance(result, dict) and result.get("ok") is False:
alert_ops(channel, result.get("error"), body["case"]["id"])
return body["case"]
if response.status_code == 400:
# Nothing was created. Fix and resend.
raise BadPayload(body["error"], body.get("details"))
if response.status_code >= 500:
return retry_with_backoff() # safe: no case was created
raise AuthError(body["error"])There is no idempotency key yet, so a retried 201 creates a second case. Retry only on network failures and 5xx.