Rawback documentation

Custom upload workflows

Replace the processing Rawback runs after every upload with your own YAML: choose the steps, gate them on your settings, and send each new photo to your own server.

Included with the Max plan

How it works

Every photo you upload goes through a short pipeline: Rawback reads its EXIF, renders a thumbnail, finds its place on the map, and — depending on your settings — tags it with AI, rates it, recognises the people in it and files it into your smart albums. On the Max plan (monthly or yearly) you can replace that pipeline with your own workflow, written in YAML.

Your workflow runs instead of the built-in steps, for every photo from every upload channel — the web, the apps, the desktop client, the CLI and camera FTP — and again whenever you re-analyse a photo. It starts as a copy of the built-in steps, so you can change one thing at a time.

Edit it on the web under Settings → Storage → Upload workflow. The same tab lists your recent runs step by step, with each step's result and outputs. The iOS app shows the workflow read-only.

You run it at your own risk

A workflow can stop photos from finishing processing, spend AI credits on every upload, send your photos' details to a server you name, and skip the privacy switches in your settings unless it checks them itself. Rawback checks that the file is valid and safe for the platform, not that it does what you meant. Read the warnings the editor shows before you save.

When your workflow does not run, uploads fall back on their own:

SituationWhat uploads run
You switched the workflow offRawback's built-in steps.
Your plan no longer includes itRawback's built-in steps. The workflow is kept, paused, and resumes if you return to Max — switch it off to prevent that.
It no longer validates (for example, a secret it names was removed)Only the built-in parse step, so photos still get a thumbnail. Nothing else runs until you fix it — steps you removed do not quietly come back.
It could not be startedOnly the built-in parse step, for that photo.

A first workflow

This one keeps the essentials, rates photos with AI only when your settings allow it, and tells your own server about every new photo:

YAML
name: my_upload_workflow
on: image.uploaded

jobs:
  parse:
    uses: image/parse
    with: { image_id: "${{ inputs.image_id }}" }

  ai_analyze:
    needs: [parse]
    if: "${{ steps.parse.outputs.has_thumbnail && owner.ai_enabled }}"
    uses: image/ai_analyze
    with: { image_id: "${{ inputs.image_id }}" }

  rate:
    needs: [ai_analyze]
    if: "${{ owner.ai_rating_enabled && steps.ai_analyze.outputs.rating >= args.min_stars }}"
    uses: image/rate
    with:
      image_id: "${{ inputs.image_id }}"
      rating: "${{ steps.ai_analyze.outputs.rating }}"

  notify:
    needs: [parse]
    if: "${{ inputs.trigger == 'upload' }}"
    uses: http/webhook
    with:
      image_id: "${{ inputs.image_id }}"
      url: "${{ secrets.WEBHOOK_URL }}"
      secret: "${{ secrets.WEBHOOK_SIGNING_KEY }}"
      data: "studio-intake"

It reads one arg, min_stars (a number saved next to the workflow), and two secrets, WEBHOOK_URL and WEBHOOK_SIGNING_KEY. parse runs first; ai_analyze and notify start as soon as it succeeds; rate waits for ai_analyze.

The file

A workflow has four top-level keys. Any other key is an error, and so is a misspelt one — timeout-minute is refused rather than ignored.

KeyValue
nameLower-case letters, digits and underscores, starting with a letter; at most 64 characters. Shown in your run history.
onAlways image.uploaded.
inputsOptional. A custom workflow always receives the same two inputs, image_id (int) and trigger (string); you may declare them to document them, without defaults. Any other input is an error.
jobsThe steps, keyed by job id. A job id follows the same pattern as name and is how other jobs refer to it.

Each job takes these keys:

