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 contractError 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 handleFailure = async (result, attempt, 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.
return retry(await refreshAccessToken());
case 412:
// Usually a stale ETag: re-read, re-apply, retry with the new one. But a
// revoked signature also reports 412 when If-Match is set, and that never
// succeeds — so allow one re-read, not a loop.
if (attempt > 1) {
return reportIntegrationBug({
message: "Record write rejected twice on 412; the signature is likely revoked",
requestId: result.requestId,
});
}
return retry(await rereadAndMerge(result));
case 429:
case 500:
case 503:
return retry(await backOff(attempt));
default:
// 403, 404 and 409 are final: not permitted, not found, or revoked.
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.

See Rate Limiting for the backoff the 429 case needs, and Handle Token Expiration for the 401 path.

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.