flowflex
v0.1.1
Published
Official FlowFlex SDK — fire custom-integration events and attach private files without handling presigned URLs yourself.
Maintainers
Readme
flowflex
Official Node/browser SDK for firing FlowFlex custom-integration events and attaching private files to them — without ever dealing with presigned URLs, Basic-auth headers, or the multi-step upload dance yourself.
npm install flowflexRequires Node 18+ (for the built-in fetch) or any modern browser.
Quick start
import { FlowFlex } from "flowflex";
const ff = new FlowFlex({
apiKey: "cik_xxx", // from your custom integration
apiSecret: "yyy",
integrationCode: "ic_abc123", // the code in your event URL: /v1/events/<code>
baseUrl: "https://api.flowflex.ai",
});
// Plain event, no file
await ff.sendEvent("order.placed", {
payload: { name: "Ada", order_id: "ord_42" },
});In your flow builder, the values land under trigger — e.g. {{trigger.name}},
{{trigger.order_id}}.
Attaching a private file
Wrap any file in ff.file(...) and drop it into the payload. The SDK uploads it
through a presigned URL and replaces it with its opaque assetId before the
event is sent. The file bytes go straight to storage — they never pass through
the FlowFlex app server.
await ff.sendEvent("invoice.created", {
payload: {
assetId: ff.file("./invoice.pdf"), // ← becomes "asset_..." on the wire
customer_id: "cust_123",
},
});Then in your flow's Media Message node:
- Set File source →
Private file (assetId) - Set the Asset ID field →
{{trigger.assetId}}
The key you use in the payload is the key you reference in the flow. If you send
{ invoice: ff.file(...) }, reference it as {{trigger.invoice}}.
Multiple files
A flow can have as many media nodes as you like — put a file() anywhere in the
payload (top-level, nested, or in arrays) and the SDK uploads them all in
parallel and swaps each for its assetId. The shape is entirely up to you;
reference each one by its path in the flow builder.
await ff.sendEvent("order.shipped", {
payload: {
invoice: ff.file("./invoice.pdf"), // {{trigger.invoice}}
label: ff.file("./label.png"), // {{trigger.label}}
gallery: [ff.file("./a.jpg"), ff.file("./b.jpg")], // {{trigger.gallery[0]}}, {{trigger.gallery[1]}}
order: { receipt: ff.file("./receipt.pdf") }, // {{trigger.order.receipt}}
note: "non-file values pass through untouched",
},
});
// → { invoice: "asset_a", label: "asset_b",
// gallery: ["asset_c", "asset_d"],
// order: { receipt: "asset_e" }, note: "..." }result.uploadedAssets maps each payload path to its assetId, e.g.
{ "invoice": "asset_a", "gallery[0]": "asset_c", "order.receipt": "asset_e" }.
Reusing one file across nodes: if you pass the same file() instance in
multiple places, it's uploaded only once and the same assetId is used
everywhere:
const banner = ff.file("./banner.png");
await ff.sendEvent("promo.sent", {
payload: { header: banner, footer: banner }, // one upload, same assetId in both
});Supplying file bytes other ways
file() accepts a path (Node), a Buffer/Uint8Array, or a Blob/File
(browser). When the type can't be inferred, pass filename and mime:
// Buffer / Uint8Array
ff.file(pdfBuffer, { filename: "invoice.pdf", mime: "application/pdf" });
// Browser File input
const f = document.querySelector("input[type=file]").files[0];
ff.file(f); // name + type read from the File
// Override the stored filename
ff.file("./tmp-7f3a.pdf", { filename: "Invoice-2026.pdf" });Allowed types: PDF, DOC(X), XLS(X), PPT(X), TXT, JPEG, PNG, MP4, 3GPP, AAC, AMR, MP3, OGG. Max size: 25 MB.
API
new FlowFlex(options)
| Option | Type | Required | Notes |
| ------------------------- | -------- | -------- | --------------------------------------------------------------------- |
| apiKey | string | yes | Custom-integration key (cik_…). |
| apiSecret | string | yes | Custom-integration secret. |
| integrationCode | string | yes | The <code> in /v1/events/<code>. |
| baseUrl | string | yes | FlowFlex host. Must be https (except localhost). Trailing /api stripped. |
| timeoutMs | number | no | Per-request timeout. Default 30000. |
| maxFileBytes | number | no | Client-side size cap. Default 26214400 (25 MB). |
| fetch | function | no | Custom fetch for runtimes without a global one. |
Server-only. There is no option to run this in a browser — it throws if constructed in one, and browser bundlers resolve it to a stub that throws on import. See Security.
ff.sendEvent(event, { payload?, idempotencyKey? })
Uploads any file() in payload, then POSTs the event. Returns:
{
response: unknown, // raw body from the events endpoint
uploadedAssets: Record<string, string>, // payload path → assetId
}An idempotencyKey is auto-generated (UUID) if you don't pass one — safe to
retry the same call.
ff.file(source, { filename?, mime?, size? })
Returns a lazy FileRef. Bytes are read only when the event is sent.
Lower-level helpers
const { assetId, uploadUrl } = await ff.createUploadUrl({ filename, mime });
const assetId = await ff.uploadFile(ff.file("./x.pdf")); // upload, get assetIdErrors
All errors extend FlowFlexError ({ message, status?, code?, details? }):
FlowFlexConfigError— bad/missing constructor options.FlowFlexUploadError— a file couldn't be read or storage rejected it.FlowFlexError— API or network failure (codeis the backend error code, e.g.MIME_NOT_ALLOWED,ASSET_FILE_MISSING).
import { FlowFlexError } from "flowflex";
try {
await ff.sendEvent("invoice.created", { payload: { assetId: ff.file("./big.pdf") } });
} catch (err) {
if (err instanceof FlowFlexError) {
console.error(err.code, err.status, err.message);
}
}File lifetime & storage cleanup
Important — read before using file attachments in production.
When you call ff.file(...), the file is uploaded to private Supabase storage
that only the FlowFlex backend can read. It is not a public URL and the caller
cannot access it after upload.
How long does the file stay?
| Phase | Duration |
| ----- | -------- |
| Presigned upload URL valid | 2 hours from createUploadUrl |
| File kept in storage | 48 hours from upload |
| After 48 hours | File deleted from storage + record removed |
After the flow delivers the WhatsApp message the file is no longer needed. The backend runs an automatic cleanup job every hour that:
- Finds assets whose 48-hour window has expired
- Deletes the file from Supabase storage first
- Then removes the database record
This means storage never accumulates — every file is cleaned up within ~1 hour of its expiry.
What this means for you
- Do not store
assetIdlong-term expecting to reuse it. It expires in 48h. - Each event send should get a fresh
assetIdby callingff.sendEventwith a newff.file(...). The SDK handles the upload automatically. - If you fire the event more than 48h after uploading, the file will be gone and
the flow will fail with
ASSET_FILE_MISSING. Keep your event send close to the upload. - Re-sending the same message to a different recipient after 48h requires a
fresh upload — call
sendEventagain with the file, don't reuse the old assetId.
Typical correct pattern
// ✅ Upload + fire in the same operation — always fresh
await ff.sendEvent("invoice.created", {
payload: { assetId: ff.file("./invoice.pdf") },
});
// ❌ Don't store assetId and reuse it hours later
const { uploadedAssets } = await ff.sendEvent(...);
// ... 50 hours later ...
// uploadedAssets["assetId"] is now expired and deletedSecurity
This SDK is server-only. Your apiKey/apiSecret are integration-wide
credentials. Bundling them into front-end code exposes them to anyone who opens
devtools — they could then fire events and upload files as you. The browser is
hard-blocked, with no opt-out:
- Runtime — the constructor always throws if it detects a browser.
- Bundle time — browser bundlers (webpack / Vite / Next) resolve the package to
a stub (via the
"browser"export condition) that throws on import, so the SDK can't be built into client code in the first place.
Other protections built in:
- HTTPS enforced.
baseUrlmust behttps://(onlylocalhostmay usehttp), so Basic-auth credentials are never sent in cleartext. - No credential leakage. The
Authorizationheader is never included in error messages, logs, orFlowFlexError.details. - Header-injection safe. The
eventname andidempotencyKeyare rejected if they contain control characters (CRLF). - Path-injection safe.
integrationCodeis URL-encoded into the request path. - Prototype-pollution safe. Payload keys like
__proto__can't polluteObject.prototypeduring the file-swap walk. - Per-request timeouts (default 30 s) on every network call.
- Client-side size cap (default 25 MB) so oversized files fail before upload.
If you must call FlowFlex from a browser, proxy through your own backend: the browser talks to your server, your server holds the secret and calls this SDK.