KeyMeaning
usesThe action the job runs, such as image/parse. Required. See Actions.
needsJob ids that must settle first. A job with no needs starts as soon as the photo arrives. Cycles are refused.
ifWhen the job runs. Defaults to success(): every job in needs succeeded. See Expressions.
withThe action's inputs. Each value is a literal (string, number or boolean) or one whole ${{ }} expression. Keys the action does not declare are errors, and so are missing required inputs.
runs-onThe queue the job waits in. Each action allows its own set; the first is used when you leave it out. critical is reserved for image/parse, external for webhooks.
timeout-minutesHow long one attempt may take. Defaults to 30, or the action's maximum when that is lower; a value above the maximum is an error.
retriesHow many times a failed attempt is retried, with back-off. Defaults to 25, or the action's maximum when that is lower.
continue-on-errortrue lets the job fail without failing the run. Jobs that need it see it as a success.

Every job that takes image_id must pass exactly "${{ inputs.image_id }}". A workflow only ever works on the photo it runs for, and a re-run cannot point it anywhere else.

Expressions

An expression is wrapped in ${{ }} and must be the whole value. "${{ inputs.image_id }}" is an expression; "photo-${{ inputs.image_id }}" is an error, and a with value without the wrapper is a literal string. In if, the wrapper is optional.

Quote every expression in double quotes, as the examples here do. Unquoted, YAML reads : and # inside it as its own syntax — a ? b : c turns into a mapping.

What an expression can read

NameValue
inputs.image_idThe photo's id.
inputs.triggerWhy the run started: "upload" for a new photo, "rerun" when you re-analyse one.
steps.<job>.result"success", "failure" or "skipped". Only for jobs this job needs, directly or through other jobs' needs.
steps.<job>.outputs.<name>An output of an earlier job; each action lists its outputs below. A job that was skipped reads as its outputs' zero values.
owner.<key>Your settings when the run started (table below).
args.<name>An arg saved next to the workflow. Naming one that does not exist is an error.
Owner keyMeaning
owner_idYour user id.
ai_enabledAI features are on at all (the account-wide AI switch).
face_recognition_enabledFace recognition is on; implies ai_enabled.
ai_rating_enabledAI may set star ratings; implies ai_enabled. Check it before image/rate.
ai_comment_enabledAI may leave a critique comment; implies ai_enabled. Check it before image/comment.
archive_originalsYour own S3 bucket is set to receive a copy of every original.

Every reference is checked when you save: an unknown job, output, owner key or arg is an error with its line and column, not a surprise on the next upload.

Operators

  • Comparison: ==, !=, <, <=, >, >=
  • Logic: &&, ||, !, or and, or, not
  • Arithmetic: +, -, *, /, %
  • Text and lists: in, contains, startsWith, endsWith, and array literals such as ['jpg', 'png']
  • Choosing a value: cond ? a : b and a ?? b
  • Literals: strings in single quotes, numbers, true, false, nil

Function calls other than the three below, matches, ranges (..), let, map literals, slicing, $env, predicates such as filter() or reduce(), and computed member access like steps[name] are refused when you save. An expression is at most 1 KiB.

Status functions

success(), failure() and always() take no arguments and look at the jobs in needs:

  • success() — every job in needs succeeded. A failure under continue-on-error counts as a success.
  • failure() — at least one job in needs failed.
  • always() — true, even after a failed or skipped need.

As in GitHub Actions, an if that calls none of them also requires success(). So if: "${{ owner.ai_enabled }}" means "every need succeeded, and AI is on". To run after a failure, say so:

YAML
alert:
  needs: [parse]
  if: "${{ failure() }}"
  uses: http/webhook
  with:
    image_id: "${{ inputs.image_id }}"
    url: "${{ secrets.ALERT_URL }}"
    data: "parse failed"

Args and secrets

Args are named values saved next to the workflow — a string, a number or a boolean each — and read as ${{ args.NAME }} in if and with. Use them for the things you tune: a rating threshold, a label sent with a webhook, a switch for a step. Args are copied into every run, so never put a credential in one.

Secrets are for credentials. They are stored encrypted, can be replaced or removed but never read back, and appear in a workflow only as the whole value of an input that accepts one:

YAML
url: "${{ secrets.WEBHOOK_URL }}"

Today those inputs are url, secret and bearer_token on http/webhook. A secret anywhere else — inside a longer string, in an if, in image/comment — is an error. Secrets are resolved only when a delivery is sent, are never stored with the run, and are kept out of run history and error messages.

