Widget SDK

Put the Digital Arborist quote form, client portal and campaign tracking on any website with one <script> tag. No framework, no build step, no npm install. Works with Astro, WordPress, Next.js, Squarespace or plain HTML.

Quickstart

Two lines. Paste the script once in your site layout, then drop the element wherever you want the form.

<!-- once, in your site layout -->
<script async src="https://digitalarborist.app/widget/v1/da-widget.js"></script>

<!-- wherever the form belongs -->
<da-quote-form widget-key="YOUR_KEY"></da-quote-form>

That is a working lead form: it collects the contact details needed to create a real customer account, checks for an existing customer, and asks a returning one which of their properties the request is for instead of making them retype an address.

Installing the SDK

The script tag

<script async src="https://digitalarborist.app/widget/v1/da-widget.js"></script>
Requirements for the script tag
RuleWhy
Use this exact URL. Never hard-code the hashed filename it redirects to. This URL is a small, uncached redirect to a content-hashed bundle cached for a year. That indirection is how you get SDK fixes without touching your site. Pinning the hash freezes you on one build forever.
Load it as a classic script. Not type="module", not import(). The bundle is an IIFE. Module loading is subject to CORS rules the hashed asset does not satisfy, so it fails cross-origin.
If your framework bundles imported assets, mark this inline. In Astro that is is:inline. The SDK derives the API origin from its own <script src>. Re-host the file and it will resolve the API to your domain and every call 404s.
async is safe and recommended. Custom elements upgrade whenever they are defined, regardless of tag order. Nothing on your page needs to wait for it.

Content Security Policy

If your site sends a CSP, the SDK needs to be allowed to load and to reach the API:

script-src  https://digitalarborist.app;
connect-src https://digitalarborist.app;

No unsafe-inline and no unsafe-eval are required.

Verifying it loaded

// In the browser console on your page:
window.DAWidget.version          // → "0.1.0"
window.DAWidget.components()     // → ["da-quote-form","da-visit-track","da-portal"]

If window.DAWidget is undefined, the script did not execute — check the Network tab for a blocked request, and check for a CSP or content blocker before looking anywhere else.

Your widget key

widget-key is public by design. It goes in your page source and anyone can read it. It selects which firm a request belongs to — it authenticates nobody.

  • Committing it to a public repo or a client bundle is fine and expected. It is not a secret and never was.
  • Every widget request is treated as anonymous, which is why responses are deliberately minimal. The duplicate check returns property addresses you can click — never customer names, emails or phone numbers.
  • There is no self-service key rotation yet. Changing a key is a manual operation on our side. Worth knowing before you plan around it.

<da-quote-form> — quote form

The component your lead flow depends on. It collects what is needed to create a real customer account, runs duplicate detection, and routes a returning customer to a property picker.

<da-quote-form
  widget-key="YOUR_KEY"
  heading="Request an estimate"
  intro="Tell us what you need and we'll get back to you."
  service="Pruning"
  contact="phone"
  phone="(512) 555-0100"
></da-quote-form>

Attributes

All attributes are observed — change them after the element is on the page and it re-renders.
Attribute Required Default Notes
widget-key Required Your firm's public key.
service Optional none Free text describing what this embed's requests are about — usually the service page it sits on. It is what your office staff read on the request. There is no built-in service list; see Sending your own data.
contact Optional both required phone or email. This relaxes a requirement rather than hiding a field: contact="phone" makes email optional. Omit it and both are required.
headingOptionalbuilt-in Form title.
introOptionalbuilt-in Line under the title.
phoneOptionalnone Your phone number, shown as a "call us instead" fallback.
themeOptionallight dark switches to the dark token set.

What it asks the visitor for

StepFields
1 — on load First name · Last name · Phone (with an "OK to text this number" checkbox) · Email · Best way to reach you · Anything else? (optional)
1 — on demand "+ Add another phone / email", up to 4 of each. A per-entry Label field ("Work", "Spouse") appears once a group holds two entries.
2 — new customers Street · City · State · ZIP
2 — returning customers Which of their existing properties this is for. The address is never asked again.

