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.

bash
npm i fileaway-sdk

Overview

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:

typescript
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 back

Everything 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.

bash
npm i fileaway-sdk
The whole happy pathtypescript
import { 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):

  1. 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.
  2. A storage integration. Connect your S3 or R2 bucket in the dashboard and copy its integrationId — that's the integrationId you pass to links.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.

POSThttps://app.fileaway.io/api/management/api-keys
cURL — issue a keybash
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

bash
FILEAWAY_API_KEY=fa_live_a1b2c3d4_e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
FILEAWAY_INTEGRATION_ID=65f0a1b2c3d4e5f6a7b8c9d0
FILEAWAY_WEBHOOK_SECRET=whsec_your_random_secret

Configuring 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.

typescript
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"
Base URL: the SDK appends resource paths directly — it never inserts a /v1 segment. The base is https://api.fileaway.io (the host root, no path prefix), which is also the default.

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.

One filetypescript
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 }
Many files (bounded concurrency)typescript
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.

Your servertypescript
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 }
The device (plain PUT — no SDK, no key)typescript
await fetch(ticket.uploadUrl, {
  method: "PUT",
  headers: ticket.headers,         // send verbatim — they're part of the signature
  body: fileBytes,
});
Your server confirmstypescript
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.

typescript
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.

typescript
// 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 3600
Proxy downloadUrl behaviour: it points at Fileaway's proxy and carries a signed token, so you fetch it with no auth header. It's short-lived (re-mint after expiresAt), 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().

fileaway-sdk/expresstypescript
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.

Framework-agnostic coretypescript
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.data

Events

  • 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 its maxUploads ceiling.

You receive only the events listed in the link's webhook.events; an empty/absent list defaults to upload.created.

Payload

json
{
  "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.

text
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.

Critical: if your handler relies on 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

Expresstypescript
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:

typescript
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.

typescript
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) },
  });
}
Checklist: raw-body signature verify · timestamp freshness · ack 2xx fast · idempotent on 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.

typescript
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

CodeHTTPSDK classMeaning
API_KEY_MISSING / INVALID / REVOKED / EXPIRED401AuthenticationErrorBad/missing/revoked/expired key — check FILEAWAY_API_KEY
API_KEY_INSUFFICIENT_SCOPE403InsufficientScopeErrorKey lacks the scope this route requires
API_ACCESS_NOT_SUBSCRIBED403SubscriptionErrorNo pack with apiAccess — upgrade to Business
FEATURE_NOT_AVAILABLE403FeatureNotAvailableErrorPack doesn't enable the feature (e.g. branding)
QUOTA_EXCEEDED / MONTHLY_UPLOAD_QUOTA_EXCEEDED403QuotaExceededErrorA plan quota would be exceeded
VALIDATION_ERROR400ValidationErrorBad input — details has the field tree
NOT_FOUND404NotFoundErrorLink or upload doesn't exist
RATE_LIMITED429RateLimitErrorPer-key cap hit — carries resetAt / retryAfter
LINK_INACTIVE / LINK_EXPIRED / LINK_LIMIT_REACHED410LinkUnavailableErrorLink can't accept uploads
UPLOAD_NOT_COMMITTED / NOT_PENDING / CORRUPT / NOT_LANDED / EXPIRED409/410UploadStateErrorUpload lifecycle conflict
UPLOADER_INFO_REQUIRED400ValidationErrorLink requires uploader name + email
(network / timeout / aborted)ConnectionErrorNo HTTP response
(bad signature / stale event)WebhookSignatureErrorwebhooks.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.

http
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 287
X-RateLimit-Reset: 1709300060

On 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.

POST/links
GET/links
GET/links/{linkId}
PATCH/links/{linkId}
DELETE/links/{linkId}
POST/links/{linkId}/toggle
GET/links/{linkId}/uploads
POST/links/{linkId}/presign
POST/uploads/{uploadId}/complete
GET/uploads/{uploadId}/download
GET/uploads/{uploadId}/content

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.

Presign by handbash
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

Always free

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.