Skip to main content

Handle API Errors

Turn a rejected request into something actionable: a message for the creator whose input was wrong, an alert for your own team when the fault is in the integration, and a request_id in your logs for the cases you have to escalate.

What You'll Need​

  • An access token — see getting an access token
  • A signature you registered — Record writes are only accepted for your own signatures
  • The error contract — Error Handling covers the response shape, the status codes, and the full reason catalogue

Read the response​

A rejected Record write returns 400 with one entry in details per offending field. Read the body before deciding anything, and capture SASHA-Request-ID on every response — including the successful ones, so a later support question has an ID to work from.

const putRecord = async (signatureId, record, accessToken, etag) => {
const response = await fetch(
`https://partner.api.sasha.eu/signature/${signatureId}/record`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
// Send the ETag you last read to make the write conditional.
...(etag ? { "If-Match": etag } : {}),
},
body: JSON.stringify(record),
},
);

const requestId = response.headers.get("SASHA-Request-ID");

if (response.status === 204) {
return { ok: true, etag: response.headers.get("ETag"), requestId };
}

// Every SASHA error response carries `message`; only a validation failure adds
// `details`. A rate-limited or rejected request may be answered by the edge
// before it reaches SASHA, so do not assume the body is JSON.
const isJson = response.headers.get("Content-Type")?.includes("application/json");
const error = isJson ? await response.json() : { message: await response.text() };

return { ok: false, status: response.status, error, requestId };
};

A stored Record returns 204 No Content with an ETag. Keep it: passing it back as If-Match on the next write is what makes concurrent updates safe, and it is what turns a lost update into a 412 you can act on.

Route each violation​

Branch on reason, never on message text. message and description are English, unlocalized, and their wording is not stable across releases.

The useful split is who can fix the problem. A creator can correct a URL they typed; nobody outside your team can correct a field name your code got wrong.

const INTEGRATION_FAULTS = new Set(["UNKNOWN_FIELD", "INVALID_TYPE", "REQUIRED"]);

const routeViolations = (error, requestId) => {
for (const violation of error.details ?? []) {
if (INTEGRATION_FAULTS.has(violation.reason)) {
// The creator cannot fix a field name or a JSON type. Page your own team.
reportIntegrationBug({ ...violation, requestId });
} else {
// Wrong value, right shape: show it to the creator who entered it.
showFieldError(violation.field, violation.description);
}
}

// A validation failure with no `details` is still a rejected request.
if (!error.details?.length) {
reportIntegrationBug({ message: error.message, requestId });
}
};

field carries the path in request-body naming, including indexes and map keys — allowed_publish_urls[2], custom_fields["asset_id"].string_value — so it maps onto a form field without further parsing.

Treat a reason you do not recognise as a generic validation failure. The set is closed but may be extended; the fallback above already does this, because anything outside INTEGRATION_FAULTS is shown to the creator with its description.

Decide what to retry​

const MAX_ATTEMPTS = 4;

// 100 ms before the first retry, doubling each time, capped at 1 s. Each wait is
// randomized between half of it and all of it.
const backOff = (attempt) => {
const ceiling = Math.min(100 * 2 ** (attempt - 1), 1000);
return new Promise((resolve) => setTimeout(resolve, ceiling / 2 + Math.random() * (ceiling / 2)));
};

// `state` starts as { attempts: 1, rereads: 0, refreshed: false } for each Record
// write. `retry` resends the write with what changed (a new token, or the merged
// Record with its new ETag) and the next state. Given null, it resends the write
// unchanged.
const handleFailure = async (result, state, retry) => {
switch (result.status) {
case 400:
// A validation failure is deterministic: fix it, do not retry it.
routeViolations(result.error, result.requestId);
return;
case 401:
// Token expired. See the Handle Token Expiration guide. Refresh once per
// write; a refresh does not use up a retry attempt.
if (state.refreshed) {
return reportIntegrationBug({
message: "Record write rejected on 401 with a fresh token",
requestId: result.requestId,
});
}
return retry(await refreshAccessToken(), { ...state, refreshed: true });
case 412:
// The Record changed since you read it: re-read, re-apply, and retry with
// the new ETag, once per write. A second 412 means the Record changed again
// after the re-read, by another writer or by a retry of this write whose
// response was lost.
if (state.rereads >= 1) {
return reportIntegrationBug({
message: "Record write rejected on 412 after a re-read; read the Record to see whether this write was applied",
requestId: result.requestId,
});
}
return retry(await rereadAndMerge(result), { ...state, rereads: state.rereads + 1 });
case 429:
case 503:
// A Record write is safe to repeat, so a rate limit or a temporary outage
// is retried with backoff. MAX_ATTEMPTS counts the first request and these
// retries; a 401 refresh and a 412 re-read do not add to it.
if (state.attempts >= MAX_ATTEMPTS) {
return reportIntegrationBug({ ...result.error, requestId: result.requestId });
}
await backOff(state.attempts);
return retry(null, { ...state, attempts: state.attempts + 1 });
default:
// 403, 404 and 409 are final: not permitted, not found, or revoked. A 500
// is not retried; escalate it with the request ID if it persists.
reportIntegrationBug({ ...result.error, requestId: result.requestId });
}
};

One 400 is not like the others: an Embed or Lookup submitted with a media_url returns 400 when SASHA cannot fetch that URL, which is the remote host failing rather than your request. Retry that one once before surfacing it.

The 429 and 503 cases apply SASHA's retry policy, which holds only for operations marked safe to repeat. A Record write is one; an Embed or Lookup submit is not, so do not put a submit through this function. A submit refused with MEDIA_STAGING_FULL is sent again after its Retry-After wait instead, as Media waiting to be processed describes. Only the 429 and 503 retries count against the four attempts: a token refresh on 401, once per write, does not use one up, and the 412 path is limited separately, to one re-read of the Record per write. Keep the four attempts inside the timeout of the original call. The 401 path is covered in Handle Token Expiration.

Handle the job, not only the response​

A 200 on an Embed or Lookup submit means the job was accepted, not that it succeeded. The processing failure arrives later, on the completed job:

const handleCompletedJob = (job) => {
if (job.status !== "failed") return;

if (job.error.code === "internal") {
return scheduleJobRetry(job); // Worth retrying: the media is fine.
}

// image_already_protected, image_load_failed, image_too_large, image_too_small
// are properties of the media. The same file fails the same way every time.
return showMediaError(job.error.code, job.error.message);
};

Code that checks only the HTTP status of the submit will record a failed job as a success. Error Handling lists every error.code the API produces.

Log the request ID​

logger.warn("Record write rejected", {
sashaRequestId: result.requestId,
status: result.status,
reasons: (result.error.details ?? []).map((violation) => violation.reason),
orderId: yourOwnCorrelationId,
});

Log SASHA-Request-ID next to your own identifiers. A failure nobody can explain is far quicker to resolve when SASHA support can be handed the exact ID, and the reason values tell you which failures are worth investigating without reading every message.

Never log the values that were rejected if they came from a creator. description never echoes the submitted value, which is what makes it safe to log as-is.