Texting consent is per-number and three-valued — ticked, unticked, or never asked. The checkbox always renders, so an unticked box records a real "no" rather than an unknown. Choosing Text message as the preferred contact method without ticking at least one number is refused inline, because the API refuses it too: a preference we cannot route is worse than no preference.

Sending your own data

The SDK has no service list and will not get one. A list of services hard-coded into a shared component goes stale the moment you change your offering, and is wrong by construction for every other firm. You own that data. There are two ways to hand it over.

Simple — one service page, one attribute

<da-quote-form widget-key="YOUR_KEY" service="Removal"></da-quote-form>

Rich — you collected structured data yourself

Set the message property (a property, not an attribute — it holds structured data):

const form = document.querySelector('da-quote-form');

form.message = {
  // Overrides the `service` attribute. Free text — your vocabulary, not ours.
  services: 'Removal, Pruning',

  // Anything else you collected. Stored on the request and rendered for staff.
  payload: {
    source: 'tree-selector',
    summary: 'Privacy screen · 3 trees · back yard',
    sections: [
      { label: 'Species picked', items: [{ label: 'Live Oak', value: 'no caveat' }] },
      { label: 'Site',           items: [{ label: 'Irrigation', value: 'Existing zone' }] },
    ],
  },

  // Prefills step 2. Also the ONLY source of coordinates.
  address: {
    street: '900 Guard St', city: 'Austin', state: 'TX', zip: '78704',
    lat: 30.245, lng: -97.77,
  },
};

Coordinates: send both or send neither

The component has no geocoder on purpose — a Maps script and an API key inside a bundle every site loads would be billed to you, not us. If you have a real position, send lat and lng as finite numbers. Never send an empty string. An empty string is not "unknown" — it becomes 0, and the property is filed at latitude 0, longitude 0, in the Gulf of Guinea. Omitting them is correct and safe: we geocode the address server-side, and staff confirm the location before the customer is activated.

The payload shape above (source, summary, sections) renders as labelled prose in the staff email and the office dashboard. Any other shape still renders, generically — but map your internal codes to display strings first. "soil_depth": "unknown" reads to staff as we failed to ask; { label: 'Soil depth', value: "Don't know" } reads as an answer.

<da-portal> — client portal

<da-portal widget-key="YOUR_KEY" heading="My Account"></da-portal>
AttributeRequiredNotes
widget-keyRequiredYour firm's public key.
headingOptionalPanel title.
themeOptionaldark for the dark token set.

Tabs: Overview, Invoices, Documents, Account. Login supports a password or a magic link — if the page loads with ?token=… it signs the customer in automatically and cleans the URL.

Put the portal on a page you control tightly

The session token lives in localStorage and survives navigation. Any script running on that origin can read it. A strict Content-Security-Policy on the portal page is the single highest-value thing you can do for it — and do not load third-party tag managers or ad scripts on that page.

<da-visit-track> — campaign attribution

<!-- put this in your site LAYOUT so it runs on every page -->
<da-visit-track widget-key="YOUR_KEY"></da-visit-track>

Renders nothing and takes no space. It records the visit, its referrer and any UTM parameters, then hands a visit ID to the quote form so a submitted lead is attributed to the campaign that produced it. It records one row per pageview, which is what makes "which marketing spend produced approved work?" answerable.

Failures are silent by design. A tracking problem must never cost a lead, so nothing about this can block or break a submission.

Events

Every component dispatches da:-prefixed CustomEvents. They bubble and cross the shadow boundary, so you can listen on document.

document.addEventListener('da:success', (e) => {
  gtag('event', 'generate_lead', { method: e.detail.mode });
});
EventFromdetail
da:submitquote form{ service, preferred, phones, emails, contactMode } — fired once at the commitment point. phones/emails are counts.
da:successquote form{ mode: 'new' | 'existing', service, userId, addressId }
da:errorquote form{ method, message }
da:visit-trackedvisit tracker{ visitId }
da:visit-errorvisit tracker{ message }
da:portal:loginportal{ userId }
da:portal:logoutportal{}
da:portal:readyportal{ authenticated, tab? }
da:portal:downloadportal{ attachmentId }
da:portal:errorportal{ stage }

