Error Handling
A failing Partner API request returns a JSON body carrying a message. A validation failure adds a details array naming every field that was rejected, so a malformed request can be corrected in one pass rather than one field at a time.
Three parts of an error are contract and safe to branch on: the HTTP status code, the reason the body carries for some failures, and the reason on each entry in details. Everything else, including all human-readable text, may change between releases.
For the code that acts on this, see Handle API Errors.
Status codes
400 | The request was rejected. On a validation failure, every offending field is listed. | On Embed and Record writes | No, with one exception below |
401 | The access token is missing, expired, or invalid. | No | After obtaining a new token |
403 | The credential is valid but not permitted to perform this operation. | No | No |
404 | The job or signature does not exist, was registered by another partner, or has no Record attached. | No | No |
409 | The signature is revoked, so its Record can no longer be modified. | No | No |
412 | The If-Match precondition did not match the signature's current revision. The Record was not modified. | No | Once, after re-reading the Record |
429 | With an HTML body: the request exceeded the rate limit and was rejected before it reached the API. With a JSON body: a video submit was refused because the partner already has as many video jobs in progress as it is allowed. | No | Rate limit: only an operation safe to repeat, as in Retrying a request. Video: submit again once a video job completes. |
500 | An internal failure. | No | No. If 500 recurs on later requests, escalate with the request_id. |
503 | A dependency is temporarily unavailable. With reason MEDIA_STAGING_FULL, an Embed or Lookup submit was refused because SASHA is holding as much submitted media as it can, and no job was created. | No | MEDIA_STAGING_FULL: submit again after the Retry-After wait, as in Media waiting to be processed. Otherwise only an operation safe to repeat, as in Retrying a request |
Only 400 carries details, and only where the endpoint validates a request body: Embed, PUT Record and PATCH Record. Every other failure from the API carries message, and a top-level reason when the failure has a named cause: today only MEDIA_STAGING_FULL on a 503. 401 and 403 return a body of message only, with no request_id — read the SASHA-Request-ID header instead.
The exception to "do not retry a 400": an Embed or Lookup submitted with a media_url returns 400 when SASHA cannot fetch that URL, which is a failure of the remote host rather than of the request. That one is worth a single retry; every validation failure is deterministic and is not.
412 does not always mean a stale ETag. A Record write on a revoked signature also reports 412 when it carries an If-Match header, so a re-read-and-retry loop on 412 will not converge. Retry once; if the second attempt fails the same way, treat the signature as revoked and stop.
404 covers an unknown signature, one registered by a different partner, and one that has no Record attached. The first two are deliberately indistinguishable: separating them would tell a caller which SignatureIDs exist.
Every response from the API, successful or not, carries a SASHA-Request-ID header identifying that request. It joins a partner's call to the server-side logs, and it is the first thing SASHA support asks for. A request rejected by the rate limit never reached the API, so its response has none.
Retrying a request
SASHA recommends an automatic retry only when both of these hold:
- It failed with a status that says "not now": gRPC
UNAVAILABLE, or REST429from the rate limit or503. - The operation is marked safe to repeat. Repeating it has the same effect as sending it once, except that a repeated Record write moves the revision on again.
| Get an access token | POST /oauth/token | AuthenticateWithClientCredential | IDEMPOTENT |
| Read a Record | GET /signature/{signature_id}/record | GetSignatureRecord | NO_SIDE_EFFECTS |
| Write or delete a Record | PUT, PATCH and DELETE /signature/{signature_id}/record | UpdateSignatureRecord | IDEMPOTENT |
The last column is the idempotency_level option the RPC carries in partner_api.proto.
SASHA recommends this policy for those operations:
- At most 4 attempts in total, the first request included.
- Wait 100 ms before the first retry, and double the wait before each further one, up to 1 second.
- Randomize each wait between half of it and all of it, so that many clients rejected at the same moment do not retry in step.
- Keep every attempt inside the deadline or timeout of the original call. A retry that cannot finish before then is not sent.
- Never retry a streaming call:
EmbedSignatureFromData,LookupSignatureFromDataandWatchJob.
A conditional Record write whose first attempt was applied, but whose response was lost, reports 412 on the retry, because the first attempt already moved the revision on. So a 412 on a retry calls for a re-read of the Record before the write is treated as failed.
Every other operation, including submitting an Embed or a Lookup job and reading jobs, carries no such mark, and SASHA recommends not retrying it automatically. A request that failed with UNAVAILABLE or 503 may still have been carried out, and the Partner API does not recognize a repeated submit as the same request: a retried Embed submit can create a second job and register a second Signature, which may be billed.
Media waiting to be processed
An Embed or Lookup submit that sends an image, by media_url or as an upload, is refused when SASHA is already holding as much submitted media as it can until jobs it has accepted finish processing. The request is valid, but no job is created. Space returns as those jobs complete, usually within minutes.
| REST | 503 with a Retry-After header and "reason": "MEDIA_STAGING_FULL" in the ErrorResponse body |
| gRPC | UNAVAILABLE with the reason MEDIA_STAGING_FULL in a google.rpc.ErrorInfo of domain partner-api.sasha.eu, carried in the grpc-status-details-bin trailer, and a retry-after trailer |
Retry-After and retry-after hold a number of seconds, currently 30. This is the one refused submit that SASHA recommends sending again, because no job was created: the same request sent again cannot create a second job or register a second Signature. Wait at least the given number of seconds, then submit the same request again. If it is refused the same way, wait again before the next attempt. If submits are still refused after five minutes, stop and contact SASHA with the request_id.
Branch on the reason, not on the status alone. A submit refused with 503 or UNAVAILABLE and no reason may already have been carried out, so the advice in Retrying a request applies to it: retry it only if a second job is acceptable.
Jobs fail after the request succeeds
Embed and Lookup are asynchronous. A 200 on the submit means the job was accepted, not that it worked: the media is processed afterwards, and the failure arrives on the completed job as status: "failed" with an error object, either on a poll of GET /jobs/{job_id} or on the callback. An integration that only handles HTTP status codes will treat a failed job as a success.
image_already_protected | The image already carries a SASHA Signature. |
image_load_failed | The media is not a valid image, or is in a format SASHA does not process. |
image_too_large | The image exceeds the size SASHA processes. See Supported Formats. |
image_too_small | The image is below the minimum size. |
internal | An internal failure. Retry the job; if it persists, escalate with the request_id of the submit. |
Only internal is worth retrying. The other four are properties of the media, so the same media fails the same way.
A media_url that cannot be fetched does not reach this stage: the download happens during the submit, so it fails the request with 400 rather than the job.
Error response shape
A failing request to the Signature, Job or Record endpoints returns an ErrorResponse:
{
"message": "purchase_url must use the https: protocol (+2 more)",
"request_id": "8f14e45f-ceea-467a-9f0b-2b3a2c5a7d61",
"details": [
{
"field": "purchase_url",
"reason": "UNSUPPORTED_SCHEME",
"description": "purchase_url must use the https: protocol"
}
]
}
message | always | Human-readable summary. On a validation failure it is the first details entry's description, suffixed with (+N more) when the request has more than one violation. |
request_id | when available | Mirrors the SASHA-Request-ID response header. |
reason | when the failure has a named cause | A machine-readable cause to branch on, such as MEDIA_STAGING_FULL. Absent on validation failures, whose causes are in details. A value an integration does not recognize is handled as the general case of the HTTP status. |
details | validation failures only | Every field that was rejected, up to 20 entries. |
The /oauth/token endpoint is the exception: it returns the OAuth 2.0 error shape (error, error_description, error_uri) defined by RFC 6749. See Authentication.
Field violations
Each details entry names one rejected field:
field— the path of the offending field, in request-body naming. Array entries are indexed and map entries are keyed:purchase_url,allowed_publish_urls[2],custom_fields["asset_id"].string_value.reason— a machine-readable cause. This is the value to branch on.description— a human-readable explanation. It never echoes the submitted value, so it is safe to log and to surface in a partner's own error reporting.
A request with several problems reports all of them at once:
{
"message": "share_state must be one of: private, unrestricted, restricted (+2 more)",
"request_id": "2c3f9b7d-5a41-4a53-8f2e-9b7a1c0d4e62",
"details": [
{
"field": "share_state",
"reason": "UNKNOWN_VALUE",
"description": "share_state must be one of: private, unrestricted, restricted"
},
{
"field": "allowed_publish_urls[1]",
"reason": "INVALID_PATTERN",
"description": "allowed_publish_urls[1]: wildcard is only allowed as the leftmost label"
},
{
"field": "purchaseUrl",
"reason": "UNKNOWN_FIELD",
"description": "purchaseUrl is not a field of this request; did you mean purchase_url? (this API uses snake_case field names)"
}
]
}
Reason catalogue
The FieldViolation schema in the REST API reference is the authoritative list: it is generated from the API specification and gains new values as they are introduced. This table explains what each current value means, and is the copy that goes stale first.
REQUIRED | A field the request cannot omit was not provided. |
INVALID_TYPE | The value has the wrong JSON type for the field. |
INVALID_FORMAT | The value has the right type but is not well-formed, for example an unparsable URL or timestamp, or a fixed-length value of the wrong length. |
UNSUPPORTED_SCHEME | A URL uses a scheme the field does not accept. Only https is accepted. |
FORBIDDEN_USERINFO | A URL carries userinfo (user:password@), which is never accepted. |
FORBIDDEN_HOST | A URL points at a host the field does not accept, such as an IP literal or localhost. |
TOO_LONG | The value is longer than the field allows. |
TOO_MANY | The list or map holds more entries than the field allows. |
INVALID_CHARACTERS | The value contains characters the field does not accept, such as control characters. |
UNKNOWN_VALUE | The value is not one of the values the field defines, for example an unknown share_state. |
NOT_ALLOWED_HERE | The field is well-formed but not allowed in this combination, for example a distribution field on a private Record. |
EXACTLY_ONE_REQUIRED | Exactly one of a group of mutually exclusive properties must be set, for example the typed value of a custom_fields entry. |
UNKNOWN_FIELD | The body carries a field the endpoint does not define — commonly a misspelling, or a camelCase spelling of a snake_case field. |
EMPTY | The value is present but empty where a value is required. |
INVALID_PATTERN | A URL pattern is malformed and cannot be interpreted. |
The set is closed but may be extended, so an unrecognised reason means a value newer than the integration, not a malformed response. Treating it as a generic validation failure keeps an integration working across releases; failing hard on it does not.
Field naming
The REST request body uses snake_case field names, matching the API specification exactly. Generated gRPC clients typically present the same fields camelCased — purchaseUrl in gRPC is purchase_url in JSON. Sending the camelCase spelling to the REST API produces an UNKNOWN_FIELD violation naming the correct spelling.
Unknown fields on a Record write are rejected rather than ignored. This is deliberate: PUT /signature/{signature_id}/record replaces the whole Record, so a misspelled field name would otherwise be silently dropped, clearing the value it was meant to set.