Send files from your own app.

Files go to global cloud storage. Encryption keys stay in the browser and are never sent to SendToAnyone.

Quickstart

Create a key in the dashboard, then open a transfer. Files of 8 MB or less get one PUT URL. Larger files get a multipart upload with 16 MB parts. The JavaScript client at /sdk/js/v1/sendtoanyone.js does that upload, including a resume from the parts already stored.

Not end-to-end encrypted unless you use the SDK or encrypt yourself. client.send encrypts by default and returns the link key only to the caller. Pass { e2ee: false } to leave the files encrypted in transit and at rest. Raw API uploads stay plaintext unless you send ciphertext and set e2ee to true.

curl -X POST https://sendtoanyone.com/api/v1/transfers \
  -H "Authorization: Bearer sta_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: transfer-001" \
  -d '{"title":"Brand assets","files":[{"name":"logo.png","size":24011,"contentType":"image/png"}]}'
import { createClient } from 'https://sendtoanyone.com/sdk/js/v1/sendtoanyone.js';

const sta = createClient({ key: process.env.STA_KEY });
const transfer = await sta.createTransfer({
  title: 'Brand assets',
  files: [{ name: file.name, size: file.size, contentType: file.type }],
});
await sta.uploadFile(transfer, file);

Authentication and scopes

Send the key as Authorization: Bearer sta_live_…. The secret is shown once. Later responses show only the prefix.

A new key includes transfers:read and uploads:write. Creating or deleting a transfer, managing webhooks, and the other privileged scopes are included only when you check them.

Scopes: transfers:read, transfers:write, uploads:write, requests:read, requests:write, webhooks:manage, workspace:read, workspace:write, usage:read, audit:read.

On create you can leave the key without an expiry, or set 30, 90, or 365 days. A key past that time is rejected.

Rate limits

Every key response includes ratelimit-limit and ratelimit-remaining. A blocked call also includes retry-after: 60 and the code RATE_LIMIT. If the limit check itself is down, the API returns 503 RATE_LIMIT_UNAVAILABLE and does not run the request.

Pro is 60 requests a minute, 5 keys, and 3 webhooks. Studio is 120, 10, and 10. Business is 600, 25, and 25. An enterprise agreement can raise these. Free, Creator, and Pass accounts cannot create keys.

Idempotency

POST /api/v1/transfers, share links, and webhook changes require Idempotency-Key: 8 to 200 letters, numbers, or . _ : -. The same key and the same body replay the saved response and set Idempotent-Replayed: true. The same key with a different body returns 409 IDEMPOTENCY_MISMATCH. Saved responses expire after 24 hours.

Routes

GET /api/v1/me — Plan and limits for this key

GET /api/v1/openapi.json — This document

GET /api/v1/keys — List key prefixes

POST /api/v1/keys — Create a key, with an optional 30, 90, or 365 day expiry, and show the secret once

DELETE /api/v1/keys/{id} — Revoke a key

GET /api/v1/transfers — List transfers for this key's owner

POST /api/v1/transfers — Open a transfer

GET /api/v1/transfers/{id} — Read one transfer owned by this key

PATCH /api/v1/transfers/{id} — Update title, expiry, or download cap

DELETE /api/v1/transfers/{id} — Delete a transfer and release its quota

GET /api/v1/transfers/{id}/links — List share links

POST /api/v1/transfers/{id}/links — Create a share link

DELETE /api/v1/links/{id} — Revoke a share link

GET /api/v1/transfers/{id}/downloads — Download receipts for a transfer

POST /api/v1/uploads/parts — Sign multipart part URLs

POST /api/v1/uploads/complete — Finish an upload

POST /api/v1/uploads/abort — Abort an upload and release the reservation

GET /api/v1/uploads/status — Upload status, including part numbers and etags already stored

GET /api/v1/webhooks — List webhook endpoints

POST /api/v1/webhooks — Create a webhook and show the signing secret once

PATCH /api/v1/webhooks/{id} — Update a webhook URL or its events

DELETE /api/v1/webhooks/{id} — Disable a webhook

POST /api/v1/webhooks/{id}/test — Queue a test event

POST /api/v1/webhooks/{id}/rotate-secret — Rotate the signing secret

GET /api/v1/webhooks/{id}/deliveries — Delivery log

POST /api/v1/webhooks/{id}/deliveries/{deliveryId}/retry — Resend a delivery with the same event id

Errors

Errors are JSON with error and code. API_KEY_REQUIRED, API_KEY_INVALID, and KEY_EXPIRED mean the key is missing, revoked, or past its expiry. AUTH_REQUIRED means the dashboard call is not signed in. MISSING_SCOPE means the key does not include that permission. API_PLAN means this plan does not include keys or webhooks. KEY_LIMIT and WEBHOOK_LIMIT mean the plan cap is full.

RATE_LIMIT and RATE_LIMIT_UNAVAILABLE are the limit results above. IDEMPOTENCY_REQUIRED and IDEMPOTENCY_MISMATCH are the idempotency results. NOT_FOUND hides another account’s transfer. KEY_NOT_STORED means the body included an encryption key. API_FAILED is an unexpected server error and includes a requestId.

Webhooks

Events: transfer.created, transfer.uploaded, transfer.downloaded, transfer.expired, transfer.deleted, share_link.created, request.upload_received, review.comment_added, review.status_changed. The body is JSON with id, type, createdAt, and data. data has shareId, status, e2ee, fileCount, totalBytes, and expiresAt. A plain request upload can include fileName. An encrypted upload sends the count and size only. A review event adds the version and status, and leaves out the comment text, addresses, file names, and file bytes. It never includes file bytes, passwords, download URLs, or encryption keys.

The header STA-Signature is t=<unix seconds>,v1=<base64 HMAC-SHA256 of timestamp.rawBody>. After a rotation, a second v1 value is the previous secret, which stays valid for 24 hours. Reject a timestamp more than 5 minutes from now. Retries send the same id so you can ignore a duplicate. STA-Webhook-Id is that same id.

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

const timestamp = header.split(',').find((part) => part.startsWith('t=')).slice(2);
const signatures = header.split(',').filter((part) => part.startsWith('v1=')).map((part) => part.slice(3));
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) throw new Error('stale');
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('base64');
const ok = signatures.some((item) => item.length === expected.length && timingSafeEqual(Buffer.from(item), Buffer.from(expected)));
import base64, hashlib, hmac, time
timestamp = None
signatures = []
for part in header.split(','):
    name, value = part.split('=', 1)
    if name == 't':
        timestamp = value
    elif name == 'v1':
        signatures.append(value)
if timestamp is None or abs(time.time() - int(timestamp)) > 300:
    raise SystemExit('stale')
expected = base64.b64encode(hmac.new(secret.encode(), f'{timestamp}.{raw_body}'.encode(), hashlib.sha256).digest()).decode()
ok = any(hmac.compare_digest(expected, item) for item in signatures)
<?php
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
  if (str_starts_with($part, 't=')) $timestamp = substr($part, 2);
  if (str_starts_with($part, 'v1=')) $signatures[] = substr($part, 3);
}
if ($timestamp === null || abs(time() - (int) $timestamp) > 300) { http_response_code(401); exit; }
$expected = base64_encode(hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret, true));
$ok = false;
foreach ($signatures as $signature) {
  if (hash_equals($expected, $signature)) $ok = true;
}

Encryption keys

An encrypted transfer is prepared in the browser before the upload. The key is not in the API body, the webhook, or the stored object. The API stores the ciphertext in global cloud storage and can only say that the transfer is encrypted.