Track conversions on da:success, not da:submit

da:submit fires when the visitor commits. da:success fires when the lead actually landed. Counting submits inflates your conversion numbers by every failure.

JavaScript API — window.DAWidget

You do not need this for a normal embed. It exists for custom flows — your own multi-step tool that submits at the end, for example.

submitQuoteRequest(input)

Create a lead from your own UI, with no <da-quote-form> rendered. Returns { mode, userId, addressId }. Throws DAWidgetError on failure.

try {
  const { mode, userId, addressId } = await DAWidget.submitQuoteRequest({
    widgetKey: 'YOUR_KEY',
    fname: 'Ada', lname: 'Lovelace',
    phone: '5125550100', email: 'ada@example.com',
    address: { street: '900 Guard St', city: 'Austin', state: 'TX', zip: '78704' },
    services: 'Planting',
    notes: 'Wants a privacy screen along the west fence.',
    payload: { source: 'tree-selector', summary: 'Privacy screen · 3 spots' },
  });
} catch (err) {
  // DAWidgetError covers a rejected request AND a network failure.
}
FieldRequiredNotes
widgetKeyRequired
fname, lnameRequired for a new customerSeparate fields. Not one "name".
address.streetRequired for a new customerA customer record cannot be created without one.
phone / emailAt least oneA lead with neither is unreachable.
userId + addressIdOptionalSupply both to file against an existing customer instead of creating one. Get them from findExistingProperty().
services, notes, payload, companyOptionalFree text and your structured payload.

findExistingProperty(input)

Look up properties matching a person so you can offer "is this your property?" before submitting. Returns [{ userId, addressId, line }], possibly empty. Never throws for a business reason — a failed lookup resolves to [], because a dedup check must never cost a lead.

const matches = await DAWidget.findExistingProperty({
  widgetKey: 'YOUR_KEY',
  fname: 'Ada', lname: 'Lovelace',
  phone: '5125550100', email: 'ada@example.com',
});
// → [{ userId: 412, addressId: 908, line: '900 Guard St, 78704' }]

Results are redacted by design: you get an ID and an address to display, never a name, phone or email. Do not build UI that shows a person their own name back — you will not have it.

Everything on the global

Verified against the deployed bundle.
MemberPurpose
submitQuoteRequest(input)Create a lead headlessly. See above.
findExistingProperty(input)Duplicate lookup. See above.
uploadQuoteAttachment(input)Attach a file to a created request. See File uploads.
UPLOAD_TYPES, MAX_UPLOAD_BYTESAllowed attachment labels; per-file size cap.
call(method, body, opts)Raw API call. Returns the parsed body, throws DAWidgetError on a failure envelope.
DAWidgetErrorError class thrown by all of the above.
versionSDK version string.
components()Tags this bundle registered — a useful smoke check.
apiOriginResolved API origin (a string, not a function).
currentVisitId() / setVisitId(id)The attribution seam. Attached automatically; you rarely touch it.
isBlank(v) / toNumber(v)Wire-safe helpers — see the gotchas.
tokens, themeCss, themeVars, brandVersionThe design tokens the components render with.
DAWidgetElement, define(tag, ctor)Build your own component on the same base class.

File uploads

Attach photos or documents to a request you just created. Call it once per file — one request per file means a partial failure stays partial instead of losing the whole batch.

document.addEventListener('da:success', async (e) => {
  const { userId, addressId } = e.detail;

  for (const file of myFileInput.files) {
    await DAWidget.uploadQuoteAttachment({
      widgetKey: 'YOUR_KEY',
      userId, addressId,
      file,
      type: 'photo',            // see the table below
      displayName: file.name,
    });
  }
});
typeAcceptsNotes
photoJPEG, PNG, GIF, WebP, HEICThe common case.
pdfPDFGeneral documents.
site_planPDFRequires the Site Plan Extraction add-on on your account.
plan_setPDFMulti-page plan set. Same add-on requirement.

File contents are validated against the declared type — a "pdf" that is not a PDF is rejected. Maximum 50 MB per file.