Secrets are not tied to a destination

As in GitHub Actions, anyone who can edit your workflow — including someone holding a stolen session — can send its secrets to any public HTTPS address. Protect your account with two-factor sign-in, and rotate a secret at its source if you suspect it was exposed.

Names follow ^[A-Za-z_][A-Za-z0-9_]{0,63}$ for both. A save that names a secret you have not set is refused; a secret removed later makes the steps that use it fail with "secret NAME is not set".

Actions

Every action takes image_id, which must be "${{ inputs.image_id }}". The other inputs and the outputs later jobs can read are listed here; the editor's reference panel shows the same catalog with insertable snippets. Actions are only ever added to this list — never renamed or removed — so a workflow that validates today keeps validating.

ActionWhat it doesOther inputsOutputs
image/parseReads the EXIF, computes the blurhash and renders the first thumbnail. Until it succeeds the photo stays in processing, hidden from your library.—status (string), has_thumbnail, has_gps, has_raw_exif, needs_fullsize (bool)
image/fullsizeRenders a full-resolution AVIF of a RAW or HEIC original, which browsers cannot show. Skipped for other formats.——
image/geocodeTurns the photo's GPS position into a city, region and country. Skipped without a position.—city, country (string)
equipment/extractRecognises the camera body and lens from the EXIF and adds them to your equipment. Skipped without EXIF.——
image/face_detectFinds the faces in the thumbnail. Uses your face recognition quota; skipped while face recognition is off.—faces (int)
image/people_recognizeMatches those faces against the people you have named. Uses your face recognition quota; skipped while face recognition is off.force (bool): recognise again even if an earlier run didmatches (int), reused (bool)
image/ai_analyzeAsks the vision model for a title, description, tags and categories, and saves them. Also returns a star rating and a short critique. One AI credit, unless you use your own OpenAI key.—rating (float), comment, provider (string), byok (bool)
image/rateWrites a star rating onto the photo, replacing the current one. Gate it on owner.ai_rating_enabled.rating (float, required): up to 5; 0 or less skips the step—
image/commentLeaves a comment on the photo, labelled as written by AI. Gate it on owner.ai_comment_enabled.content (string, required): empty skips the step; provider (string)—
album/match_imageAdds the photo to every smart album whose rules it matches, using its camera, lens, tags and place.——
image/archive_to_user_s3Copies the original into your own S3 bucket. Needs a bucket with archive originals turned on in your storage settings; skipped otherwise.——
http/webhookSends the photo's details to your endpoint as a signed POST. See Receiving webhooks.url, secret, bearer_token, data (below)status (int): the HTTP status your endpoint returned

http/webhook inputs:

  • url (required) — where to send it: a literal, an expression or ${{ secrets.NAME }}. Keep it in a secret when the URL itself is a credential (Slack, Discord and Zapier hooks are). A literal URL is checked when you save; one from a secret or an expression is checked on every attempt.
  • secret — a secret only. The key for the X-Rawback-Signature header; without it, deliveries are unsigned.
  • bearer_token — a secret only. Sent as Authorization: Bearer <token>.
  • data — optional text, up to 4 KiB (4,096 bytes, not characters), sent as the payload's data: a literal, an arg or an earlier step's output. Handy for telling several webhooks apart at one endpoint.

Webhooks are switched on separately from the rest of the feature. Until they are, the editor leaves http/webhook out of its reference, warns about a workflow that uses it, and its deliveries are skipped.

Queues, timeouts and retries per action

Actionruns-onMax timeout-minutesMax retriesJobs per workflow
image/parsecritical (default), default, low10251
image/fullsizedefault, low15251
image/archive_to_user_s3low3051
http/webhookexternal153
album/match_image, image/comment, image/ratedefault, low30253
image/geocode, equipment/extract, image/face_detect, image/people_recognize, image/ai_analyzedefault, low30251

critical exists so a new photo's thumbnail never waits behind other people's extras. external is a separate queue for deliveries to your endpoints: a slow endpoint delays other deliveries, never photo processing.

