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:
| Situation | What uploads run |
|---|---|
| You switched the workflow off | Rawback's built-in steps. |
| Your plan no longer includes it | Rawback'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 started | Only 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:
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.
| Key | Value |
|---|---|
name | Lower-case letters, digits and underscores, starting with a letter; at most 64 characters. Shown in your run history. |
on | Always image.uploaded. |
inputs | Optional. 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. |
jobs | The 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:
| Key | Meaning |
|---|---|
uses | The action the job runs, such as image/parse. Required. See Actions. |
needs | Job ids that must settle first. A job with no needs starts as soon as the photo arrives. Cycles are refused. |
if | When the job runs. Defaults to success(): every job in needs succeeded. See Expressions. |
with | The 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-on | The 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-minutes | How 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. |
retries | How many times a failed attempt is retried, with back-off. Defaults to 25, or the action's maximum when that is lower. |
continue-on-error | true 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
| Name | Value |
|---|---|
inputs.image_id | The photo's id. |
inputs.trigger | Why 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 key | Meaning |
|---|---|
owner_id | Your user id. |
ai_enabled | AI features are on at all (the account-wide AI switch). |
face_recognition_enabled | Face recognition is on; implies ai_enabled. |
ai_rating_enabled | AI may set star ratings; implies ai_enabled. Check it before image/rate. |
ai_comment_enabled | AI may leave a critique comment; implies ai_enabled. Check it before image/comment. |
archive_originals | Your 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:
&&,||,!, orand,or,not - Arithmetic:
+,-,*,/,% - Text and lists:
in,contains,startsWith,endsWith, and array literals such as['jpg', 'png'] - Choosing a value:
cond ? a : banda ?? 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 inneedssucceeded. A failure undercontinue-on-errorcounts as a success.failure()— at least one job inneedsfailed.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:
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:
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.
| Action | What it does | Other inputs | Outputs |
|---|---|---|---|
image/parse | Reads 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/fullsize | Renders a full-resolution AVIF of a RAW or HEIC original, which browsers cannot show. Skipped for other formats. | — | — |
image/geocode | Turns the photo's GPS position into a city, region and country. Skipped without a position. | — | city, country (string) |
equipment/extract | Recognises the camera body and lens from the EXIF and adds them to your equipment. Skipped without EXIF. | — | — |
image/face_detect | Finds the faces in the thumbnail. Uses your face recognition quota; skipped while face recognition is off. | — | faces (int) |
image/people_recognize | Matches 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 did | matches (int), reused (bool) |
image/ai_analyze | Asks 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/rate | Writes 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/comment | Leaves 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_image | Adds the photo to every smart album whose rules it matches, using its camera, lens, tags and place. | — | — |
image/archive_to_user_s3 | Copies the original into your own S3 bucket. Needs a bucket with archive originals turned on in your storage settings; skipped otherwise. | — | — |
http/webhook | Sends 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 theX-Rawback-Signatureheader; without it, deliveries are unsigned.bearer_token— a secret only. Sent asAuthorization: Bearer <token>.data— optional text, up to 4 KiB (4,096 bytes, not characters), sent as the payload'sdata: 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
| Action | runs-on | Max timeout-minutes | Max retries | Jobs per workflow |
|---|---|---|---|---|
image/parse | critical (default), default, low | 10 | 25 | 1 |
image/fullsize | default, low | 15 | 25 | 1 |
image/archive_to_user_s3 | low | 30 | 5 | 1 |
http/webhook | external | 1 | 5 | 3 |
album/match_image, image/comment, image/rate | default, low | 30 | 25 | 3 |
image/geocode, equipment/extract, image/face_detect, image/people_recognize, image/ai_analyze | default, low | 30 | 25 | 1 |
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
| What | Limit |
|---|---|
| Workflow file | 64 KiB |
name | 64 characters |
| One expression | 1 KiB |
| Args | 50 names, 1 KiB per value, 8 KiB together |
| Secrets | 20 names, 4 KiB per value |
| The workflow once compiled | 32 KiB (it is stored with every run) |
| One step's resolved inputs | 8 KiB per string, 32 KiB together — larger fails the step |
| Webhook deliveries | 60 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:
{
"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"
}eventisimage.uploadedfor a new photo andimage.reprocessedwhen you re-analyse one.delivery_idis<run>:<job>. It is the same on every retry of a delivery — store it and ignore a delivery you have already handled.attemptcounts from 1.sent_at,captured_atandcreate_timeare RFC 3339 times in UTC.imagecarries the photo's details as Rawback knows them when the delivery is sent.width,height,captured_at,create_time,latitude,longitude,cityandratearenullwhen not known — a photo without GPS has no city, and one sent beforeimage/parsefinishes 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.datais the job'sdatainput, 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:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Rawback-Webhook/1 |
X-Rawback-Event | The event. |
X-Rawback-Delivery | The delivery_id. |
X-Rawback-Attempt | The attempt. |
X-Rawback-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> — only when the job sets secret. See below. |
Authorization | Bearer <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
3xxresponse fails the delivery permanently — update the URL instead.
How your response is read
| Response | Outcome |
|---|---|
Any 2xx | Delivered. The body is ignored. |
408, 425, 429, any 5xx, a timeout or a dropped connection | Retried with back-off, up to the job's retries (at most 5). |
3xx | Permanent failure: "endpoint redirected — update the URL". |
Any other 4xx | Permanent failure, not retried. |
| A refused address, an unknown host, an invalid certificate or a server that does not speak HTTPS | Permanent 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:
X-Rawback-Signature: t=1791106212,v1=5f0c…e9a1v1 is the hex-encoded HMAC-SHA256 of t, a full stop and the raw request body, keyed with your secret:
v1 = hex(HMAC_SHA256(secret, t + "." + raw_body))To verify a delivery:
- Read the body as raw bytes, before any JSON parsing — re-serialising changes the bytes.
- Recompute the HMAC and compare it with
v1in constant time. - Reject a
tmore than 5 minutes from your clock, so a captured request cannot be replayed later. Each retry is signed afresh with a newt. - De-duplicate on
delivery_id.
Node.js
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
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
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:
| Warning | Why it matters |
|---|---|
No job uses image/parse | New photos stay in processing, with no thumbnail, EXIF or blurhash. |
image/parse has needs or an if | When it is skipped, that photo stays in processing. |
image/rate without owner.ai_rating_enabled | Turning AI ratings off in your settings would stop working. |
image/comment without owner.ai_comment_enabled | Turning AI comments off in your settings would stop working. |
| Same as the default | The 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.