SaveTo.it

saveto.it documentation

saveto.it watches the vendor documents your product catalog links to — cutsheets, spec sheets, manuals, installation guides — and keeps a copy of every version. When a vendor changes a document, the old version stays readable; when a vendor's link dies, your link keeps working because it points at the stored copy.

How it fits together:

  1. Your server registers references: each link in your catalog, with the vendor URL it points to.
  2. saveto.it answers with a status row per reference, including a permanent public link (https://offto.it/d/{id}) to show and save in place of the vendor's URL.
  3. saveto.it checks each document about once a week and stores a new version when it changes.
  4. Your server learns what changed by pulling the changes feed, optionally nudged by a webhook.

Each distinct URL is archived once. If several of your references — or several customers — link the same document, they share one public id and one set of copies.

Access and tokens

Accounts are set up by ScaledApps staff; there is no self-service sign-up. To start, write to contact@scaledapps.com.

You get an API token like saveto_… (48 random characters after the prefix). It is shown once when it is issued; saveto.it keeps only a hash of it and cannot show it again. Keep it on your server. A lost or leaked token is revoked and a new one issued.

Send it on every API call:

Authorization: Bearer saveto_…

A token carries one or both scopes:

ScopeAllows
sources:writeRegistering and removing references: POST /api/sync/references, POST /api/sync/finish, PUT /api/groups/{group}/references, PUT /api/products/{uuid}/references.
sources:readReading status: GET /api/sync/changes, GET /api/sources/{public_id}.

The API base is https://saveto.it/api. Requests and answers are JSON; send Accept: application/json. Times are ISO 8601 with an offset.

Registering references

A reference is one link in your catalog. It is identified by your app name, a ref_type and your own ref_key; sending the same three again updates it. Every call that registers references answers with one status row per reference, in the order sent.

Reference fields

FieldMeaning
appRequired, on the request (not per reference). Your application's name, which is set on your token when it is issued (for example homebook). A token works only for its own app: any other value is refused with 403, so no other customer can change or read your references.
ref_typeRequired. document for a document link (archived and versioned), or page for a web page (only watched for breaking; no copy is kept). product_link and product_page are the same two under their older names.
ref_keyRequired. Your id for this link, up to 64 characters. Case-insensitive: it is stored and returned in lowercase.
urlRequired. The vendor's URL: http:// or https://, up to 2,000 characters. Changing it moves the reference to the new URL's document.
product_uuidOptional UUID of the product the link belongs to. Required, and must match, for a product push.
check_dailyOptional. true to check this document every day instead of every week (Business plan, up to 10% of its documents); false to go back to weekly. Left out, it stays as it was.
organization_uuidOptional UUID of an organization of yours that should get the email notices for this reference. Without it, notices go to the address set on your account page.
labelOptional. A human name for the document, up to 255 characters (stored up to 250). Shown as the title of the public page.
link_typeOptional. Your own category for the link, such as Datasheet, up to 32 characters.

POST /api/sync/references

Registers or updates a batch of up to 500 references. Scope sources:write. Add a run_id (a UUID) when the batch is part of a full sync; leave it out for a one-off upsert.

curl -X POST https://saveto.it/api/sync/references \
  -H "Authorization: Bearer saveto_…" -H "Accept: application/json" -H "Content-Type: application/json" \
  -d '{
    "app": "acme-catalog",
    "references": [
      {
        "ref_type": "product_link",
        "ref_key": "link-1842",
        "url": "https://vendor.example/docs/xr-200-cutsheet.pdf",
        "product_uuid": "0198f3a2-7c41-71ee-b8d3-1a2b3c4d5e6f",
        "organization_uuid": "3d8fb814-88de-43ed-a174-f97528c01cab",
        "label": "XR-200 cutsheet",
        "link_type": "Datasheet"
      }
    ]
  }'
{
  "references": [
    {
      "ref_type": "product_link",
      "ref_key": "link-1842",
      "public_id": "kbXbepWC",
      "public_url": "https://offto.it/d/kbXbepWC",
      "kind": "document",
      "status": "pending",
      "has_copy": false,
      "current_version": null,
      "saved_at": null,
      "file_url": null,
      "thumbnail_url": null,
      "moved_to_url": null,
      "last_checked_at": null,
      "changed_at": "2026-10-31T14:02:11+00:00"
    }
  ]
}

