Developer Documentation
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> | Rule | Why |
|---|---|
| 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
| 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. |
heading | Optional | built-in | Form title. |
intro | Optional | built-in | Line under the title. |
phone | Optional | none | Your phone number, shown as a "call us instead" fallback. |
theme | Optional | light | dark switches to the dark token set. |
What it asks the visitor for
| Step | Fields |
|---|---|
| 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> | Attribute | Required | Notes |
|---|---|---|
widget-key | Required | Your firm's public key. |
heading | Optional | Panel title. |
theme | Optional | dark 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 });
}); | Event | From | detail |
|---|---|---|
da:submit | quote form | { service, preferred, phones, emails, contactMode } — fired once at the commitment point. phones/emails are counts. |
da:success | quote form | { mode: 'new' | 'existing', service, userId, addressId } |
da:error | quote form | { method, message } |
da:visit-tracked | visit tracker | { visitId } |
da:visit-error | visit tracker | { message } |
da:portal:login | portal | { userId } |
da:portal:logout | portal | {} |
da:portal:ready | portal | { authenticated, tab? } — a ?payJob= landing also fires this from the Pay view with { authenticated: true, tab: 'invoices', payJob, found } |
da:portal:pay | portal | { 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:download | portal | { attachmentId } |
da:portal:help | portal | {} — one per answered help-chat exchange |
da:portal:error | portal | { 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.
}
Field Required Notes widgetKeyRequired — fname, lnameRequired for a new customer Separate fields. Not one "name". address.streetRequired for a new customer A customer record cannot be created without one. phone / emailAt least one A lead with neither is unreachable. userId + addressIdOptional Supply both to file against an existing customer instead of creating one. Get them from findExistingProperty(). services, notes, payload, companyOptional Free 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-valued —
true (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. Member Purpose 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,
});
}
});
typeAccepts Notes photoJPEG, PNG, GIF, WebP, HEIC The common case. pdfPDF General documents. site_planPDF Requires the Site Plan Extraction add-on on your account. plan_setPDF Multi-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
- Put
<da-visit-track> in your site layout, not
on individual pages. One forgotten page is silently
unattributed traffic with no error anywhere.
- Branch on
body.status, never on
res.ok. See the gotchas — this
is the single most common integration bug.
- Fire conversion events on
da:success. da:submit is intent; da:success is a lead.
- 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.
- Map your internal codes to human labels before sending
them. Staff read your payload verbatim.
"unknown" reads as we failed to ask.
- Do not re-host the bundle or pin the hashed filename.
You will freeze on one build and stop getting fixes.
- Send coordinates only when you really have them. Omitting
them is safe and correct; an empty string files the property at 0,0.
- 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
What Budget On breach Lead submission 20 / hour / IP Serves the request anyway and logs a warning — a false block is a lost lead. Duplicate check 30 / 60s / IP 429 Visit tracking 300 / 60s / IP 429, 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.