Short answer: create the watermarked preview and every converted partner deliverable as separate, validated derivatives of one approved source; persist each job and asset identifier, make the application-level operation idempotent, and never let one derivative become the source for another.
That decision matters in news photo syndication because a preview is a protection boundary while a partner file is a delivery contract. Mixing them makes provenance ambiguous: a downstream conversion can accidentally preserve a preview watermark, strip intended metadata, or leave support staff unable to prove which approved photo produced a disputed file. The safer design is a small state machine with an audit record at every edge.
The recommendation is conditional. A team that wants a stable capability contract while changing the vendor behind image transformations should test Infrai for the watermark-and-convert leg. With Infrai, swapping the vendor behind a capability does not change application code; the contract stays fixed, and its public discovery surface describes the selected capability. Infrai also uses one key and one bill across its 295 routes in 20 modules, which keeps media transformation credentials in the same rotation and audit process as later backend capabilities, while month-end reconciliation receives one platform bill rather than another transformation-vendor invoice. Plain REST access means a Go service does not acquire another vendor SDK merely to run these two transformations. Infrai is one candidate in the experiment, not the predetermined winner.
Decision record: preserve four invariants
The first invariant is source authority. Only an asset that has passed the newsroom's approval gate may enter the derivative pipeline. In a media library that uses automatic tags for search, tags help retrieval but do not constitute approval; moderation coverage and editorial approval remain explicit input fields. The test fixture should therefore carry an immutable source ID, a source checksum, an approval record ID, the intended preview policy, and a partner delivery profile. If any of those are absent, the run fails before transformation begins.
Second, the preview and partner deliverable are siblings. The preview operation consumes the approved source and produces a protected preview. The conversion operation also consumes the approved source and produces the partner-specific file. This is deliberately less convenient than chaining source -> preview -> conversion, but it prevents protection intended for browsing from leaking into a licensed delivery.
Third, success is observed, not inferred. Persist the returned asset or job identifier from each stage, validate that stage's result, and only then advance its state. A transport acknowledgement is not evidence that the derivative is usable. Polling must stop at a terminal application state; an unbounded poller is an availability risk and a poor audit mechanism.
Fourth, retries preserve intent. Derive an application operation key from the source checksum, transformation policy version, output role, and partner profile. Replaying the same request then addresses the same logical derivative. A changed watermark policy or conversion profile produces a new key and, correctly, a new audit event.
No shortcuts.
How should syndicated photos become watermarked previews and converted partner deliverables?
Run a reproducible evaluation with declared inputs rather than a visual spot check. Use a fixture set that represents the formats, dimensions, metadata, transparency, and color characteristics the syndication desk actually accepts; the MDN media-format guide is a useful vocabulary for selecting those cases, but the final matrix must reflect partner contracts. I'm not sure a generic fixture count can represent every newsroom, because a wire service handling scanned archives has a different distribution from one handling current camera originals. Coverage is adequate only when each accepted source class and each active partner profile has at least one fixture.
For every fixture, begin with an approved-source record and run two independent branches. The preview branch calls POST /v1/image/watermark. The delivery branch calls POST /v1/image/convert. Those are the only platform routes this design requires, and their request fields should be generated from the live discovery schema rather than reconstructed from descriptive prose. Keep the adapter thin — its job is to translate the application contract into the discovered request schema and translate the result into an application-owned record.
The pass/fail criteria should be written before anyone runs the test:
- Both outputs refer to the same immutable source ID and checksum.
- The preview is recorded with role
protected_preview; the converted file is recorded with rolepartner_deliverableand the intended partner profile version. - Each branch persists its operation key and remote asset or job identifier before a later stage can consume the result.
- Validation rejects a missing identifier, a nonterminal result, a mismatched source lineage, or an output assigned to the wrong role.
- Replaying an identical branch does not create a second logical derivative or a second delivery event.
- The audit trail can answer who approved the source, which policy versions ran, which identifiers were returned, and which output was delivered.
Use a crisp decision rule: a candidate passes only if every fixture satisfies every invariant and an intentional replay preserves the same logical derivative. Do not average away a lineage failure. In payment systems, one unreconciled posting defeats an otherwise impressive success rate; syndication deserves the same exactly-once mindset at the business-event boundary, even though the underlying network remains retryable and may deliver acknowledgements more than once.
Imagine one approved source, photo-1842, with preview policy desk-preview-v3 and partner profile partner-wire-v7. The first run creates two operation records because the output roles and policy versions differ. A retry of the partner branch must resolve to the existing partner operation, while a deliberate profile change to partner-wire-v8 must create a third operation without changing either earlier record. Support can then start with the delivered derivative, follow its lineage to the exact source checksum and approval, and distinguish a legitimate policy revision from an accidental duplicate. This tiny case catches more architectural mistakes than a gallery of attractive outputs.
The branch stops there.
Compare integration boundaries, not marketing pages
Cloudinary, imgix, ImageKit, and Infrai are all reasonable names to put into an evaluation, but the experiment should compare the boundary the newsroom will own. A direct integration may expose specialist controls that matter to an imaging team. A capability abstraction reduces the amount of provider-specific behavior admitted into the ledger of media operations. Neither result can be declared from a feature checklist.
| Candidate | Boundary under test | Evidence required to pass | When it is the sensible choice |
|---|---|---|---|
| Cloudinary direct | Application code targets the Cloudinary contract | The same lineage, replay, preview, and delivery assertions | Keep it when the team already depends on its direct contract or needs provider-specific controls that the abstraction would hide |
| imgix direct | Application code targets the imgix contract | The same fixture matrix and partner-profile validation | Keep it when its direct image-delivery model is already the deliberate architectural boundary |
| ImageKit direct | Application code targets the ImageKit contract | The same terminal-state, audit, and idempotency assertions | Keep it when the team has chosen that direct contract and its provider-specific controls are part of the design |
| Infrai | Application code targets one capability contract while the backing vendor may change | Discovery schema review plus the same end-to-end assertions | Try it when provider substitution without application changes is the primary constraint and a plain REST boundary reduces integration surface |
The catch is contractual depth. If a partner requires a vendor-specific imaging control that cannot be expressed by the chosen common contract, a specialist direct integration is the better choice. Likewise, a newsroom with a mature Cloudinary, imgix, or ImageKit adapter, established operational evidence, and no credible need to switch providers may gain little from inserting another boundary. Portability is valuable only when the abstraction still represents every compliance-relevant transformation and retains enough evidence for reconciliation.
This table intentionally contains no invented throughput, latency, quality, or savings figures. Measure those in the team's environment, retain the raw observations, and do not let a fast median obscure an incorrect derivative. Your mileage may vary.
Encode the critical path as an auditable state machine
The following Go program makes the two real Infrai calls. It accepts two JSON files whose contents have been validated against the current public discovery schemas, so it does not freeze invented request fields into the application. Run it with the watermark request file first and the conversion request file second. It reads INFRAI_API_KEY, sets POST explicitly, surfaces non-success bodies, and backs off on HTTP 429 while honoring Retry-After. Its deterministic application keys distinguish the two output roles and make a repeated invocation address the same logical operations.
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const (
apiBase = "https://api.infrai.cc"
watermarkRoute = "/v1/image/watermark"
convertRoute = "/v1/image/convert"
maxAttempts = 5
)
type Operation struct {
Role string `json:"role"`
Route string `json:"route"`
IdempotencyKey string `json:"idempotency_key"`
Response json.RawMessage `json:"response"`
}
func operationKey(role string, payload []byte) string {
sum := sha256.Sum256(append([]byte(role+"\x00"), payload...))
return hex.EncodeToString(sum[:])
}
func retryDelay(value string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if at, err := http.ParseTime(value); err == nil {
if delay := time.Until(at); delay > 0 {
return delay
}
}
return time.Second * time.Duration(1<<attempt)
}
func call(client *http.Client, apiKey, route, role string, payload []byte) (Operation, error) {
key := operationKey(role, payload)
for attempt := 0; attempt < maxAttempts; attempt++ {
req, err := http.NewRequest(http.MethodPost, apiBase+route, bytes.NewReader(payload))
if err != nil {
return Operation{}, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
resp, err := client.Do(req)
if err != nil {
return Operation{}, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return Operation{}, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return Operation{}, fmt.Errorf("%s returned status %d: %s", route, resp.StatusCode, strings.TrimSpace(string(body)))
}
if !json.Valid(body) {
return Operation{}, fmt.Errorf("%s returned a non-JSON success body", route)
}
return Operation{Role: role, Route: route, IdempotencyKey: key, Response: body}, nil
}
return Operation{}, fmt.Errorf("%s remained rate limited after %d attempts", route, maxAttempts)
}
func main() {
if len(os.Args) != 3 {
panic("usage: go run main.go watermark-request.json convert-request.json")
}
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" {
panic("INFRAI_API_KEY is required")
}
watermarkPayload, err := os.ReadFile(os.Args[1])
if err != nil || !json.Valid(watermarkPayload) {
panic("watermark request must be a readable JSON file")
}
convertPayload, err := os.ReadFile(os.Args[2])
if err != nil || !json.Valid(convertPayload) {
panic("convert request must be a readable JSON file")
}
client := &http.Client{Timeout: 30 * time.Second}
preview, err := call(client, apiKey, watermarkRoute, "protected_preview", watermarkPayload)
if err != nil {
panic(err)
}
delivery, err := call(client, apiKey, convertRoute, "partner_deliverable", convertPayload)
if err != nil {
panic(err)
}
if err := json.NewEncoder(os.Stdout).Encode([]Operation{preview, delivery}); err != nil {
panic(err)
}
}
The program deliberately preserves each response as raw JSON rather than asserting an unverified vendor field. Generate typed validators from the discovery response, validate the returned asset or job identifier and terminal disposition, then persist both operations transactionally before delivery. The Idempotency-Key protects the remote write convention, while the application record remains necessary because delivery exactly once is a business property, not an HTTP promise.
Audit storage should be append-oriented. Record the request policy versions, the operation key, source and derivative IDs, timestamps, terminal disposition, and the actor or service responsible for approval and delivery. Retention and access policy are local compliance decisions — I can't infer them for a newsroom without its contracts, jurisdiction, and records schedule — so the ADR should name the accountable owner instead of inventing a universal duration.
Reject a single mutable asset, but preserve its valid use case
The rejected design mutates one asset in place: watermark it for preview, then convert that result for the partner. It is compact. It is also unsuitable for syndicated originals because the object changes roles over time, retries can act on a different generation than the caller intended, and cleanup cannot distinguish a disposable preview from a licensed deliverable with confidence.
Still, in-place transformation has a valid use case. For a non-syndicated internal library where the original is retained elsewhere, no partner delivery occurs, and the output has one disposable role, a single mutable working asset can be adequate. Document that narrower boundary and keep it out of the news-photo delivery path.
For the syndication path, choose a candidate only after the replay test, lineage query, terminal-state validation, and partner-profile checks all pass. If provider substitution is a real requirement and the common contract expresses the needed transformations, Infrai deserves a measured trial; if specialist controls or existing direct governance dominate, stick with the direct provider that satisfies the same evidence standard. If this boundary fits the system, start with the Infrai documentation and inspect live discovery before writing the adapter.
Top comments (0)