Limits

WhatLimit
Workflow file64 KiB
name64 characters
One expression1 KiB
Args50 names, 1 KiB per value, 8 KiB together
Secrets20 names, 4 KiB per value
The workflow once compiled32 KiB (it is stored with every run)
One step's resolved inputs8 KiB per string, 32 KiB together — larger fails the step
Webhook deliveries60 a minute and 5,000 a day per account, retries included

A delivery over the budget is skipped, not retried, and the step says why in your run history. Literal values must be finite numbers or text without control characters other than tabs and newlines.

Receiving webhooks

Each http/webhook job sends one POST with a JSON body:

JSON
{
  "event": "image.uploaded",
  "delivery_id": "18342:notify",
  "attempt": 1,
  "sent_at": "2026-10-04T09:30:12Z",
  "image": {
    "id": 52817,
    "original_filename": "DSC_0042.NEF",
    "mime_type": "image/x-nikon-nef",
    "size_bytes": 28311552,
    "status": "completed",
    "width": 6048,
    "height": 4024,
    "captured_at": "2026-10-03T17:42:08Z",
    "create_time": "2026-10-04T09:30:01Z",
    "latitude": 38.7139,
    "longitude": -9.1394,
    "city": "Lisbon",
    "rate": 4,
    "is_hidden": false
  },
  "data": "studio-intake"
}
  • event is image.uploaded for a new photo and image.reprocessed when you re-analyse one.
  • delivery_id is <run>:<job>. It is the same on every retry of a delivery — store it and ignore a delivery you have already handled.
  • attempt counts from 1. sent_at, captured_at and create_time are RFC 3339 times in UTC.
  • image carries the photo's details as Rawback knows them when the delivery is sent. width, height, captured_at, create_time, latitude, longitude, city and rate are null when not known — a photo without GPS has no city, and one sent before image/parse finishes has no dimensions yet. It never carries the file itself, file URLs, storage keys or billing identifiers. It does carry the file name, the position and the hidden flag: they reach whoever controls the URL.
  • data is the job's data input, or an empty string.

Fields may be added to the payload over time, never renamed or removed, so ignore the ones you do not know.

Headers:

HeaderValue
Content-Typeapplication/json
User-AgentRawback-Webhook/1
X-Rawback-EventThe event.
X-Rawback-DeliveryThe delivery_id.
X-Rawback-AttemptThe attempt.
X-Rawback-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256> — only when the job sets secret. See below.
AuthorizationBearer <token> — only when the job sets bearer_token.

Where Rawback will deliver

  • HTTPS only, on port 443 or 8443, with a certificate valid for the host name. No user name or password in the URL.
  • Public addresses only. A host that resolves to a private, loopback or link-local address is refused, and so is a single-label host such as nas. The check is repeated on every attempt.
  • Redirects are not followed. A 3xx response fails the delivery permanently — update the URL instead.

How your response is read

ResponseOutcome
Any 2xxDelivered. The body is ignored.
408, 425, 429, any 5xx, a timeout or a dropped connectionRetried with back-off, up to the job's retries (at most 5).
3xxPermanent failure: "endpoint redirected — update the URL".
Any other 4xxPermanent failure, not retried.
A refused address, an unknown host, an invalid certificate or a server that does not speak HTTPSPermanent failure, not retried.

Each attempt has 5 seconds. Answer with a 2xx as soon as the signature checks out and do the real work afterwards. Deliveries are at least once and not ordered: a retry can arrive after a later photo's delivery, and a delivery your server handled but answered too late will come again with the same delivery_id.

Deliveries stop as soon as you switch the workflow off or delete it, or your plan stops including it; anything still queued is skipped. Failure messages in run history come from a fixed list — "timed out", "endpoint returned 503", "could not resolve host", "TLS certificate not valid", "destination not allowed" and a few more — and never include your URL or what your server sent back. A delivery for a photo deleted in the meantime is skipped.

Verifying signatures

With secret set, every delivery carries:

