Storage destinations

A storage destination is one of your own S3-compatible buckets, saved on your snapnedit account. Once it exists, snapnedit signs the upload server-side and PUTs the finished result directly into your bucket — the bytes never have to come back to you and go out again.

It replaces the per-job presigned URL described in Bring your own storage: instead of minting a signed URL for every job, you save the credentials once and refer to the destination by id — or mark one as your account default and stop mentioning storage altogether.

Manage them in your dashboard under Storage. You can save up to 10 destinations per account.

What snapnedit stores

| Field | Notes | | --- | --- | | name | Your label for the destination. | | provider | aws-s3, cloudflare-r2, backblaze-b2 or s3-compatible. | | bucket | The bucket name. | | keyPrefix | Defaults to snapnedit/. Results land at <prefix>YYYY/MM/DD/<job-id>.<ext>. | | accessKeyId / secretAccessKey | Encrypted at rest. Only the last 4 characters of the key id are ever shown back to you, and the secret is never returned by any endpoint. | | isDefault | At most one per account. | | deleteAfterDelivery | Drop the snapnedit copy once the result is in your bucket. |

Fields per provider

AWS S3 — bucket + region (us-east-1, eu-west-2, …). The endpoint is AWS's own.

Cloudflare R2 — bucket + your Cloudflare account id. The endpoint is derived from it (https://<account-id>.r2.cloudflarestorage.com) and shown read-only in the form; R2 has a single region, so there is nothing to pick. Use an R2 API token (Object Read & Write), not a global Cloudflare key.

Backblaze B2 — bucket + the S3 endpoint for your bucket's cluster, e.g. https://s3.us-west-004.backblazeb2.com (Backblaze shows it on the bucket's detail page). Use an application key scoped to that bucket.

S3-compatible — bucket + endpoint, plus the path-style switch. MinIO, Ceph and most self-hosted servers need path style on; Wasabi and DigitalOcean Spaces usually do not.

Every field is optional to change later except the provider: editing a destination keeps its stored credentials untouched unless you switch Replace credentials on.

Permissions the credentials need

Scope the key to the bucket and the prefix you gave, and grant:

  • s3:PutObject — writing the result. This is what everything else is for.
  • s3:DeleteObject — the Test button in the dashboard writes a tiny probe object under your prefix and deletes it again. Without delete permission the test still reports the write as successful but leaves the probe behind.

Nothing needs s3:GetObject or s3:ListBucket: snapnedit only ever writes.

An AWS policy for the default prefix looks like:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::my-app-images/snapnedit/*"
    }
  ]
}

CORS — only for browser exports

Server-side delivery (a POST /jobs result) is made by snapnedit's own backend, so no CORS configuration is needed for it.

CORS matters only when the browser does the upload: the editor's Export → Save to <destination> menu, and the embedded editor's exportTo({ destinationId }). Both PUT straight from the page to your bucket, and the origin the browser presents is https://snapnedit.com (the editor frame's origin — not your own site's, even when the editor is embedded in it).

[
  {
    "AllowedOrigins": ["https://snapnedit.com"],
    "AllowedMethods": ["PUT"],
    "AllowedHeaders": ["content-type", "cache-control", "content-disposition", "x-amz-*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]

ExposeHeaders: ["ETag"] is optional — without it the upload still succeeds, the result just reports etag: null.

Using one from the API

POST /jobs
{
  "operation": "remove-background",
  "inputAssetId": "ast_...",
  "destination": { "type": "saved", "id": "dst_..." }
}
  • A default destination is applied automatically. If your account has one, a job that names no destination at all is still delivered to it.
  • "destination": null opts out for that one job — the result stays on snapnedit and you download it from download.url as usual. This is the only way to bypass an account default.
  • An explicit { "type": "saved", "id": ... } always wins over the default.
  • Your region, endpoint and credentials are never echoed back in a job response or a webhook payload; the job reports "destination": { "type": "saved", "id": ..., "name": ... }, and the delivery block adds the bucket and key the result was written to once it lands.
  • A cache hit is delivered too. Running the same operation on the same image with the same params does not re-run the model — but with a destination it still writes to your bucket. POST /jobs answers 202 with a new job id that is already succeeded and whose delivery starts pending; poll it to see the delivery outcome. It costs no credits, since nothing ran. (Because the bytes are shared with the cached job, delete-after-delivery does not remove them in that case — deleting them would break the other job's download.)

API and SDK

The routes, all account-scoped. An embed token may call only GET /destinations (and gets a reduced { id, name, provider, bucket, isDefault } per row) and POST /destinations/:id/presign — never create, edit, delete or test.

| Route | | | --- | --- | | GET /destinations | List. Credential-free: only accessKeyIdLast4. | | POST /destinations | Create (max 10 per account). | | PATCH /destinations/:id | Edit. Credentials travel as a pair; provider is not patchable. | | DELETE /destinations/:id | Delete. | | POST /destinations/:id/test | Real write probe → { ok, latencyMs }. | | POST /destinations/:id/presign | A 15-minute signed PUT for one object. |

@snapnedit/sdk wraps all six:

const [production] = await client.listDestinations();
await client.run('remove-background', bytes, { destination: { type: 'saved', id: production.id } });

await client.createDestination({ name, provider: 'aws-s3', bucket, region, accessKeyId, secretAccessKey });
await client.updateDestination(id, { isDefault: true });
await client.testDestination(id);
await client.deleteDestination(id);
await client.presignDestinationUpload(id, { ext: 'png', contentType: 'image/png' });

The MCP server exposes the read-only half to AI agents: every image tool takes a destination_id, and list_storage_destinations / test_storage_destination let an agent pick one and check it. Creating, editing and deleting destinations are deliberately not MCP tools — those take an access key and a secret, and a tool's arguments end up in the agent's transcript.

Using one from the editor and the embed

In the editor, signed-in accounts with at least one destination get a Save to cloud section at the bottom of the Export menu: pick the format, then the destination. The finished bytes go straight from your browser to your bucket, and the resulting bucket/key is shown with a copy button.

In an embedded editor, a host can do the same programmatically without configuring anything on its own page:

const destinations = await editor.listDestinations();
// → [{ id: 'dst_1', name: 'Production', provider: 'aws-s3', bucket: 'my-app-images', isDefault: true }]

const result = await editor.exportTo({ destinationId: destinations[0].id, format: 'png' });
// → { ok: true, status: 200, bytes: 91234, mime: 'image/png', width: 1024, height: 768,
//      key: 'snapnedit/2026/09/13/…​.png', bucket: 'my-app-images' }

See Embed the editor.

Don't keep a copy after delivery

Switching Don't keep a copy on snapnedit after delivery on means exactly that: as soon as the result is written to your bucket, snapnedit deletes its own copy. The job's download.url stops working from that moment — and so does re-downloading it later, and the result cache that would otherwise let an identical job return instantly.

Leave it off unless your bucket is genuinely the only place you want the file. It is a per-destination setting, so you can keep one destination archival and another delivery-only.

Testing a destination

Test in the dashboard performs a real round trip against the bucket: it PUTs a small probe object under your prefix, deletes it, and reports the latency. The chip on the row records the outcome and the time; hover a failed chip for the provider's own error message. The most common failures are a key that isn't scoped to the prefix, a wrong region on AWS, and path style being off on a self-hosted S3 server.