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, Properties, Tree Care, Contact, Account, Help. Login supports a password or a magic link — if the page loads with ?token=… it signs the customer in automatically and cleans the URL. If the page also loads with ?payJob=<jobId> (a hosted payment link your staff emailed), the customer lands directly on that job's Pay view instead of the dashboard — no extra click needed.

Paying an invoice

The Invoices tab shows only the payment methods that are both connected AND enabled for that specific invoice — never a button that can't work. Depending on what you've connected, a customer may see QuickBooks pay-by-bank / pay-by-card straight from your QuickBooks-hosted payment page (genuinely off your website — Intuit hosts it, with your convenience fee disclosed first if you've turned one on), and/or Stripe one-time pay-by-card / pay-by-bank, confirmed in-page through Stripe's own hosted payment element. A Stripe-only account never shows a QuickBooks button, and vice versa. A "Saved cards" section also lets a customer keep a card on file (Stripe) for your office to charge later; there is no self-serve one-click charge from a saved card. If nothing is payable yet for an invoice, the customer sees a "contact us" message instead of a dead button.

Tree Care

If a customer is enrolled in one of your recurring tree-care programs at any of their properties, this tab lists the services included in that program (name and short description — never a plan-tier label). If they aren't enrolled anywhere, it shows a short invitation message you configure yourself (Tools → Configuration), with an optional link to a page of yours. Leave both blank and the tab shows a plain "nothing configured yet" message instead of appearing broken.

Properties, contact and account

The Properties tab lists each property a customer has with you, with a drill-down into the jobs and tree inventory recorded there. The Contact tab lets a customer send a message or request a quote (optionally naming one of their properties) — both land in your usual staff notification queue. The Account tab lets a customer edit their own name, email, phone and mobile, and set their own password — a real forgot-password flow: a customer signed in the normal way sees "Change password" (needs the current one), while a customer who signed in via an emailed link sees "Set a new password" with no current password required, since the link itself already proved it's them. Either way, their other signed-in devices are signed out.

The Help tab

An AI assistant that answers your customers' "how do I…" questions about the portal, in your firm's voice. It knows how the portal works; it does not look up a customer's invoices, jobs or records, so it points them at the right tab — or at you — instead of quoting figures it cannot verify.

  • It tells customers how to reach you using your CRM record — firm name, phone and admin email. Keep those fields current, because that is what your customers will be told to call.
  • Fair-use capped at 30 messages per customer per hour. Over the cap the customer is asked to try later or contact your office; nothing breaks.
  • It only exists while signed in, and it is a tab rather than a floating bubble — nothing of ours covers your page, and it will not collide with a chat widget you already run.

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.

Optional: version observability

The element always sends its own SDK version automatically — nothing to configure. If you want us to see which version of your integration is running, set host-version:

<da-visit-track widget-key="YOUR_KEY" host-version="1.4.0"></da-visit-track>

Purely observational, never required.

Optional: GA4 correlation

If you run Google Analytics 4 on the same page (a gtag.js install — load order relative to this element doesn't matter), set ga4-id to your GA4 measurement id and the element will correlate its visit with GA4's own client_id/session_id:

<da-visit-track widget-key="YOUR_KEY" ga4-id="G-XXXXXXXXXX"></da-visit-track>

You set this yourself, on your own page — the widget does not fetch it from anywhere. If your Digital Arborist CRM account also has a GA4 Measurement ID configured in Firm Settings → Integrations, that's a separate copy used only for the CRM's own dashboard reporting link; enter the same id in both places so they point at the same property.

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? } — a ?payJob= landing also fires this from the Pay view with { authenticated: true, tab: 'invoices', payJob, found }
da:portal:payportal{ jobId, method: 'card' | 'bank' | 'ach', processor: 'qb' | 'stripe' } — for QuickBooks, fires when the hosted payment tab opens (not proof of completion); for Stripe, only after the charge is confirmed and server-verified
da:portal:downloadportal{ attachmentId }
da:portal:helpportal{} — one per answered help-chat exchange
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.

Richer contact details — the array contract

phone / email above are the simple form and are not going anywhere. If you collected more — several numbers, labels, texting consent, a stated preference — send arrays instead:

phones: [
  { value: '5125550100', label: 'Ada',  kind: 'phone',  canText: true },
  { value: '5125550111', label: 'Work', kind: 'mobile', canText: false },
],
emails: [{ value: 'ada@example.com', label: 'Ada' }],
preferred: 'text',            // 'email' | 'phone' | 'text'

An array replaces its channel. It does not extend it. Send phones and your flat phone / altPhone / mobilePhone are ignored — not merged. There is no error and no warning: you get a lead with a missing number. So migrate a channel wholesale — every number in phones, or none of them. The two channels are decided separately, so a flat email beside a phones array is safe.

canText is three-valuedtrue (asked, consented), false (asked, declined), omitted (never asked). The rule is about your UI, not the value: if you rendered a consent control, send the boolean. An unticked box is a real answer and belongs on the record as false. Omit it only where you genuinely never asked — recording a refusal nobody made is its own kind of wrong.

preferred: 'text' needs a consenting number, and it fails before the request is sent. Texting isn't a channel like email or phone; it's a property of one specific number, and a preference we cannot route is worse than none. Rather than catching that, make it unreachable:

const routable = DAWidget.quoteTextPreferenceOk(contact);   // the same rule we enforce
textOption.disabled = !routable;
if (!routable && select.value === 'text') select.value = ''; // ← don't skip this line

Clear the selection when you disable the option. A disabled <option> that is still selected stays selected — the browser will not clear it for you, and the form submits the value you just forbade. It is invisible until submit, and it is the failure mode of "untick consent while Text message is chosen". <da-quote-form> takes the other valid route: it always renders the checkbox and refuses inline, next to the control that caused it. Either is fine; sending an unroutable preference silently is not.

Errors and their codes

Everything throws DAWidgetError — server refusals (which arrive as HTTP 200, see the gotchas) and the SDK's own pre-flight checks. Pre-flight refusals carry a stable code, so you never have to show a visitor "something went wrong" when you could name the problem. err.message is written to be shown; a server refusal has no code and carries envelope instead.

codeMeaning
text_preference_unroutablepreferred: 'text' with no canText: true phone.
name_requiredCreating a customer without fname + lname.
street_requiredCreating a customer without address.street.
widget_key_requiredNo widgetKey.
file_too_large, file_required, request_ids_requiredAttachment upload guards — see File uploads.

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.
quoteTextPreferenceOk(contact)Is a preferred: 'text' choice routable? Ask before submitting and disable the option, rather than catching the refusal. See the array contract.
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, pay-row, fee-note, no-payment

Don’t hide fee-note

On <da-portal>’s open invoices, fee-note is the line disclosing the card processing fee — and that bank transfer has none. The fee is also named in the card button’s own label, so restyling the note is fine; display: none is not.

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.