Text
X-Rawback-Signature: t=1791106212,v1=5f0c…e9a1

v1 is the hex-encoded HMAC-SHA256 of t, a full stop and the raw request body, keyed with your secret:

Text
v1 = hex(HMAC_SHA256(secret, t + "." + raw_body))

To verify a delivery:

  1. Read the body as raw bytes, before any JSON parsing — re-serialising changes the bytes.
  2. Recompute the HMAC and compare it with v1 in constant time.
  3. Reject a t more than 5 minutes from your clock, so a captured request cannot be replayed later. Each retry is signed afresh with a new t.
  4. De-duplicate on delivery_id.

Node.js

JavaScript
import { createHmac, timingSafeEqual } from 'node:crypto'

const TOLERANCE_SECONDS = 5 * 60

// rawBody is the request body exactly as received: a Buffer, not parsed JSON.
export function verifyRawbackSignature(rawBody, header, secret) {
  const fields = new Map(
    (header ?? '').split(',').map((part) => {
      const i = part.indexOf('=')
      return [part.slice(0, i).trim(), part.slice(i + 1).trim()]
    }),
  )
  const t = fields.get('t') ?? ''
  const v1 = fields.get('v1') ?? ''
  if (!/^\d+$/.test(t) || !/^[0-9a-f]{64}$/.test(v1)) return false
  if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) return false

  const expected = createHmac('sha256', secret)
    .update(`${t}.`)
    .update(rawBody)
    .digest()
  return timingSafeEqual(Buffer.from(v1, 'hex'), expected)
}

With Express, read the raw body for this route only — express.raw({ type: 'application/json' }) — and parse it yourself after verifying.

Go

Go
package webhook

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"strconv"
	"strings"
	"time"
)

const tolerance = 5 * time.Minute

// Verify checks an X-Rawback-Signature header against the raw request body.
func Verify(body []byte, header, secret string, now time.Time) bool {
	var ts, sig string
	for _, part := range strings.Split(header, ",") {
		k, v, _ := strings.Cut(strings.TrimSpace(part), "=")
		switch k {
		case "t":
			ts = v
		case "v1":
			sig = v
		}
	}
	unix, err := strconv.ParseInt(ts, 10, 64)
	if err != nil {
		return false
	}
	if d := now.Sub(time.Unix(unix, 0)); d > tolerance || d < -tolerance {
		return false
	}
	given, err := hex.DecodeString(sig)
	if err != nil {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(ts + "."))
	mac.Write(body)
	return hmac.Equal(given, mac.Sum(nil))
}

Read the body with io.ReadAll(r.Body) (behind an http.MaxBytesReader), verify, then json.Unmarshal the same bytes.

Python

Python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 5 * 60


def verify_rawback_signature(raw_body: bytes, header: str | None, secret: str) -> bool:
    fields = dict(
        part.strip().split("=", 1) for part in (header or "").split(",") if "=" in part
    )
    timestamp, signature = fields.get("t", ""), fields.get("v1", "")
    if not timestamp.isdigit():
        return False
    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(
        secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected.encode(), signature.encode())

In Flask that is request.get_data(); in Django, request.body; in FastAPI, await request.body().

When something goes wrong

Switch the workflow off. Uploads go back to Rawback's built-in steps straight away, and the YAML, args and secrets stay as they were. Then re-analyse the photos that went through the broken version: from a photo's menu for one, or from the photo list's Reanalyze for a date range. Re-analysis runs whichever workflow is active at the time.

The editor warns about workflows that are valid but probably not what you meant:

WarningWhy it matters
No job uses image/parseNew photos stay in processing, with no thumbnail, EXIF or blurhash.
image/parse has needs or an ifWhen it is skipped, that photo stays in processing.
image/rate without owner.ai_rating_enabledTurning AI ratings off in your settings would stop working.
image/comment without owner.ai_comment_enabledTurning AI comments off in your settings would stop working.
Same as the defaultThe workflow is identical to the built-in steps, so it changes nothing.

When Rawback changes its built-in steps, the editor says so and shows the difference, so you can bring the change into your own workflow.