Manage Records
Attach distribution intent and display metadata to a signature you registered, keep it up to date, and read the guidance SASHA returns when the media is looked up. This guide assumes you already have a signature_id from an embed job and an access token.
For what a Record is and what each field means, see Records. Every operation below has a reference page: Put, Get, Patch, Delete.
The gRPC examples use a signatureClient built from the PartnerAPI service definition and a metadata carrying your bearer token; see Authentication for obtaining the token. ShareState is the generated enum from sasha/common/public/share_state.proto.
Attach a Record
Attach a Record with PUT. It is a full-replace upsert: the stored Record becomes exactly what you send. You can do this any time after the signature is registered — it does not have to be at embed time.
- REST
- gRPC
curl -X PUT https://partner.api.sasha.eu/signature/12345678901234567890/record \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"share_state": "restricted",
"allowed_publish_urls": ["https://news.example.com/articles/"],
"allowed_from": "2026-07-01T06:00:00Z",
"byline": "Photo by Jane Doe",
"purchase_url": "https://license.example.com/photos/123"
}'
A 204 No Content with an ETag header confirms the write. Keep the ETag — it is the signature's current revision, and you pass it back to make a later write conditional (see Concurrent updates).
On gRPC there is one write method, UpdateSignatureRecord. To attach or fully replace a Record, name record in the update mask:
const { revision } = await signatureClient.updateSignatureRecord(
{
signatureId: "12345678901234567890",
record: {
shareState: ShareState.SHARE_STATE_RESTRICTED,
allowedPublishUrls: ["https://news.example.com/articles/"],
allowedFrom: new Date("2026-07-01T06:00:00Z"),
byline: "Photo by Jane Doe",
purchaseUrl: "https://license.example.com/photos/123",
},
updateMask: { paths: ["record"] },
},
{ metadata }
);
// `revision` is the signature's new revision — the gRPC equivalent of the ETag.
The Record must satisfy the consistency rules: share_state is required, and private / unrestricted Records must not carry distribution fields. A Record that breaks them is rejected with 400 Bad Request (INVALID_ARGUMENT on gRPC) and nothing is stored. The response names every offending field — see Errors.
Unknown fields are rejected rather than ignored. PUT replaces the whole Record, so a misspelled field name would otherwise be dropped silently and clear the value it was meant to set. A camelCase spelling is the usual cause: the JSON body uses the snake_case field names, while a generated gRPC client shows the same fields as purchaseUrl.
Attach a Record at embed time
An embed request can carry the Record with it, so the media is never registered without its intent. This closes the window in which the signature exists but has no Record and is therefore treated as private.
- REST
- gRPC
curl -X POST https://partner.api.sasha.eu/signature/embed \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"media_url": "https://example.com/image.jpg",
"media_mime_type": "image/jpeg",
"record": {
"share_state": "restricted",
"allowed_publish_urls": ["https://news.example.com/articles/"],
"byline": "Photo by Jane Doe"
}
}'
const { job } = await signatureClient.embedSignatureFromURL(
{
mediaUrl: "https://example.com/image.jpg",
mediaMimeType: "image/jpeg",
record: {
shareState: ShareState.SHARE_STATE_RESTRICTED,
allowedPublishUrls: ["https://news.example.com/articles/"],
byline: "Photo by Jane Doe",
},
},
{ metadata }
);
The Record is validated when the request is submitted, not when the job runs. An invalid one fails the embed request itself with 400 naming the offending fields, and no job is created — so you never end up with a protected image whose Record silently failed to attach. Violation paths are prefixed record., for example record.purchase_url.
Read a Record
Only the partner that registered a signature can read its Record back in full. Use GetSignatureRecord.
- REST
- gRPC
curl https://partner.api.sasha.eu/signature/12345678901234567890/record \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" -i
The response is the stored Record, and the ETag header carries the current revision:
ETag: "5"
{
"share_state": "restricted",
"allowed_publish_urls": ["https://news.example.com/articles/"],
"allowed_from": "2026-07-01T06:00:00Z",
"byline": "Photo by Jane Doe",
"purchase_url": "https://license.example.com/photos/123",
"created_at": "2026-06-15T10:00:00Z",
"updated_at": "2026-06-20T08:30:00Z"
}
A signature with no Record attached returns 404 — the Record sub-resource does not exist. That is the same status an unknown signature returns, so a 404 does not tell you which of the two it was.
created_at and updated_at are set by SASHA and cannot be written. They are accepted and ignored on a write, so you can send a Record straight back the way you read it. Any other unrecognised field is rejected — see Errors.
const { record, revision } = await signatureClient.getSignatureRecord(
{ signatureId: "12345678901234567890" },
{ metadata }
);
// `record` is unset when the signature has no Record attached.
// `revision` plays the role of the ETag for conditional writes.
The two surfaces differ here: gRPC succeeds with record unset and still hands you a usable revision, where REST returns 404 and no ETag. On REST, a 404 means you have no revision to make a conditional write from — use If-Match: * to require only that the signature exists.
GetSignatureRecord is the only reliable way to read your own Record. Guidance and disclosed fields returned on a lookup are transient; your durable copy lives here.
Update part of a Record
To change a few fields without resending the whole Record, use PATCH (REST) or a field-scoped update mask (gRPC).
- REST
- gRPC
PATCH uses JSON Merge Patch: fields you send replace the stored value, fields set to null are cleared, and absent fields are left unchanged — except share_state, which is required and cannot be cleared.
curl -X PATCH https://partner.api.sasha.eu/signature/12345678901234567890/record \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/merge-patch+json" \
-d '{ "byline": "Photo by Jane Doe / Example Agency" }'
Arrays and objects are replaced wholesale, not merged — sending allowed_publish_urls replaces the entire list. The patched result must still satisfy the consistency rules, or the write is rejected and nothing changes.
Name only the fields you are changing in the update mask, as record.<field> paths:
await signatureClient.updateSignatureRecord(
{
signatureId: "12345678901234567890",
record: { byline: "Photo by Jane Doe / Example Agency" },
updateMask: { paths: ["record.byline"] },
},
{ metadata }
);
Each named field is replaced wholesale (repeated and map fields included). A path named with its value left unset clears that field.
Delete a Record
Deleting a Record removes the attached metadata only. The signature stays valid and reverts to the default "no Record means private" interpretation. Deleting is idempotent — deleting when nothing is attached still succeeds.
- REST
- gRPC
curl -X DELETE https://partner.api.sasha.eu/signature/12345678901234567890/record \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
A 204 with an ETag confirms the delete; that ETag is the signature's new revision. If-Match works here too, so a delete can be made conditional the same way a write can.
Detach the Record by naming record in the mask with the value left unset:
await signatureClient.updateSignatureRecord(
{
signatureId: "12345678901234567890",
updateMask: { paths: ["record"] },
},
{ metadata }
);
Concurrent updates
Every read and write hands you the signature's current revision — the ETag header on REST, the revision field on gRPC. Pass it back on your next write to make the write conditional, so two clients editing the same Record can't silently clobber each other.
Every accepted write bumps the revision, including one that changes nothing. The revision tracks writes, not content, so it is a safe concurrency token but not a change detector.
- REST
- gRPC
Send the revision you last saw in an If-Match header, exactly as the ETag gave it to you. If the Record has moved on since, the write is rejected with 412 Precondition Failed and nothing changes — re-read and retry:
async function patchByline(signatureId, byline) {
for (let attempt = 0; attempt < 3; attempt++) {
const read = await fetch(`${BASE}/signature/${signatureId}/record`, {
headers: { Authorization: `Bearer ${token}` },
});
const etag = read.headers.get("ETag");
const write = await fetch(`${BASE}/signature/${signatureId}/record`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/merge-patch+json",
"If-Match": etag,
},
body: JSON.stringify({ byline }),
});
if (write.status !== 412) return write; // applied (or a real error)
// 412: someone else wrote first — loop to re-read and retry.
}
throw new Error("record update kept losing the race");
}
Bound the loop as above rather than retrying until it succeeds. A 412 is not always a lost race: a revoked signature can no longer be modified, and when the write carried an If-Match that rejection also surfaces as 412, so an unbounded loop on a revoked signature would never terminate.
Two details on the header itself:
- Send the
ETagvalue verbatim, quotes included. A weak validator (W/"5") or any non-numeric value is rejected with400. If-Match: *asserts only that the signature exists, not which revision it is at, so it never produces a412. Use it to require existence; use a concrete revision to require that revision.
Omit If-Match entirely for last-write-wins.
Set expected_revision to the revision you last read. The update applies only if the signature is still at that revision; otherwise it fails with FAILED_PRECONDITION — re-read and retry:
const { revision } = await signatureClient.getSignatureRecord(
{ signatureId },
{ metadata }
);
await signatureClient.updateSignatureRecord(
{
signatureId,
record: { byline: "Photo by Jane Doe / Example Agency" },
updateMask: { paths: ["record.byline"] },
expectedRevision: revision,
},
{ metadata }
);
Omit expectedRevision for last-write-wins.
Read guidance on a lookup
When you look up an image, supply where you found it — publish_url and source_url. SASHA evaluates the found signature's Record against that context and returns guidance on the completed job.
- REST
- gRPC
curl https://partner.api.sasha.eu/signature/lookup \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"media_url": "https://example.com/image.jpg",
"media_mime_type": "image/jpeg",
"publish_url": "https://news.example.com/articles/123",
"source_url": "https://cdn.example.com/images/abc.jpg"
}'
The completed job — read with GetJob or delivered by a callback — carries guidance:
{
"job_id": "job_12345",
"type": "lookup-signature",
"status": "completed",
"created_at": "2026-07-01T09:00:00Z",
"updated_at": "2026-07-01T09:00:12Z",
"signature_id": "12345678901234567890",
"creator_id": 1234567890,
"guidance": {
"share_state": "restricted",
"advice": "not_allowed"
}
}
const { job } = await signatureClient.lookupSignatureFromURL(
{
mediaUrl: "https://example.com/image.jpg",
mediaMimeType: "image/jpeg",
// On gRPC the two URLs are nested in `guidance_context`, not flat as on
// REST. Sending them at the top level silently drops the context, and a
// restricted Record then evaluates to `inconclusive`.
guidanceContext: {
publishUrl: "https://news.example.com/articles/123",
sourceUrl: "https://cdn.example.com/images/abc.jpg",
},
},
{ metadata }
);
// Read `guidance` off the completed job (poll GetJob or stream WatchJob).
Branch your logic on advice and share_state. A restricted Record that carries URL patterns can only return inconclusive when you supply neither URL — but a condition that can be evaluated still decides: an embargo that has not opened yet returns not_allowed with no URL context at all.
If the looked-up signature is one you own (or one whose owner granted you fields, or that has public fields), the completed job may also carry a field-filtered record. That disclosure is transient — for a durable read of your own Record, use Read a Record.
Errors
| REST | gRPC | Meaning | What to do |
|---|---|---|---|
400 | INVALID_ARGUMENT | The Record breaks a consistency rule, carries a field the endpoint does not define, or the mask names an unknown field. | Fix the named fields. Do not retry — the same request fails the same way. |
403 | PERMISSION_DENIED | The access token lacks the scope this operation requires. | Do not retry with the same token. |
404 | NOT_FOUND | No such signature, or one your partner account did not register. The two are deliberately indistinguishable, so that the API does not confirm the existence of another creator's signature. | Do not retry. |
409 | FAILED_PRECONDITION | The signature is revoked; its Record can no longer be modified. | Do not retry. |
412 | FAILED_PRECONDITION | The If-Match / expected_revision precondition did not match. | Re-read and retry, bounded — see Concurrent updates. |
A 400 names every offending field, so one request tells you everything that is wrong rather than one problem at a time:
{
"message": "purchase_url must use the https: protocol (+1 more)",
"request_id": "8f14e45f-ceea-467a-9f0b-2b3a2c5a7d61",
"details": [
{ "field": "purchase_url", "reason": "UNSUPPORTED_SCHEME",
"description": "purchase_url must use the https: protocol" },
{ "field": "allowed_publish_urls[1]", "reason": "INVALID_PATTERN",
"description": "allowed_publish_urls[1]: wildcard is only allowed as the leftmost label" }
]
}
Branch on reason, never on message text, and treat a reason you do not recognise as a generic validation failure. Error Handling has the full catalogue; Handle API Errors covers routing each violation to the right place.
When a write against a revoked signature carries an If-Match, it currently reports 412 rather than 409. Both mean the write was rejected and nothing changed, but a retry loop keyed on 412 will not make progress — which is why the loop above is bounded.
A Record's c2pa_manifest_store cannot be set through the Partner API — that field is left unset here and is managed via the SDK API. Everything else on the Record is fully editable through this surface.
See also
- Records — the Record model, share states, URL pattern syntax, and disclosure
- Error Handling — the error response shape and the
reasoncatalogue - Handle API Errors — routing a field violation to the right place
- Signature — embed and lookup
- Set Up Callback Notifications — get notified when a lookup completes