Skip to content
Get started

Your first import, end to end

The overview gets you a healthy engine. This page is the rest of the first two hours: a file goes in, someone reviews it, and signed rows land on your backend.

You do not write a mapping UI, and you do not declare the schema in host code. The schema is a published catalog. The widget loads it. Apply is a signed webhook — that is the default.

your page Mildport engine your backend
───────── ─────────────── ────────────
<mildport-import ingest → match → review
catalog-key="crm"> ─────────► ─────────► POST /apply
HMAC-signed rows

You need two values, both from your Mildport account:

What Where Goes on
License key Licenses — shown once at mint the widget’s license-key
Public key Self-hosting (IMPORT_LICENSE_PUBLIC_KEY) the engine, already, if the overview’s preflight passed

Confirm the engine accepts the key:

Terminal window
curl -H "Authorization: Bearer $LICENSE" \
https://imports.your-infra.example/api/import/v1/license/verify

A good response is "verified": true. Anything else — see Deployment troubleshooting.

The schema lives in the catalog editor/admin/catalogs on your engine, or <mildport-catalog-editor> in your own admin. Draft fields from an OpenAPI spec or a sample record if you have one, mark the ones your API would reject as empty as required, and publish. Give the catalog a stable key (crm, contacts, …).

Pin that key on the widget. Schema changes after this are a publish in the editor, not a host release:

<mildport-import
api-base-url="https://imports.your-infra.example"
license-key="SIGNED_TENANT_KEY"
catalog-key="crm"
></mildport-import>

One published catalog and no catalog-key is also fine — the widget adopts it on its own. Pin the key when a page must always import into a specific schema.

Need the schema computed per user at runtime, or versioned with your app? That’s the schema-as-code path. Skip it until you have a reason.

Apply defaults to a signed POST to your backend. Register the URL once; the response carries the signing secret exactly once — store it.

Terminal window
curl -X POST https://imports.your-infra.example/api/import/v1/webhooks \
-H "Authorization: Bearer $LICENSE" \
-H "Content-Type: application/json" \
-d '{ "url": "https://api.your-app.example/mildport/apply" }'

Want a first look without a backend? Set el.applyMode = 'browser' and read rows from onResults. Production is the webhook. Details: Events & apply and the webhook reference.

Open the page, drop a CSV (or a messy spreadsheet, or a PDF with a table), confirm the suggested mapping, fix anything the review grid flags, and apply.

The widget talks to your engine for ingest and mapping. You do not call ingest yourself unless you are building a headless, no-widget flow.

Every delivery is HMAC-signed. Verify the raw body before you trust it, reject stale timestamps, and dedupe on x-import-delivery-id — retries are normal.

import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(',').map(kv => kv.split('=')));
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const got = Buffer.from(parts.v1 ?? '', 'hex');
const exp = Buffer.from(expected, 'hex');
return got.length === exp.length && timingSafeEqual(got, exp);
}

The body is rows shaped like your catalog (contact.email, …) plus a mapping. Return 2xx when you have persisted them. A 4xx is terminal; a 5xx is retried. Full payload and headers: Apply webhooks.

  • The widget fired import-applied with a rowCount.
  • Your handler logged one deliveryId and wrote that many rows.
  • A second delivery with the same id is a no-op on your side.

Stuck? Deployment troubleshooting covers images that won’t pull, license 401s, CORS, and a pod that never becomes Ready.

Next: Configuration · Licensing · Target catalogs