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, 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 });
}); | 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? } |
da:portal:download | portal | { attachmentId } |
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.
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. 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
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.