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:
- Your server registers references: each link in your catalog, with the vendor URL it points to.
- 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. - saveto.it checks each document about once a week and stores a new version when it changes.
- 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:
| Scope | Allows |
|---|---|
sources:write | Registering and removing references: POST /api/sync/references, POST /api/sync/finish, PUT /api/groups/{group}/references, PUT /api/products/{uuid}/references. |
sources:read | Reading 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.
Public links
Every document has an 8-character public id in base58 (no 0, O, I or l), such as kbXbepWC.
Ids are case-sensitive. Versions are numbered from 1 and written after a dot: kbXbepWC.3.
The address to show people and save in your catalog is the permanent one on offto.it, which forwards to saveto.it.
It is what public_url holds in every status row:
https://offto.it/d/{id} the latest copy
https://offto.it/d/{id}.{n} version n, for good
The same pages on saveto.it itself, which anyone with the link can open, no token needed:
| Address | Returns |
|---|---|
/d/{id} | A page for the latest copy: the document, its versions, and whether the vendor's link still works (and where it moved, if it did).
When the vendor's link is broken or unreachable, the page says so above the copy. |
/d/{id}.{n} | The same page pinned to version n. If a newer version has been saved since, the page says so and links to the latest. |
/d/{id}/file | The latest stored file itself. |
/d/{id}.{n}/file | Version n's file. It never changes, so it is served with Cache-Control: public, max-age=31536000, immutable. |
/d/{id}/file?download=1 | The file as a download (Content-Disposition: attachment) instead of inline. Works with a version too. |
/d/{id}/p{i}.svg, /d/{id}.{n}/p{i}.svg | One page of an installation guide rebuilt from a vendor web page (see below), numbered from 0. |
How files are served
- PDF, PNG and JPEG are served inline. Any other type is always a download, never displayed.
- Every page and file says
X-Robots-Tag: noindex, nofollow, noarchive: copies are public but search engines are asked not to index them. - The latest-copy page is cacheable for 5 minutes; a version page for a day.
- An unknown id or version is a
404.
Previews of PDF copies
For a PDF, saveto.it shows a picture of the first page, made by thumbto.it from the versioned file, so it never goes stale:
- Link previews. The copy page carries
og:image(1200×630) andtwitter:cardtags, so a link shared in chat or email shows the document's first page. - Phones. Phones rarely show a PDF inside a page, so on small screens the copy page shows the first page as a picture that opens the file.
- Your own pages. The same picture, 400 pixels wide, is in every status row as
thumbnail_url.
Pages captured from vendor web pages
saveto.it never stores a vendor's HTML as a document. When a link leads to a web page, saveto.it follows it once to the PDF that page names, if there is one.
When the page itself is the specification or installation guide, saveto.it keeps a capture of it: its text as plain blocks and its guide pages as SVG images,
never the vendor's markup or scripts. A capture has no file to download: /d/{id}/file for a capture redirects to its page, /d/{id}.
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
| Field | Meaning |
|---|---|
app | Required, 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_type | Required. 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_key | Required. Your id for this link, up to 64 characters. Case-insensitive: it is stored and returned in lowercase. |
url | Required. The vendor's URL: http:// or https://, up to 2,000 characters. Changing it moves the reference to the new URL's document. |
product_uuid | Optional UUID of the product the link belongs to. Required, and must match, for a product push. |
check_daily | Optional. 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_uuid | Optional 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. |
label | Optional. A human name for the document, up to 255 characters (stored up to 250). Shown as the title of the public page. |
link_type | Optional. 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:
- Pick a new UUID as the
run_id. - Send every reference in batches of up to 500 to
POST /api/sync/references, each with thatrun_id. - Call
POST /api/sync/finish. Every active reference of yourappthat 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.
| Field | Meaning |
|---|---|
ref_type, ref_key | Your reference, as registered (ref_key in lowercase). |
public_id | The document's 8-character id. |
public_url | The permanent public link to the latest copy, on offto.it. Show and save this one. |
kind | document (archived) or page (a shop page, watched for breaking only). |
status | The state of the vendor's link: see status values. |
has_copy | true when a copy is stored. Always false for a page. |
current_version | The latest stored version's number, or null. |
saved_at | When the latest version was saved, or null. |
file_url | The 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_url | A 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_url | Where 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_at | When saveto.it last checked the vendor's link, or null. |
changed_at | When 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.
| Parameter | Meaning |
|---|---|
app | Required. Your app name. |
cursor | The cursor from your previous call. Leave it out to get every active reference. |
limit | Rows 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
cursorafter you have processed the rows, and send it next time. It is opaque: don't parse or build one. - While
has_moreistrue, call again straight away with the new cursor. - With nothing new,
referencesis 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
422and{"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
appreferences the changed document. - The webhook URL must be
httpson a public internet address. It is called exactly as set: a redirect is not followed and counts as a failed ping. - Answer with any
2xxwithin 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 agentMozilla/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 tomovedand fillsmoved_to_url. Housekeeping redirects (httptohttps, 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/httpsURLs 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.
| Status | Meaning |
|---|---|
pending | Not fetched successfully yet: newly registered, or every check so far has failed. |
ok | The vendor's link works (or, for a document kept by hand, a person fetched it from the vendor). |
moved | The link works but permanently redirects elsewhere; see moved_to_url. |
broken | The 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. |
unreachable | Any other failure (for example 403, a server error or a timeout) on 3 checks running. |
retired | No 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>"}}}
| Tool | Scope | As |
|---|---|---|
get_source | sources:read | GET /api/sources/{id} |
list_changes | sources:read | the changes feed (default 100 per call) |
set_references* | sources:write | a product push |
register_references | sources:write | POST /api/sync/references |
finish_sync* | sources:write | finishing 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.
| Status | When |
|---|---|
401 | No bearer token (Bearer token required.), or an unknown or revoked one (Invalid API token.). |
403 | The 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). |
404 | An unknown public id, or a malformed one in the path. |
422 | Invalid 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
| Limit | Value |
|---|---|
| References per request | 500; keep each request body under 1 MB |
| Changes feed page | 1–500 rows (default 500) |
| URL length | 2,000 characters, http or https |
ref_key / label / link_type | 64 / 255 / 32 characters |
app | 32 characters: a-z, 0-9, _, - |
| Largest document stored | 100 MB |
| Redirects followed per check | 5 |
| Check frequency | About once a week per document |
| Webhook pings | At 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.