Connect every system you run.
Build the missing one in hours.
beetl reads more than 100 systems out of the box. When yours isn't one of them, we write the connector with our open-source SDK: a short TypeScript file, validated before it goes live, and yours to change.
Start from 100+ ready connections.
Ask for the one that isn't there.
The catalogue as it looks in the product, with the systems we read today. Search it, or ask the assistant.
A brand new connection type.
Exactly the way you need it.
A connection type is a TypeScript file with the data contract next to the code. beetl reads that file and builds the setup form, the syncs and the data set from it. Scroll through it.
The schema becomes the data set
Five fields, declared once. Every record is checked against them before it's stored.
const Order = z.object({ id: z.string(), customer: z.string(), total: z.number(), status: z.enum(["open", "shipped", "invoiced"]), updated_at: z.iso.datetime(),});
Inputs become the setup form
Two fields and a bearer token. beetl draws the form from the schema, so nobody builds UI for a connector.
connection: { origin: { type: "input", input: "origin" }, inputs: z.object({ origin: z.url().meta({ title: "ERP address" }), company: z.string().meta({ title: "Company code" }), }), auth: auth.bearer(),
Verify runs before anything is saved
Retries are one line. Verify calls the API before the connection is saved, so a wrong address fails here, not on the first night's sync.
retry: { maxAttempts: 5, statuses: [429, 500, 502, 503] }, async verify(ctx) { await ctx.json(`/api/${ctx.config.company}/orders?limit=1`, Page); },
Merge on id
An updated order replaces its old version. Running the sync again never doubles a row.
syncs: (sync) => ({ orders: sync({ displayName: "Orders", mode: "merge", primaryKey: ["id"], records: Order,
Each page commits with its checkpoint
If a run stops halfway, the next one starts after the last page that made it in.
checkpoint: z.object({ since: z.string() }), async *run(ctx) { const { company } = ctx.config.connection; let since = ctx.checkpoint?.since ?? ""; for await (const page of ctx.paginate({ request: { path: `/api/${company}/orders`, query: { since } }, schema: Page, next: ({ data }) => (data.next ? { path: data.next } : undefined), })) { since = page.data.orders.at(-1)?.updated_at ?? since; yield { records: page.data.orders, checkpoint: { since } }; } },
ERP orders Custom
erp-orders.tgz · built and validated
Syncs
Data set erp_orders.orders
| id | string |
| customer | string |
| total | number |
| status | open · shipped · invoiced |
| updated_at | datetime |
Change any connector to fit.
Same SDK, same checks.
Most of our connectors are written with the same SDK, so each one is a starting point, not a black box. One sales channel out of Shopify, a field your ERP gained last year, a different schedule: a few lines, reviewed and validated like the rest.
Orders
Shopify · sync settings
shopify/integration.ts
@@ -43,3 +43,4 @@ const Inputs = z.strictObject({
}),
+salesChannel: z.string().meta({ title: "Sales channel" }).optional(),
pageSize: z.number().int().min(1).max(100).default(50),
@@ -107,2 +108,3 @@ async *run(ctx) {
const filters = config.query ? [`(${config.query})`] : [];
+if (config.salesChannel) filters.push(`sales_channel:${config.salesChannel}`);
if (definition.root === "orders") {
Two lines in the Shopify connector, and the sync has a new setting.
Fast to write.
Hard to break.
Speed only counts if the connector still works in month six. The SDK owns the parts hand-written integrations usually get wrong: paging, retries, validation, and where to start again after a failure.
- Page 1500 orders · committed with its checkpoint
- Page 2500 orders · committed
- Page 3HTTP 429 · retried after 0.5 s and 1 s · committed
- Page 4Worker restarted mid-page · nothing half-written
- Next runStarts after page 3 · page 4 committed once
20
connectors in one day, by one engineer.
An agent drafts each one from the vendor's API docs. An engineer reviews it before it ships.
- Every record is checked against its schema. One that doesn't match is rejected, not quietly stored.
- A page and its checkpoint commit together, so a restart picks up exactly where the last commit left off.
- Retries back off on rate limits and server errors, within the limits the connector declares.
- The connector runs isolated. It gets the source credentials it needs, never the ones for where the data lands.
- Read-only by default. What the source sent is kept as it came, before any of your mappings run.
No API?
It runs inside your network.
Some systems were never meant to be reached from outside: an ERP database in the server room, a file share, a machine log. A site connector inside your network reads them and opens an outbound tunnel. No inbound firewall rules, no open ports.
- DatabasesRead-only, over a scoped connection
- Files and spreadsheetsShares, uploads and object storage
- WebhooksTyped and schema-checked on the way in
- Local systemsThrough the site connector
Your network
ERP databaseSite connectorOpen source.
Apache 2.0.
The SDK is public. Your engineers can read exactly what runs, try a connector on a laptop against the real API, and write their own. Upload the package in beetl, or send it to us and we'll review it.
Read the SDK on GitHub ↗# add the SDK$ npm install --save-dev @beetlio/connect# set up a connection and run a sync on your laptop$ npx beetl-connect configure . orders$ npx beetl-connect sync . orders --output orders.ndjson# package it for beetl$ npx beetl-connect pack .