How it fits together
- Switch it on. An owner or admin of your Morph organisation switches on Morph Embedded in Settings. A set-up guide there ticks off each step below.
- Make a server key. Your server authenticates every call with it. It is shown once.
- Describe your data. A target schema lists the fields you want, with their types and rules.
- Allow your sites. Only the sites you list can show the importer.
- Start a session on your server. Add the customer once, then make a 15-minute session for them each time they open the importer.
- Open the importer in your page. Load one script and call MorphImporter.open with your public id, the schema and the session.
- Receive the records. Morph posts clean records to your webhook, or your server collects them by batch id.
Everything below uses https://morph-vereon.com/embedded/v1. Every call from your server sends Authorization: Bearer morph_sk_…, and every error comes back as { "error": "<code>", "message": "<sentence>" }.
1. Switch it on and make a server key
In Morph, an owner or admin opens Settings, Morph Embedded and switches it on. Under Server keys, make a key and copy it: 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.
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 target schema is the shape you want every import to arrive in. Make one in Settings with the field table, or from your server:
PUT https://morph-vereon.com/embedded/v1/schemas/Invoice
Authorization: Bearer morph_sk_…
{
"label": "Invoice",
"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 | Other names the column may have, the field's standard clean-up (such as reading dates as DD/MM/YYYY or translating values), and an example shown to your customers. |
Helper file. In Settings, each schema can download a template, filled in with a row per field (other names, value translations, formats, defaults), and uploaded as CSV, Excel or JSON. Morph shows what it read, then merges it into the schema as a new version. The schema's version only goes up when its definition actually changes.
3. Allow your sites
Under Allowed sites, list the origins your product runs on, such as https://app.example.com (https, no path; http only for localhost; up to 20). The importer can only be framed by these sites, and only exchanges its session with them, so a page that isn't listed never gets past loading.
4. Start a session on your server
Each of your customers is a tenant, named by your own id for them. Add the tenant (repeat as often as you like), then make a session whenever they open the importer. A session lasts 15 minutes.
// Your server: one route your page calls to get a session.
const MORPH = "https://morph-vereon.com/embedded/v1"
const headers = {
Authorization: `Bearer ${process.env.MORPH_SERVER_KEY}`,
"Content-Type": "application/json"
}
app.get("/morph-session", async (req, res) => {
const tenant = req.user.accountId // your own id for this customer
// 1. Add the customer. Safe to repeat: it creates or renames.
await fetch(`${MORPH}/tenants`, {
method: "POST", headers,
body: JSON.stringify({ external_id: tenant, name: req.user.accountName })
})
// 2. A 15-minute session for this customer and user.
const session = await fetch(`${MORPH}/sessions`, {
method: "POST", headers,
body: JSON.stringify({ tenant, user: { id: req.user.id } })
})
res.json(await session.json()) // { token, expires_at }
})5. Open the importer in your page
<script src="https://morph-vereon.com/embed/importer.js"></script>
<script>
const importer = MorphImporter.open({
vendor: "pv_…", // your public id, from Settings
schema: "Invoice", // a target 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 and a target schema key. |
getSessionToken or sessionToken | One is required. Prefer the function: it is called again when a session runs out mid-import. |
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. |
The importer opens over your page (full screen on phones), shows your brand, and takes CSV, TSV and Excel files.
6. Receive the records
With a webhook
Under Webhook, 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 in Settings sends a webhook.test event.
Without a webhook
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.
Without the importer: the API alone
Everything the importer does is also an API, for files that arrive on your server:
| Call | What it does |
|---|---|
POST /jobs | Imports a whole file (base64 up to 4 MB, or a URL up to 50 MB) for a tenant with a mapping. GET /jobs/:id gives its status and batch id. |
POST /mappings/suggest, POST /mappings | Suggests how columns map to a schema, and saves a mapping for a tenant. |
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. |
/tenants, /schemas | List, add and remove tenants (removing one deletes its data); list, get, put and delete schemas. |
Limits and errors
| Limit | Value |
|---|---|
| Rows per import | 1,000,000 |
| Columns | 500 |
| 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 (add the tenant before its session), 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.