A newly registered URL is pending until its first successful check, which is queued straight away. public_url is available at once, so you can save it immediately.

A full sync: POST /api/sync/finish

To send your whole catalog and have saveto.it forget links you no longer have:

  1. Pick a new UUID as the run_id.
  2. Send every reference in batches of up to 500 to POST /api/sync/references, each with that run_id.
  3. Call POST /api/sync/finish. Every active reference of your app that the run did not mention is deactivated.
POST /api/sync/finish
{"app": "acme-catalog", "run_id": "6f1c2b0e-0d5a-4c1e-9a43-2f8e5d7b9c10"}

{"deactivated": 3}

Scope sources:write. Only call finish after every batch has succeeded: a reference the run missed is deactivated.

PUT /api/groups/{group}/references

One group's complete list — a group is any set you name (a product, a project, a customer; letters, digits and ._:@-, up to 64). The references are registered as above and put in the group; the group's other references (in your app) are deactivated. An empty list removes the whole group. Scope sources:write.

PUT /api/groups/project-7/references
{"app": "acme-catalog", "references": [{"ref_type": "document", "ref_key": "spec", "url": "https://vendor.example/spec.pdf"}]}

PUT /api/products/{product_uuid}/references

One product's complete list of links, sent after the product is edited. The references are registered as above; that product's other references (in your app) are deactivated. Every reference must carry a product_uuid equal to the one in the path, or the call is refused with 422. An empty references list removes all of the product's links. Scope sources:write.

PUT /api/products/0198f3a2-7c41-71ee-b8d3-1a2b3c4d5e6f/references
{"app": "acme-catalog", "references": [ … ]}

{"references": [ … one status row per reference … ]}

What deactivating does

A deactivated reference no longer appears in your changes feed. When no active reference from anyone points at a document any more, saveto.it stops checking it. Its stored copies and their links stay readable. Registering the URL again starts the checks again.

The status row

Everything you need to store about a reference. The same shape is returned by registration, the changes feed and the single-document lookup.

FieldMeaning
ref_type, ref_keyYour reference, as registered (ref_key in lowercase).
public_idThe document's 8-character id.
public_urlThe permanent public link to the latest copy, on offto.it. Show and save this one.
kinddocument (archived) or page (a shop page, watched for breaking only).
statusThe state of the vendor's link: see status values.
has_copytrue when a copy is stored. Always false for a page.
current_versionThe latest stored version's number, or null.
saved_atWhen the latest version was saved, or null.
file_urlThe latest version's file on saveto.it, versioned (https://saveto.it/d/{id}.{n}/file), for software that needs the bytes. It never changes; a new version gets a new URL in the feed. null without a copy.
thumbnail_urlA picture of a PDF copy's first page, 400 pixels wide, made by thumbto.it. Usable directly in an <img>; the first view may show a placeholder while it is made. null for anything that is not a PDF, and when no thumbnail is available.
moved_to_urlWhere the vendor now permanently redirects the link, when it does; otherwise null. A suggestion for your catalog: saveto.it never changes the URL it watches.
last_checked_atWhen saveto.it last checked the vendor's link, or null.
changed_atWhen anything in this row last changed (status, kind, version or moved_to_url). Routine checks that find nothing new do not change it.

Changes feed

GET /api/sync/changes?app={app}&cursor={cursor}&limit={n} — scope sources:read.

Your active references whose status row changed since the cursor, oldest change first. This is the way to stay up to date: it works without any inbound endpoint on your side, and webhooks only tell you when to call it.

ParameterMeaning
appRequired. Your app name.
cursorThe cursor from your previous call. Leave it out to get every active reference.
limitRows per page, 1–500. Default 500.
curl "https://saveto.it/api/sync/changes?app=acme-catalog&cursor=MjAyNi0x…" \
  -H "Authorization: Bearer saveto_…" -H "Accept: application/json"
{
  "references": [ … status rows … ],
  "cursor": "MjAyNi0xMC0zMSAxNDowMjoxMS4xMjN8MDE5YTJm…",
  "has_more": false
}
  • Store the returned cursor after you have processed the rows, and send it next time. It is opaque: don't parse or build one.
  • While has_more is true, call again straight away with the new cursor.
  • With nothing new, references is empty and the cursor you sent comes back unchanged.
  • Paging never skips a row, even when many documents change at the same instant.
  • A cursor that can't be read is refused with 422 and {"message": "Invalid cursor."}.