There is a ten-minute deadline, and it does not reset

The office notification for a new request is held for ten minutes specifically so attachments can arrive first. Files uploaded after that window still attach to the record, but the email your staff already received will not contain them. Start uploads immediately on the confirmation step — never behind another click or a later page.

Styling & CSS customization

The components use shadow DOM, so your page CSS does not leak in and cannot accidentally break them. There are two supported ways to style them, in order of preference.

1. CSS custom properties — preferred

Every visual value is a design token exposed as a --da-* property. Custom properties inherit through the shadow boundary, so you set them on the host element:

da-quote-form {
  /* Colour */
  --da-color-primary:      #1B4D3E;
  --da-color-accent:       #69A741;
  --da-color-text:         #1a1a1a;
  --da-color-text-muted:   #5f6b66;
  --da-color-border:       #8d6e63;
  --da-color-link:         #1c4600;
  --da-color-on-cta:       #ffffff;

  /* Shape & type */
  --da-radius-md:          6px;
  --da-font-family-body:   'Inter', system-ui, sans-serif;
  --da-font-size-base:     16px;
}

This is the durable option: tokens are a contract, so they keep working across SDK updates.

2. ::part() — structural overrides

Every structural node is exposed as a part:

da-quote-form::part(button) { text-transform: uppercase; letter-spacing: .04em; }
da-quote-form::part(input)  { border-width: 2px; }
da-quote-form::part(error)  { font-weight: 600; }

Available parts:

wrap, header, title, heading, intro, form, field, label, input, select, textarea, hint, button, options, error, form-error, notice, success, loading, callout, card, body, nav, tab, login, user, profile, summary, line-item, call-link

Prefer tokens over parts

A ::part() rule pins you to the component's current DOM structure. Tokens do not. Reach for parts only when a token cannot express what you need.

Dark mode

<da-quote-form widget-key="YOUR_KEY" theme="dark"></da-quote-form>

Best practices

  1. Put <da-visit-track> in your site layout, not on individual pages. One forgotten page is silently unattributed traffic with no error anywhere.
  2. Branch on body.status, never on res.ok. See the gotchas — this is the single most common integration bug.
  3. Fire conversion events on da:success. da:submit is intent; da:success is a lead.
  4. Keep a non-JavaScript path to contact you. A phone number or a plain mailto: near the form costs nothing and catches every visitor whose script was blocked.
  5. Map your internal codes to human labels before sending them. Staff read your payload verbatim. "unknown" reads as we failed to ask.
  6. Do not re-host the bundle or pin the hashed filename. You will freeze on one build and stop getting fixes.
  7. Send coordinates only when you really have them. Omitting them is safe and correct; an empty string files the property at 0,0.
  8. Style with tokens first, parts second. Tokens survive SDK updates; parts pin you to today's DOM.

Four things that will bite you

1. A failure is HTTP 200

An invalid widget key, a rejected body, a business-rule refusal — all return HTTP 200 with {"status":"fail","message":"…"}. This is a legacy wire contract and it is not changing.

Branch on body.status, never on res.ok. DAWidget.call() and the higher-level methods already do this and throw DAWidgetError — that is the main reason to use them instead of raw fetch.

2. null is never on the wire

Every null is converted to "" before sending. An absent number arrives as "" — not null, not 0. Test === '', and never Number(x) a maybe-blank field: Number('') is 0, which is a real value and a wrong one.

3. Empty-string coordinates become 0,0

lat: '' does not mean "unknown" — it coerces to 0 and files the property in the Gulf of Guinea. Send both as finite numbers or send neither.

4. visitId is an integer

If you plumb attribution yourself, do not string-compare it. A mismatch drops attribution silently, with no error anywhere — the exact failure the tracker exists to prevent.

Rate limits

WhatBudgetOn breach
Lead submission20 / hour / IPServes the request anyway and logs a warning — a false block is a lost lead.
Duplicate check30 / 60s / IP429
Visit tracking300 / 60s / IP429, never blocks the page

Getting help

Something here wrong, missing, or contradicted by what you are seeing? Get in touch — integration reports are the fastest way this page improves.