How it fits together
Everything in the first six steps happens in Settings, Morph Embedded, with no code. A set-up guide at the top ticks off each step. The developer sections after them are optional. For what Morph Embedded is and what it keeps, see Morph Embedded.
- Switch it on. An owner or admin switches on Morph Embedded in Settings and says how they'll use it: an importer in their product, the engine from their server, or both.
- Describe your data. Upload a sample of what your customers send. Morph fills in a field per column; you add other names and rules.
- Make it look like yours. Your name, colour and logo on the importer.
- Try it. Open the importer as your customers will see it. Nothing is sent.
- Put it in front of your customers. Two lines on your website, or a link sent by email. No code to write.
- Choose where the data goes. Into your app through a connector built from its API docs, or where your system picks files up: a Google Sheet, Google Drive, OneDrive, SharePoint or SFTP.
1. Switch it on
An owner or admin of your Morph organisation opens Settings, Morph Embedded, switches it on and picks how they'll use it: an importer in my product (a button or a link, no code), the engine from my server (files sent from your own system), or both. The steps on the page follow the answer, and you can change it at any time.
Your public id (it starts pv_) is on the same page. It goes in your pages and is not a secret.
2. Describe your data
A schema is the shape every import arrives in: invoices, customers, products. The quickest start is Start from a sample file: choose any CSV or spreadsheet your customers send. Morph reads it in your browser and fills in a field per column, with its type, whether every row had it, and how its dates and numbers are written. The file isn't uploaded or kept.
Then check each field:
- Also called: the other names it goes by in your customers' files ("Inv No", "Invoice #"), so the importer matches them.
- Rules: how values are written (31/03/2026 or 03/31/2026, 1.234,50), words that mean yes or no, values to change ("PD = paid"), what to use when it's empty, an example and a description for your customers.
- What a value must be: for text, the fewest and most characters and the shapes it can have (
INV-9999: 9 is a digit, A a capital letter, a a small letter, ? any letter, * a letter or a digit); for a number, the smallest and largest; for a date, the earliest and latest. A value that breaks one is a problem your customer fixes, said in their language. In the API these areminLength,maxLength,shapes,min,max,earliestandlateston a field. - Each record's id (optional): the field that identifies a record in your system. With it, every record arrives marked
_action: "create"or"update", and the importer shows your customer which is which (see your own checks).
Many fields to describe? Each schema has a helper file: download the template (a row per field), fill it in as CSV, Excel or JSON, upload it, and Morph shows what it read before saving it as a new version. A schema's version only goes up when it actually changes. Developers can also write schemas from a server:
PUT https://morph.vereon.tech/embedded/v1/schemas/Invoice
Authorization: Bearer morph_sk_…
{
"label": "Invoice",
"keyField": "invoice_number",
"fields": [
{ "name": "invoice_number", "type": "string", "required": true, "unique": true,
"aliases": ["Inv No", "Invoice #"] },
{ "name": "issue_date", "type": "date", "required": true },
{ "name": "amount", "type": "number", "required": true },
{ "name": "status", "type": "enum", "options": ["paid", "open", "overdue"] },
{ "name": "customer.email", "type": "email" }
]
}| Property | What it does |
|---|---|
name | The key in each record. Letters, digits, _ and dots (customer.email), up to 100 characters. |
type | string, number, integer, boolean, date, datetime, email, enum (with options) or json. Dates arrive as YYYY-MM-DD, numbers as JSON numbers. |
required, unique | A required field must be filled; a unique one is checked across the whole import. |
aliases, transforms, example | The field's other names, its rules, and an example shown to your customers. |
keyField | Optional: the field that is a record's id in your system. Records then carry _action. |
3. Make it look like yours
Set the name shown, a colour and a logo, and choose whether to show "Powered by Morph". The importer opens over your page (full screen on phones) and takes CSV, TSV and Excel files.
Languages
The importer and the Connect window speak English, French, German, Spanish or Dutch: choose one under Language, or Match each customer's browser (English when the browser speaks none of the five). Buttons, steps, help, row problems, dates and numbers all follow; your own checks' messages are shown as you write them. Under Your own words you can put a few texts in your words, per language: the title, the upload prompt, the help on matching, the import button, the finished message and Connect's title and intro. One page can choose its own language: data-morph-language="fr" on a button, language: "fr" in MorphImporter.open or MorphImporter.connect, or ?language=fr on an import link. A session from your server can carry one too (language on POST /sessions). The page's choice wins, then the session's, then Settings.
4. Try it
Open the importer shows it exactly as your customers will see it, with your data and your look. Upload a sample, map it, fix it: nothing is sent anywhere. When you've mapped a file, Make this the standard saves the column names and rules you used into the schema, so every customer's file with those columns maps itself.
5. Put it in front of your customers
A button on your website
Enter your website's address (like https://app.yourcompany.com; https, no path, up to 20) and paste these two lines where your site builder takes custom code: an Embed element in Webflow, a Custom HTML block in WordPress, an HTML element in Bubble or Softr.
<script src="https://morph.vereon.tech/embed/importer.js" data-vendor="pv_…" async></script>
<button data-morph-import="Invoice">Import invoices</button>The button opens the importer for that schema. Change its words as you like; to say who is importing, add data-morph-customer-id and data-morph-customer-name. The importer opens only on the sites you listed. Anyone who can see the button can import, so when only signed-in customers may, switch off The button starts the importer by itself and start each session from your server (below).
One workbook with a sheet for each kind of data? Name several schemas, data-morph-import="Customer,Invoice": the customer says which sheet holds which, and each sheet is mapped, checked and sent as its own import, in your order. The template they can download then has a sheet for each. In Settings, choose "All of them, a sheet each" for the button or for a link.
Or send a link
No website change at all: under Or send a link, name the customer and make a link. It is copied for you to send by email. They open it, import, and the data comes to you. A link works for 14 days and can be switched off at any time. Your own team can also import for a customer from Import logs.
6. Choose where the data goes
| Where | What happens |
|---|---|
| Your app | Build a connector to your app from its API docs in Morph (Integrations, "Build from API docs"), choose which of its writes each of your data types goes into, match the fields (filled in by name), and send one test record. Each import's records are then written straight into your app; anything it refuses is listed in the import's log. |
| A Google Sheet | Rows are added to the sheet you choose, each field under the column of its name (an empty sheet gets a header row). |
| Google Drive, OneDrive, SharePoint or SFTP | Each import is saved as a CSV file in the folder your system picks files up from. |
| Your own server | A signed webhook (below), or your server collects each import by its id. |
Turn on email alerts to hear when an import arrives (and where it is) or when one couldn't be delivered: to up to 10 addresses, or your organisation's owners and admins. Alerts carry counts and names, never values. Until you choose, imports wait 24 hours for your server to collect them.
Import logs your customers can read
Every import keeps a log for 30 days: when it came in and how (the importer, a link, your team or the API), how many rows, which rows had problems (the row, the field and why), what your app refused, and where the records went. Your customers open their own from Your imports in the importer, so they can see a problem for themselves; your team sees every customer's under Import logs in Settings; and your server reads them with GET /tenants/:external_id/logs to show them in your product. A log never holds a value from the data.
An audit log of who did what
Settings keeps a record of who did what, and when, for 400 days: what your team changed (which settings, by name, never what they were set to), what your server did (by its key), what your customers did in the importer and the Connect window, and what Morph did on its own, such as a sync bringing records or a sign-in that stopped working. Each import is there with its counts and a link to its log. Filter it by customer and by what happened under Audit log; your server reads it with GET /audit. A customer's entries go when you delete the customer.
Let customers connect their own systems (Connect)
Some customers would rather connect their accounting or CRM system than export a file. Under Connect in Settings, choose which systems they may connect (Xero, QuickBooks, Sage, Sage 200, HubSpot, Salesforce, Zoho CRM, Books and Invoice, Odoo), which of each system's records come in and into which of your data types, and how often (every hour, six hours or day). Xero signs your customers in through your own Xero app: create one at developer.xero.com and enter its id and secret there.
A system that isn't on that list can be offered too, if it has an API: build its connector from its API docs in Morph (Integrations, "Build from API docs") and it appears under Your own connectors. Your customers connect it with what that connector signs in with (an API key, a token, or a user and password), which Morph tries on one record before keeping it.
Add one more line to your website, next to the importer's button: <button data-morph-connect>Connect your systems</button>. Your customer signs in to their system in its usual window; from then on, only what changed since the last run comes in, mapped into your fields (their confirmed mapping, your standard, or Morph's matching; if Morph isn't sure, Settings asks you once) and delivered to the same place as their imports, with a log they can read in the same window. Morph keeps the sign-ins fresh; if one stops working, your customer is asked to reconnect and your team is emailed.
// Or from your page: the same window, for one of your signed-in customers.
MorphImporter.connect({
vendor: "pv_…",
getSessionToken: () => fetch("/morph-session").then(r => r.json()).then(s => s.token),
// provider "custom" is one of your own connectors, with its connectorId.
onConnected: ({ provider, connectorId }) => console.log("connected", provider, connectorId),
onDisconnected: ({ provider }) => console.log("removed", provider)
})Writing back. Your app can also add records to a customer's system: in Settings, under the system, choose which of your data types goes in as which of its records (Xero, QuickBooks, Sage and Zoho customers, suppliers and items, or any write of your own connectors), with each of its fields filled from one of yours or a fixed value. Your server then sends a customer's records with POST /tenants/:external_id/writes; they are checked like an import's rows and written into each of that customer's systems once, in the background. The write's import log and the records.written event say what went in and what the system refused. Customers see, before they connect, that your app can add records.
Your server lists a customer's connections and syncs with GET /tenants/:external_id/connections, removes one with DELETE /tenants/:external_id/connections/:id, and runs a sync now with POST /syncs/:id/run. Your webhook gets connection.connected, connection.needs_reconnect and connection.disconnected; synced records arrive as records.delivered with source: "sync" and, with a record id field, _action: "upsert".
For developers
Everything below uses https://morph.vereon.tech/embedded/v1. Make a server key under For developers: it is shown once, and Morph keeps only a hash of it. You can have up to 10 active keys, so you can rotate one without downtime. Keep keys on your server; never put one in a page. Every call sends Authorization: Bearer morph_sk_…, and every error comes back as { "error": "<code>", "message": "<sentence>" }. The same section downloads the API as an OpenAPI file and a Postman collection.
Sessions from your server
For an importer only your signed-in customers can open. Each of your customers is a tenant, named by your own id for them; a session adds the tenant the first time and lasts 15 minutes.
// Your server: one route your page calls to get a session.
const MORPH = "https://morph.vereon.tech/embedded/v1"
const headers = {
Authorization: `Bearer ${process.env.MORPH_SERVER_KEY}`,
"Content-Type": "application/json"
}
app.get("/morph-session", async (req, res) => {
// A 15-minute session for this customer and user. The customer is
// added the first time Morph sees your id for them.
const session = await fetch(`${MORPH}/sessions`, {
method: "POST", headers,
body: JSON.stringify({
tenant: req.user.accountId, tenant_name: req.user.accountName,
user: { id: req.user.id }
})
})
res.json(await session.json()) // { token, expires_at }
})<script src="https://morph.vereon.tech/embed/importer.js"></script>
<script>
const importer = MorphImporter.open({
vendor: "pv_…", // your public id, from Settings
schema: "Invoice", // a schema key
// Called for the first session and again whenever one runs out.
getSessionToken: () => fetch("/morph-session").then(r => r.json()).then(s => s.token),
onComplete: ({ batchId, counts }) => console.log(batchId, counts),
onCancel: () => {}
})
// importer.close() closes it without calling onCancel.
</script>| Option | What it is |
|---|---|
vendor, schema | Required: your public id (or data-vendor on the script tag) and a schema key, or several (["Customer", "Invoice"]) for a workbook with a sheet for each. With several, onComplete gets imports, one per sheet, and onRecords is called once per sheet, with its schema. |
getSessionToken or sessionToken | A session from your server. Prefer the function: it is called again when a session runs out mid-import. Without either, the importer starts its own session on your listed sites. |
customer | Without a session: { id, name }, who is importing, as your page knows them. |
onRecords(records, counts), onCheck(rows) | Direct delivery: the clean records come to your page and never go to Morph's servers, with your own checks in the page (below). |
onComplete(result) | Called when the import is done, with { batchId, counts }; counts are received, valid, invalid, skipped and delivered. |
onCancel() | Called when your customer closes the importer. |
language | Optional: en, fr, de, es or nl (or browser) for this window, over Settings and the session (Languages). |
Keep the data off Morph's servers
With onRecords, the importer works in your customer's browser and hands the clean records to your page. Nothing is sent to Morph's servers, and nothing waits for a webhook or a download.
MorphImporter.open({
vendor: "pv_…",
schema: "Invoice",
// The clean records come to your page and are never sent to Morph's servers.
onRecords: (records, counts) => save(records),
// Your own checks, answered in the page while your customer fixes their file.
onCheck: async rows => rows
.filter(r => !knownCustomer(r.record.customer_id))
.map(r => ({ row: r.row, errors: [{ field: "customer_id", message: "No customer with this number" }] }))
})Your own checks, and create or update
Morph checks types, formats and required fields. For the checks only your system can do, such as whether a customer number exists or an invoice is already imported, give a checks address under For developers. While your customer fixes their file, Morph sends the mapped rows there, up to 500 at a time, and shows your answers as problems to fix. Answer exists: true for a row whose record you already have: with an id field on the schema, it arrives as _action: "update".
// Morph → your checks address, signed like a webhook (with the checks secret):
{ "event": "rows.check", "tenant": { "external_id": "cust_123" },
"schema": { "key": "Invoice", "version": 3 },
"rows": [ { "row": 0, "record": { "invoice_number": "INV-9", "amount": 120 } } ] }
// Your answer, within 10 seconds:
{ "results": [ { "row": 0, "exists": true,
"errors": [ { "field": "invoice_number", "message": "INV-9 is already paid" } ] } ] }Requests are signed like webhooks, with the checks secret shown once when you save the address. If your checks don't answer within 10 seconds, your customer is asked to try again.
The engine: one call per file
For files that arrive on your server (an upload, an email, an SFTP drop). Inline files up to 4 MB; by URL, a CSV up to 2 GB (read as it arrives, never held whole) and a workbook up to 50 MB. In Settings, Try the engine sends a sample file through it and shows the mapping and the clean records, with nothing kept.
// One call per file. Morph maps it itself: this customer's saved mapping for
// the layout, then your standard, then its own matching.
const job = await fetch(`${MORPH}/jobs`, {
method: "POST", headers,
body: JSON.stringify({
tenant: "cust_123", tenant_name: "Brook Supplies", target_schema: "Invoice",
file: { url: "https://files.example.com/invoices.csv" } // or { filename, content_base64 }
})
}).then(r => r.json()) // { job_id, status: "queued" }
// Then GET /jobs/:id → "completed" (the records are delivered like an import),
// or "needs_mapping" with questions. Confirm once with POST /mappings and
// that layout maps itself from then on.Receive with a webhook
Choose Send to my server under step 6, give an https address and copy the signing secret (it starts whsec_and is shown once). Morph sends records.delivered with up to 500 records per part, in order, then import.completed with the counts. Each request carries x-morph-timestamp, x-morph-signature (hex HMAC-SHA256 of timestamp.body with your secret), x-morph-event and x-morph-delivery.
import { createHmac, timingSafeEqual } from "node:crypto"
// Keep the raw body: the signature is over the exact bytes Morph sent.
app.post("/morph-webhook", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("x-morph-timestamp")
const signature = req.get("x-morph-signature") ?? ""
const expected = createHmac("sha256", process.env.MORPH_WEBHOOK_SECRET)
.update(`${timestamp}.${req.body}`)
.digest("hex")
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300 // your window
const valid = signature.length === expected.length &&
timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
if (!fresh || !valid) return res.sendStatus(400)
// A retry repeats x-morph-delivery: skip one you have already stored.
const event = JSON.parse(req.body)
if (event.event === "records.delivered") store(event.tenant.external_id, event.records)
res.sendStatus(200)
})Reply with a 2xx within 10 seconds. Anything else is retried after 1, 2, 4 and so on up to 60 minutes, for up to 24 hours; a part is deleted as soon as you accept it. Morph doesn't reject old timestamps for you, so choose a window (5 minutes is common) and enforce it. The Send a test button sends a webhook.test event.
Or collect each import
Collect each import from your server with its batch id (from onComplete, or from import.completed if you subscribe to that event alone). Each call returns whole parts and deletes them, so store what you get before asking again:
// No webhook: collect a batch by its id (from onComplete or import.completed).
let batch
do {
batch = await fetch(`${MORPH}/batches/${batchId}`, { headers }).then(r => r.json())
if (batch.status === "ready") store(batch.records) // records are deleted as you read them
} while (batch.status === "ready" && batch.remaining_parts > 0)Collect within 24 hours; after that the records are deleted and the batch is expired. Two calls at once for the same batch get 409 batch_busy.
Every call
| Call | What it does |
|---|---|
POST /sessions | A 15-minute importer session for one customer and user; adds the customer the first time. |
POST /jobs, GET /jobs/:id | Sends a whole file through the engine, with or without a mapping; its status, counts and questions. |
POST /mappings/suggest, POST /mappings | Suggests how columns map to a schema, and saves a customer's confirmed mapping. |
POST /transform, POST /validate | Turns up to 5,000 rows into clean records with a mapping, or checks records against a schema. |
POST /profile | Describes up to 5,000 rows: each column's type, formats and how often it is empty, never a value. |
GET /batches/:id | Collects an import's records, once. |
/tenants, /schemas | List, add and remove customers (removing one deletes its data); list, get, put and delete schemas. |
GET /audit | Who did what, and when, newest first: by customer (tenant) and kind, a page at a time. |
Limits and errors
| Limit | Value |
|---|---|
| Rows per import | 1,000,000 |
| Columns | 500 |
| Documents in the importer | PDF, PNG, JPG or TIFF up to 4 MB each, read with OCR (no AI) when you switch on "Let customers send PDFs and scans"; the same by API as a file job. One record each, or a record per line of an invoice when the data type says "Each line of an invoice is a record" |
| File size in the importer | CSV and TSV up to 2 GB (over 50 MB is read from the customer's disk a piece at a time); Excel up to 50 MB |
| API calls | 600 a minute per key (429 with Retry-After: 60) |
| Records per webhook part | 500 |
| Records held | Until delivered, at most 24 hours |
Common errors: 401 invalid_key (a wrong or revoked key), 403 embedded_disabled (switched off), 404 tenant_not_found (a customer Morph hasn't seen yet), 404 schema_not_found, 400 invalid_request (the message names the field), 413 file_too_large.
Building your product's own connection to Morph? See how to build a connector from your API's docs.