Looking up one document

GET /api/sources/{public_id} — scope sources:read.

The status row for one document by its public id, plus url, the vendor URL being watched. Here ref_key is the public id, and ref_type is product_link for a document or product_page for a page. Only documents your app references can be looked up: any other id, like an unknown one, is a 404.

{
  "ref_type": "product_link",
  "ref_key": "kbXbepWC",
  "public_id": "kbXbepWC",
  "public_url": "https://offto.it/d/kbXbepWC",
  "kind": "document",
  "status": "ok",
  "has_copy": true,
  "current_version": 2,
  "saved_at": "2026-10-24T03:17:42+00:00",
  "file_url": "https://saveto.it/d/kbXbepWC.2/file",
  "thumbnail_url": "https://thumbto.it/i/…",
  "moved_to_url": null,
  "last_checked_at": "2026-10-31T03:12:05+00:00",
  "changed_at": "2026-10-24T03:17:42+00:00",
  "url": "https://vendor.example/docs/xr-200-cutsheet.pdf"
}

Webhooks

Optional. ScaledApps staff can attach an HTTPS webhook URL to your token; ask at contact@scaledapps.com. You receive its signing secret once, when it is set.

When one of your references' status rows changes, saveto.it sends a POST that says only "something changed — pull the feed":

POST https://your-server.example/saveto-hook
Content-Type: application/json
User-Agent: saveto.it webhook
X-SaveTo-Timestamp: 1793459531
X-SaveTo-Signature: sha256=5d41402abc4b2a76b9719d911017c592…

{"type":"changes","app":"acme-catalog","sent_at":"2026-10-31T14:03:11+00:00"}
  • It carries no data: on receipt, call the changes feed with your stored cursor.
  • Pings are batched: at most one per minute per token, sent about a minute after the first change.
  • Pings go only to tokens whose app references the changed document.
  • The webhook URL must be https on a public internet address. It is called exactly as set: a redirect is not followed and counts as a failed ping.
  • Answer with any 2xx within 10 seconds. A ping is never retried: keep pulling the feed on a schedule as well, so a missed ping only costs a delay.

Verifying a ping

The signature is sha256= followed by the hex HMAC-SHA256 of {timestamp}.{body} — the X-SaveTo-Timestamp value, a dot, and the raw request body — keyed with your webhook secret. Reject a timestamp more than a few minutes old.

$body = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_SAVETO_TIMESTAMP'] ?? '';
$expected = 'sha256='.hash_hmac('sha256', $ts.'.'.$body, getenv('SAVETO_WEBHOOK_SECRET'));

if (! hash_equals($expected, $_SERVER['HTTP_X_SAVETO_SIGNATURE'] ?? '') || abs(time() - (int) $ts) > 300) {
    http_response_code(401);
    exit;
}

