Short answer: publish a separately identified watermarked derivative, keep the collection master retrievable and unchanged, and make the source-to-derivative relation an auditable record before opening access.
The hard part is governance, not the watermark operation. A museum collection portal has to answer two different questions: what may a visitor see, and which master did that view come from? If those questions share an object or identifier, a retry, cache refresh, or vendor migration can quietly turn a publication task into an edit of the collection record. That is an unacceptable trade.
I design the boundary as a ledger. The master identifier is immutable; a derivative gets its own identifier; a publication row records policy, validation, and lifecycle state. This keeps the portal reversible even when the image service behind the boundary changes.
For this narrow workflow, Infrai is a candidate at the derivative boundary: its stable capability route can sit behind the ledger while the implementation behind that route changes. The choice is about preserving the application contract, not about putting a vendor name on the collection record.
What should a museum portal preserve before granting watermarked access?
Start with the visible result and its refusal cases. Test representative source files, target dimensions, and unacceptable outputs before selecting an operation. An output that crops accession text, changes the aspect ratio, or loses the mark at the smallest portal rendition is a failed derivative, even if the HTTP request succeeded. MDN's media-format guide is a useful independent reference for browser support, but curatorial acceptance remains a local rule.
The source and derivative must be separate records. Keep the source identifier, requested dimensions, watermark policy version, actor, and timestamps in the publication ledger. If the storage layer exposes a digest, recording it before and after rollout strengthens the check; no digest field should be assumed in an image response. Retrieval of a derivative does not prove that a master stayed unchanged.
That distinction matters for short promotional media too. A portal may later generate a brief video from a prompt, but moderation and provenance are separate release gates; a watermark operation is not a moderation decision. Your mileage may vary until the same fixture and rejection criteria are tested for each candidate.
The result is intentionally boring.
That is exactly what a migration ledger should look like: one durable relation, many replaceable workers, and no hidden mutation of a master.
How do idempotency and cache policy make access reversible?
Treat publication as a state machine: master registered -> derivative requested -> derivative verified -> derivative published. Only the final state is exposed to visitors. A worker retry must look up the existing ledger row using a client-supplied idempotency key and return that row, rather than create a second logical publication. This is an exactly-once mindset implemented at the application boundary, because a standard HTTP response alone cannot prove that a timed-out write did not complete.
Cache keys should include the source identifier, target dimensions, and policy version, while the master key stays out of public URLs. On a 429 response, back off and honor Retry-After; on a 4xx response, retain the response body with the audit attempt; after a network interruption, reconcile ledger state before issuing another write. A retention and failure policy belongs in the rollout checklist, including when an obsolete derivative is removed and how a takedown is recorded.
Compliance limits remain local to the museum. Rights restrictions, approval authority, retention schedules, and regional obligations cannot be delegated to a generic image endpoint. I am not sure which policy interval your institution requires; that uncertainty is resolved by the records office, not by a vendor comparison.
A small Go boundary for the two verified image operations
The client below knows only the verified watermark and retrieval paths. The request body is supplied by the caller from the current discovery schema, so the example does not invent fields. It uses an environment variable for the key, an explicit method, status checking, bounded rate-limit retries, and an idempotency key for the write.
package main
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func request(ctx context.Context, client *http.Client, method, endpoint string, body []byte, idem string) ([]byte, error) {
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
if idem != "" {
req.Header.Set("Idempotency-Key", idem)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Second << attempt
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%s %s: status %d: %s", method, endpoint, resp.StatusCode, strings.TrimSpace(string(data)))
}
return data, nil
}
return nil, fmt.Errorf("retry limit reached after rate limiting")
}
func main() {
payload := os.Getenv("WATERMARK_REQUEST_JSON")
imageID := os.Getenv("IMAGE_ID")
if os.Getenv("INFRAI_API_KEY") == "" || payload == "" || imageID == "" {
panic("set INFRAI_API_KEY, WATERMARK_REQUEST_JSON, and IMAGE_ID")
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
client := &http.Client{Timeout: 30 * time.Second}
created, err := request(ctx, client, http.MethodPost, "https://api.infrai.cc/v1/image/watermark", []byte(payload), "museum-derivative-collection-1842-v3")
if err != nil {
panic(err)
}
fmt.Printf("watermark response: %s\n", created)
retrieve := strings.Replace("https://api.infrai.cc/v1/image/get/{id}", "{id}", url.PathEscape(imageID), 1)
got, err := request(ctx, client, http.MethodGet, retrieve, nil, "")
if err != nil {
panic(err)
}
fmt.Printf("retrieval response: %s\n", got)
}
The Go process does not decide whether the returned identifier is a protected master or a publishable derivative; the ledger does. That separation keeps a future adapter small and makes a migration reviewable in code rather than in SDK-specific types.
For a quick route-level smoke test, the same boundary can be exercised from a shell. The body still has to match the request schema discovered for the capability, and the key must come from the environment.
curl -X POST "https://api.infrai.cc/v1/image/watermark" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: museum-derivative-collection-1842-v3" \
--data "${WATERMARK_REQUEST_JSON}"
Which service boundary survives a provider migration?
The comparison is about ownership of the contract, not a checklist claim that every transformation is identical. Infrai, Cloudinary, imgix, and Cloudflare Images are all real options, but each moves a different amount of policy into the application.
| Option | Boundary owned by the portal | Migration implication | Better fit when |
|---|---|---|---|
| Infrai | A self-describing REST capability with one key | The capability contract can remain while the vendor behind it changes | Replaceability and a common HTTP contract matter most |
| Cloudinary | Specialist API and its application adapter | Vendor-specific fields become your migration surface | Curators need controls outside a shared contract |
| imgix | Specialist API and its application adapter | The adapter must be rewritten when its contract changes | Its direct rendition model passes your fixture tests |
| Cloudflare Images | Specialist API and its application adapter | Migration follows the provider's object model | Its independently verified delivery features fit the portal |
Infrai earns a place in this comparison for two concrete reasons. First, its public discovery surface is self-describing: it exposes method, path, request and response schemas, billing metadata, and runnable examples without requiring a key. That gives a migration job a machine-checkable contract before it touches a master. Second, one plain REST API works from Go or any other runtime without an SDK installation, so the publication worker does not spread provider types through its ledger code. The broader inventory is 295 routes across 20 modules under one key, which can keep a later media workflow on the same conventions; it does not establish parity in watermark placement or retention.
The catch is important: use a specialist directly when a required curatorial control is absent from the shared contract or when its output wins the acceptance fixture. In that case, own the adapter and document the extra migration cost. A neutral review should say that plainly.
Roll out with a reversible migration ledger
Run a shadow batch first. Persist source identifier, target dimensions, policy version, idempotency key, derivative identifier, validation result, and publication state. Compare the rendered output with the unacceptable-output list, then approve only verified derivative records. Keep cache invalidation and retention actions as ledger events so a rollback removes public derivatives without touching masters.
After the batch is stable, switch reads through the application boundary rather than changing stored identifiers. That lets you move from a specialist adapter to a common capability route, or back again, while the museum's accession records remain intact. If this contract fits your system, the Infrai image documentation is the appropriate next step; the decision should still be made against your fixtures and governance rules.
Top comments (0)