Official SDK
Fileaway SDK for Node & TypeScript
Mint branded upload links, take files straight into your own S3 / R2 bucket, and receive HMAC-signed webhooks — in a few lines. The SDK wraps the two-phase upload, webhook verification, pagination, retries, and typed errors so you don't hand-roll HTTP. Bytes go browser-direct to your bucket; we sign the URL, confirm the write, and notify you.
npm i fileaway-sdkOverview
Fileaway is a server-to-server upload pipeline. Your backend creates a link (scoped to an event, a case, a customer — whatever you like, tagged with your own metadata); your end-user uploads to it; the bytes land in your bucket; and Fileaway POSTs a signed webhook to your server. You read files back through a proxy without ever holding bucket credentials.
The whole lifecycle is three SDK calls:
const link = await fileaway.links.create({ ... }); // 1. mint a link
const receipt = await fileaway.upload({ link: link.id, file }); // 2. send a file
const { downloadUrl } = await fileaway.uploads.getDownloadUrl(receipt.uploadId); // 3. read it backEverything in the SDK maps to a REST endpoint under https://api.fileaway.io. If you're not on Node, the same surface is available as plain HTTP — see Raw HTTP API.
Install & quickstart
Node ≥ 18 (or any runtime with global fetch / crypto — Bun, Deno, Cloudflare Workers, the browser). Ships dual ESM + CJS with full types, zero runtime dependencies.
npm i fileaway-sdkimport { Fileaway } from "fileaway-sdk";
const fileaway = new Fileaway({ apiKey: process.env.FILEAWAY_API_KEY });
// 1. Create a branded link — hand link.uploadUrl to your end-user
const link = await fileaway.links.create({
integrationId: process.env.FILEAWAY_INTEGRATION_ID,
label: "Acme Conf 2026 - photo wall",
allow: "images", // friendly preset -> MIME list
maxFileSize: "25MB", // friendly string -> bytes
uploadMode: "multiple",
webhook: { url: "https://acme.com/hooks/fileaway", secret: process.env.FILEAWAY_WEBHOOK_SECRET },
});
// 2. Upload a file (presign -> PUT-to-bucket -> complete, all internal)
const receipt = await fileaway.upload({
link: link.id,
file: "./IMG_8423.jpg", // path | File | Blob | Uint8Array | stream
uploader: { name: "Jane", email: "jane@acme.com" },
});
// 3. Download it back later — no bucket creds, no second call to wire up
await fileaway.uploads.download(receipt.uploadId, { to: "./out.jpg" });Get access — key & integration
Two one-time, dashboardprerequisites the SDK can't do for you (they predate any API key):
- An active subscription with API access. Programmatic access is gated on a pack with
apiAccess(the Business pack). It's re-checked on every call, so cancelling revokes access on the next request — no key rotation needed. - A storage integration. Connect your S3 or R2 bucket in the dashboard and copy its
integrationId— that's theintegrationIdyou pass tolinks.create.
Issue an API key
Create a key from the dashboard, or via the management endpoint with your user JWT. The plaintext key is returned once in data.plaintextKey — store it immediately; afterwards only the public keyPrefix is retrievable.
curl -X POST https://app.fileaway.io/api/management/api-keys \
-H "Authorization: Bearer <your dashboard JWT>" \
-H "Content-Type: application/json" \
-d '{
"label": "Production server",
"scopes": ["links:read", "links:write", "uploads:read", "uploads:write", "webhooks:write"]
}'Scopes: links:read, links:write, uploads:read, uploads:write, webhooks:write. Omit scopes to grant all five. For a typical backend you want all four links:* / uploads:* scopes. Revoke a key with DELETE /api/management/api-keys/{id}.
Set your environment
FILEAWAY_API_KEY=fa_live_a1b2c3d4_e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
FILEAWAY_INTEGRATION_ID=65f0a1b2c3d4e5f6a7b8c9d0
FILEAWAY_WEBHOOK_SECRET=whsec_your_random_secretConfiguring the client
Construct once and reuse. If you don't pass apiKey, the SDK reads FILEAWAY_API_KEY from the environment. The base URL defaults to https://api.fileaway.io and the live/test environment is inferred from the key prefix.
import { Fileaway } from "fileaway-sdk";
const fileaway = new Fileaway({
apiKey: process.env.FILEAWAY_API_KEY, // optional; falls back to env
// baseUrl: "https://api.fileaway.io", // override only for the legacy host
// timeout: 30_000, // per API request (ms)
// uploadTimeout: 0, // direct-to-bucket PUT (0 = no cap)
// maxRetries: 3, // auto-retry on 429 / 5xx / network
// defaultUploader: { source: "api" }, // merged into every upload()
// onRequest: (e) => log.info(e), // observability hook
});
fileaway.environment; // "live" | "test"/v1 segment. The base is https://api.fileaway.io (the host root, no path prefix), which is also the default.Create a link
Anything in metadata is echoed back in every webhook, so you can reattribute an upload with no database round-trip. link.uploadUrl is the public page to hand to your end-user.
const link = await fileaway.links.create({
integrationId: process.env.FILEAWAY_INTEGRATION_ID, // required
label: "Smith v. Jones - discovery intake", // required
allow: ["documents", "pdf"], // preset(s) -> MIME list, or allowedMimeTypes: [...]
maxFileSize: "100MB", // friendly string, or maxFileSizeBytes: number
maxUploads: 200,
password: "Verbal-Cipher-9", // optional gate on the hosted page
expiresAt: "30d", // relative ("30d"/"12h"), Date, or ISO string
uploadMode: "multiple", // "single" (default) | "multiple"
requireUploaderInfo: true, // force uploader name + email
directory: "cases/2026-CV-04412", // bucket folder uploads land in (defaults to links/<slug>/)
namingRule: { rename: "{date}-{original}" }, // or { validate: /^INV-\d+\.pdf$/ }
branding: { title: "Upload documents", primaryColor: "#0F172A", logoUrl: "https://firm.example/logo.png" },
metadata: { caseNumber: "2026-CV-04412", clientId: "cli_smith" }, // <=20 keys, string values
webhook: {
url: "https://firm.example/api/intake",
secret: process.env.FILEAWAY_WEBHOOK_SECRET,
events: ["upload.created", "upload.failed", "link.limit_reached"],
},
});
console.log(link.id, link.slug, link.uploadUrl);Friendly inputs
allow— presetsimages,documents,pdf,video,audio,archives,any, an array of them, or raw MIME strings. Or passallowedMimeTypesdirectly.maxFileSize— "25MB", "1.5GiB"… or rawmaxFileSizeBytes.expiresAt— relative duration,Date, or ISO string.uploadMode— set"multiple"to presign more than one file per call.requireUploaderInfo— enforced on the hosted page and on API presign (rejected withUPLOADER_INFO_REQUIRED).directory— the bucket folder (object-key prefix) uploads land under. Set it to your own entity reference — e.g.directory: eventId— so each entity's files are grouped together. Sanitized server-side (no leading slashes or..); defaults tolinks/<slug>/when omitted.
Manage links
await fileaway.links.get(linkId); // one link
await fileaway.links.update(linkId, { maxUploads: 500, webhook: null }); // patch; null clears
await fileaway.links.enable(linkId); // idempotent (reads, toggles only if needed)
await fileaway.links.disable(linkId);
await fileaway.links.delete(linkId); // -> void
// Paginated list
const page = await fileaway.links.list({ isActive: true, search: "acme", limit: 50 });
page.data; // Link[]
page.total; // total matching
page.hasNext; // boolean
await page.next();// next Page | null
// Or iterate everything (auto-paginates, respects rate limits)
for await (const link of fileaway.links.listAll({ search: "acme" })) {
// ...
}update accepts the same friendly inputs as create; pass null to clear a nullable field (e.g. webhook: null, expiresAt: null).
Uploading files
Uploads are two-phase and direct-to-bucket: the SDK presigns a short-lived URL, the bytes go straight to your S3 / R2 bucket, then it confirms. Fileaway is never in the data path. upload() does all three steps — and sends the exact signed headers, so the bucket can never reject a Content-Type / Content-Length mismatch. Requires the uploads:write scope.
const receipt = await fileaway.upload({
link: link.id, // id or Link object
file: "./IMG_8423.jpg", // path (Node) | File | Blob | Uint8Array | ArrayBuffer | stream
// filename / contentType inferred; size required only for streams
uploader: { name: "Jane", email: "jane@acme.com", id: "u_1", source: "ios-app" },
});
// receipt: { uploadId, filename, storedFilename, size, mimeType, uploadedAt }const receipts = await fileaway.uploadMany({
link: link.id,
files: [{ file: "./a.pdf" }, { file: "./b.pdf" }],
concurrency: 4,
});Browser / React Native uploads (no key on device)
Never ship your API key to a client. Mint a ticket on your server, let the device PUT the bytes directly, then confirm server-side. This is also where uploader attribution is set (it then appears in the webhook), satisfying requireUploaderInfo.
const [ticket] = await fileaway.createUploadTicket({
link: linkId,
files: [{ filename: "photo.jpg", mimeType: "image/jpeg", size: 482310 }],
uploader: { name: "Jane", email: "jane@acme.com" },
});
// Send ticket to the device: { uploadId, uploadToken, uploadUrl, method, headers, expiresAt }await fetch(ticket.uploadUrl, {
method: "PUT",
headers: ticket.headers, // send verbatim — they're part of the signature
body: fileBytes,
});const receipt = await fileaway.completeUploadTicket(ticket.uploadId, ticket.uploadToken);From a browser, your bucket's CORS must allow PUT from the uploader origin with Content-Type and Content-Length in AllowedHeaders. Pending reservations self-expire ~1 hour after presign and count against maxUploads until then.
Listing uploads
Every file that came through a link, newest first — metadata only (bytes live in your bucket). Returns a paginated Page; iterate with uploadsAll. Requires uploads:read.
const page = await fileaway.links.uploads(linkId, { page: 1, limit: 100 });
for (const up of page.data) {
// up._id, up.originalFilename, up.storedFilename, up.mimeType,
// up.sizeBytes, up.destinationType, up.status, up.createdAt, up.uploader*
}
// Or drain every page:
for await (const up of fileaway.links.uploadsAll(linkId)) {
if (up.status === "committed") { /* ... */ }
}Upload records expose _id (the uploadId you pass to the download calls). To fetch the actual bytes, see Downloading files.
Downloading files
Files live in your bucket, but you never need the bucket credentials or a storage SDK to read them back. Ask Fileaway for a download URL by uploadId (from the webhook's data.uploadId or an upload's _id). Requires uploads:read.
// Save to disk (Node)
await fileaway.uploads.download(uploadId, { to: "./invoice.pdf" });
// Get the bytes
const bytes = await fileaway.uploads.download(uploadId, { as: "buffer" }); // Uint8Array
const stream = await fileaway.uploads.download(uploadId, { as: "stream" }); // ReadableStream
// Or just the URL — to hand to a frontend / <img src> / redirect
const { downloadUrl, expiresAt, mimeType, filename } =
await fileaway.uploads.getDownloadUrl(uploadId, { expiresIn: 600 }); // 60..604800s, default 3600expiresAt), must not be persisted (store the uploadId instead), and has no Range support (whole-object pipe — fine for images / downloads, not video seeking). The storage provider, bucket, and key are never exposed, and access stays gated by Fileaway whether your bucket is public or private.Bandwidth note: unlike uploads (browser-direct to your bucket), download bytes transit Fileaway — the cost of never exposing storage to consumers.
Webhooks
Webhooks are configured per link (set webhook on links.create / update) — there is no integration-wide webhook. When a file lands, Fileaway POSTs a JSON envelope signed with HMAC-SHA256.
Receive & verify (Express)
The adapter captures the raw body, verifies the signature, enforces timestamp tolerance, and dedupes — the four things people get wrong. Mount it before any global express.json().
import { fileawayWebhook } from "fileaway-sdk/express";
app.post("/hooks/fileaway", fileawayWebhook({
secret: process.env.FILEAWAY_WEBHOOK_SECRET,
tolerance: 300, // reject events >5 min skew (replay protection)
dedupe: true, // dedupe on X-Webhook-Id (swap for a Redis store in prod)
onEvent: async (e) => {
// Acked 200 already; runs out-of-band (safe past the 10s deadline).
if (e.type === "upload.created") await enqueue(e.data);
},
}));Next.js App Router: import handleFileawayWebhook from fileaway-sdk/next. Any other runtime: call fileaway.webhooks.verify(...) with the raw body — it throws WebhookSignatureError on a bad signature or stale timestamp.
const event = await fileaway.webhooks.verify({
payload: rawBody, // RAW bytes / Buffer — NOT parsed JSON
signature: req.headers["x-webhook-signature"],
timestamp: req.headers["x-webhook-timestamp"],
secret: process.env.FILEAWAY_WEBHOOK_SECRET,
tolerance: 300,
});
if (await fileaway.webhooks.isDuplicate(event.id)) return; // dedupe across retries
// event.type, event.id, event.linkId, event.linkSlug, event.metadata, event.dataEvents
upload.created— bytes confirmed in your bucket via the complete handshake.upload.failed— a presigned upload was confirmed but the bytes never landed (the commit-time HEAD check missed). Bad MIME / oversize / naming-rule / password failures are rejected synchronously at presign and do not fire a webhook.link.limit_reached— an upload pushed the link to itsmaxUploadsceiling.
You receive only the events listed in the link's webhook.events; an empty/absent list defaults to upload.created.
Payload
{
"id": "a4d2e8f0-...", // X-Webhook-Id -> dedupe key
"event": "upload.created", // SDK exposes this as event.type
"deliveredAt": "2026-04-29T12:00:00.000Z",
"linkId": "65f0c1...",
"linkSlug": "a1b2c3d4...",
"metadata": { "caseNumber": "2026-CV-04412" }, // your link metadata, echoed back
"data": {
"uploadId": "65f0c2...",
"originalFilename": "IMG_8423.jpg",
"storedFilename": "IMG_8423.jpg",
"mimeType": "image/jpeg",
"sizeBytes": 4823100,
"storageKey": "links/a1b2c3d4.../9f7e_IMG_8423.jpg",
"destinationType": "S3",
"uploadedAt": "2026-04-29T12:00:00.000Z",
"uploaderName": "Jane", "uploaderEmail": "jane@acme.com",
"uploaderId": "u_1", "uploaderSource": "ios-app"
}
}The SDK's verify() returns this as a typed union keyed on event.type (note: type, not event); for link.limit_reached the data is { linkId, linkLabel, slug, maxUploads }.
Signature scheme & headers
Stripe / GitHub style. Verify against the raw bytes, never re-serialized JSON.
X-Webhook-Id: a4d2e8f0-... (unique delivery id; dedupe on this)
X-Webhook-Event: upload.created
X-Webhook-Timestamp: 1709300000 (unix seconds)
X-Webhook-Signature: 8d0f1a3c4b... (only present when the link has a secret)
signature = hex( HMAC_SHA256( secret, timestamp + "." + rawBody ) )Retries
Failed deliveries retry up to 3 times with exponential backoff (1s → 4s → 16s) and a 10s per-call timeout. Persistent failures are visible in the dashboard's deliveries view. Always ack 2xx fast and process out-of-band.
Full integration guide
End-to-end recipe for an event-platform-style backend: you create a link, your end-user uploads, Fileaway notifies your server, you record the upload idempotently and serve files back — never touching the bucket directly.
1 — Create a link per context
When a flow that needs uploads begins (a new event, case, or form), call links.create once and store link.id + link.slug against that entity. Put your identifiers in metadata — they come back in every webhook, so you reattribute with no lookup.
metadata.eventId, you must set it at link creation. A link created without it delivers metadata: undefined — read it defensively and never throw.2 — Receive the webhook, idempotently
import express from "express";
import { fileawayWebhook } from "fileaway-sdk/express";
const app = express();
app.post("/hooks/fileaway", fileawayWebhook({
secret: process.env.FILEAWAY_WEBHOOK_SECRET,
tolerance: 300,
dedupe: true,
onEvent: async (e) => {
if (e.type !== "upload.created") return;
const d = e.data;
// Idempotent upsert keyed on uploadId; e.id is delivery-level dedupe.
await db.uploads.upsert({
where: { uploadId: d.uploadId },
update: { lastDeliveryId: e.id },
create: {
uploadId: d.uploadId,
eventId: e.metadata?.eventId, // your correlation id
filename: d.originalFilename,
mimeType: d.mimeType,
sizeBytes: d.sizeBytes,
uploaderEmail: d.uploaderEmail,
uploadedAt: new Date(d.uploadedAt),
lastDeliveryId: e.id,
},
});
},
}));3 — Serve files back
Store only uploadId — never a download URL (it expires). Mint a fresh one on demand:
async function displayUrl(uploadId) {
const { downloadUrl, expiresAt } =
await fileaway.uploads.getDownloadUrl(uploadId, { expiresIn: 600 });
return { downloadUrl, expiresAt }; // hand to <img src> / redirect; fetch with NO auth header
}4 — Reconcile (don't trust webhooks alone)
Webhooks can be missed (endpoint down through all retries, a deploy mid-delivery). Periodically — or when a gallery opens — page through the link's uploads and upsert anything you haven't seen. Because both paths key on uploadId, replaying is harmless.
for await (const up of fileaway.links.uploadsAll(linkId)) {
if (up.status !== "committed") continue;
await db.uploads.upsert({
where: { uploadId: up._id },
update: {},
create: { uploadId: up._id, eventId, filename: up.originalFilename, sizeBytes: up.sizeBytes, uploadedAt: new Date(up.createdAt) },
});
}X-Webhook-Id · tolerate missing metadata · store uploadId not URLs · poll-reconcile as a backstop. The Express / Next adapters cover the first four.Error handling
Every server error code maps to a typed class — catch by meaning, not status. All extend FileawayError with code, status, requestId, and details. RateLimitError adds resetAt / retryAfter / remaining.
import {
QuotaExceededError, RateLimitError, SubscriptionError,
FeatureNotAvailableError, ValidationError,
} from "fileaway-sdk";
try {
await fileaway.upload({ link, file });
} catch (e) {
if (e instanceof SubscriptionError) return showUpgrade("API access needs a Business plan.");
if (e instanceof QuotaExceededError) return showUpgrade(e.message);
if (e instanceof FeatureNotAvailableError) return showUpgrade("That feature needs a higher plan.");
if (e instanceof RateLimitError) return retryAfter(e.resetAt); // SDK already auto-retried
throw e;
}The SDK auto-retries 429, 5xx, and transient network errors with jittered backoff (honoring X-RateLimit-Reset); a thrown RateLimitError means retries were exhausted.
Code → class
| Code | HTTP | SDK class | Meaning |
|---|---|---|---|
| API_KEY_MISSING / INVALID / REVOKED / EXPIRED | 401 | AuthenticationError | Bad/missing/revoked/expired key — check FILEAWAY_API_KEY |
| API_KEY_INSUFFICIENT_SCOPE | 403 | InsufficientScopeError | Key lacks the scope this route requires |
| API_ACCESS_NOT_SUBSCRIBED | 403 | SubscriptionError | No pack with apiAccess — upgrade to Business |
| FEATURE_NOT_AVAILABLE | 403 | FeatureNotAvailableError | Pack doesn't enable the feature (e.g. branding) |
| QUOTA_EXCEEDED / MONTHLY_UPLOAD_QUOTA_EXCEEDED | 403 | QuotaExceededError | A plan quota would be exceeded |
| VALIDATION_ERROR | 400 | ValidationError | Bad input — details has the field tree |
| NOT_FOUND | 404 | NotFoundError | Link or upload doesn't exist |
| RATE_LIMITED | 429 | RateLimitError | Per-key cap hit — carries resetAt / retryAfter |
| LINK_INACTIVE / LINK_EXPIRED / LINK_LIMIT_REACHED | 410 | LinkUnavailableError | Link can't accept uploads |
| UPLOAD_NOT_COMMITTED / NOT_PENDING / CORRUPT / NOT_LANDED / EXPIRED | 409/410 | UploadStateError | Upload lifecycle conflict |
| UPLOADER_INFO_REQUIRED | 400 | ValidationError | Link requires uploader name + email |
| (network / timeout / aborted) | — | ConnectionError | No HTTP response |
| (bad signature / stale event) | — | WebhookSignatureError | webhooks.verify rejected the delivery |
Rate limits
Per-key sliding-window throttle; the cap comes from your pack's apiRateLimitPerMinute, so an upgrade lifts it on the next call. The SDK reads these headers and auto-retries 429 to the window edge, so you rarely handle this yourself.
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 287
X-RateLimit-Reset: 1709300060On a thrown RateLimitError, respect error.resetAt (ms epoch) / error.retryAfter (seconds) before retrying.
Raw HTTP API (no SDK)
Not on Node? Every SDK call is a thin wrapper over REST at https://api.fileaway.io. Authenticate with X-API-Key: fa_live_... (or Authorization: Bearer). Responses use a wrapper: success, the payload under data, plus meta.requestId (and pagination on lists). Errors set success: false with an error.code.
The two-phase upload by hand: POST /links/{id}/presign with one descriptor per file ({ filename, mimeType, size }) returns a signed PUT URL each; PUT the bytes to your bucket with the returned headers verbatim; then POST /uploads/{id}/complete with the uploadToken to confirm and fire the webhook.
curl -X POST https://api.fileaway.io/links/$LINK_ID/presign \
-H "X-API-Key: fa_live_..." \
-H "Content-Type: application/json" \
-d '{ "files": [{ "filename": "report.pdf", "mimeType": "application/pdf", "size": 2048576 }] }'The full schema lives in the OpenAPI spec — import it into Postman or generate a client in any language.
Ready to integrate?
Subscribe to the Business pack, issue a key, and npm i fileaway-sdk.
Support
Stuck on something? We're here to help — for free.
Support is completely free for everyone, on every plan. There's no paid support tier and no charge to talk to us — email our team any time with questions about the SDK, the API, integrations, or your account and we'll get back to you.