Checks and versions

  • Schedule. A new URL is checked as soon as possible, then about once a week. Checks are spread across the day, and saveto.it waits at least 5 seconds between two requests to the same vendor's site.
  • One request. A check is a single GET, conditional when saveto.it already has a copy, from the user agent Mozilla/5.0 (compatible; saveto.it archiver; +https://saveto.it).
  • What counts as a change. A file with different bytes is a new version. For a PDF, saveto.it also compares the text: a PDF re-issued with the same text (a new timestamp, say) is not a new version.
  • Versions are permanent. A change adds version n+1 beside version n. Stored versions are never changed or removed by a check, so https://offto.it/d/{id}.{n} keeps working.
  • Redirects. A permanent redirect (301/308) to a new address sets the status to moved and fills moved_to_url. Housekeeping redirects (http to https, a trailing slash, another host name on the same site, letter case or URL encoding of the path) don't count, and neither does a download link that redirects to the file it serves.
  • Gone, not replaced. A link that now lands on the vendor's home page, or a link naming a file (such as .pdf) that now answers with a web page, counts as gone. Nothing is stored from it.
  • Public addresses only. saveto.it fetches only http/https URLs on the standard ports (80 and 443), without a user name or password, whose host resolves only to public internet addresses. Every redirect along the way is checked the same way. A link to a private or local address is never fetched: it counts as a failed check.
  • Bot protection. saveto.it does not try to get past a vendor's bot protection (no browser impersonation, no challenge solving). When a vendor's site refuses it, a person at ScaledApps keeps the document instead, in one of two ways:
    • by hand: they open the document in their own browser and upload it. An upload with the same file or the same PDF text only confirms the copy is current; anything else is saved as a new version, appears in your changes feed and is emailed like any other change. Web pages are never accepted as an upload. Documents kept by hand are not checked automatically; they are reviewed about every 90 days.
    • from another address: checks fetch the same file from an unprotected public copy. The URL you registered stays the document's identity, and the other address's redirects are never suggested as moved_to_url.
  • Shop pages (product_page) are only checked to see whether they still work. No copy is kept.

Email notices

If your references carry an organization_uuid, ScaledApps can set a notification address for that organization (contact@scaledapps.com). It then gets a daily digest when one of its documents changes, breaks, becomes unreachable or recovers. Each organization has one notification address. The notice says which document and what happened, never what changed in it, and links to the latest and previous versions. A document shared by several organizations is reported to each of them once, under its own labels. A moved link is not emailed, because it still works. An organization is only told about its own references.

Status values

The status describes the vendor's link. Your public link to the stored copy works whatever the status.

StatusMeaning
pendingNot fetched successfully yet: newly registered, or every check so far has failed.
okThe vendor's link works (or, for a document kept by hand, a person fetched it from the vendor).
movedThe link works but permanently redirects elsewhere; see moved_to_url.
brokenThe document is gone: 404/410, Amazon S3's AccessDenied (how S3 answers for a missing file), a domain that no longer resolves, or a link now sent to a home page or a web page — on 2 checks running.
unreachableAny other failure (for example 403, a server error or a timeout) on 3 checks running.
retiredNo longer checked, because nothing references it any more or ScaledApps stopped it. Stored copies stay available.

A link that works again after broken or unreachable returns to ok. One bad week does not change the status.

AI tools (MCP)

AI assistants that speak the Model Context Protocol (Claude, Cursor, VS Code and others) can use saveto.it directly. The address is https://saveto.it/mcp, and it takes the same token as the API, in the same header. The token's app is used for every call, so the tools don't ask for app; each tool appears only if the token has its scope.

# Claude Code
claude mcp add --transport http saveto https://saveto.it/mcp --header "Authorization: Bearer <token>"

# Clients configured with JSON (the exact layout varies by client)
{"saveto": {"url": "https://saveto.it/mcp", "headers": {"Authorization": "Bearer <token>"}}}
ToolScopeAs
get_sourcesources:readGET /api/sources/{id}
list_changessources:readthe changes feed (default 100 per call)
set_references*sources:writea product push
register_referencessources:writePOST /api/sync/references
finish_sync*sources:writefinishing a full sync

* Marked as destructive (links left out stop being tracked), so assistants ask you before using them. The tools return status rows and links, never a document's text. Errors come back as tool errors starting with a short code, for example not_found: …. Signing in without a token (needed for claude.ai connectors) is planned and not available yet.

Errors

API errors are JSON with a message; validation errors add errors, keyed by field.

StatusWhen
401No bearer token (Bearer token required.), or an unknown or revoked one (Invalid API token.).
403The token lacks the scope (Token lacks the required scope: sources:write), or the request names an app other than the token's own (This token can only be used for app "…"), or the token was issued without an app (ask for a new one).
404An unknown public id, or a malformed one in the path.
422Invalid fields, more than 500 references, an invalid cursor, or a product push with a reference for another product.
{
  "message": "The references.0.url field format is invalid.",
  "errors": {
    "references.0.url": ["The references.0.url field format is invalid."]
  }
}

The public pages answer an unknown id or version with a 404 page.

Limits

LimitValue
References per request500; keep each request body under 1 MB
Changes feed page1–500 rows (default 500)
URL length2,000 characters, http or https
ref_key / label / link_type64 / 255 / 32 characters
app32 characters: a-z, 0-9, _, -
Largest document stored100 MB
Redirects followed per check5
Check frequencyAbout once a week per document
Webhook pingsAt most one per minute per token; 10-second timeout; not retried

Takedown and contact

Stored copies are public to anyone with the link and are not indexed by search engines. If you hold the rights to a document and want a copy removed, email contact@scaledapps.com with the subject “saveto.it takedown request” and the link (https://offto.it/d/{id} or https://saveto.it/d/{id}).

For an account, a token, a webhook, a notification address or anything else: contact@scaledapps.com.