Data model
One table for every address.
Every postal address in the platform — a user's home, a client's
headquarters, a vendor's billing office, an organization's mailing
address — lives as a single row in the addresses table.
Owning records reference it by foreign key. The form engine renders
it with one reusable address element, and the REST
surface gives you standard CRUD plus validation metadata.
Column shape
| Column | Type | Notes |
|---|---|---|
id | bigserial | Primary key. |
org_id | bigint | Tenant scope. Every query filters by this. |
street1 | text | Required. First line of the street address. Max 500 chars. |
street2 | text | Optional. Apartment, suite, unit, floor. Max 500 chars. |
city | text | Required. Max 200 chars. |
state | text | State, province, or administrative region. Max 100 chars. |
zip | text | ZIP or postal code. Stored as text to preserve leading zeros. Max 32 chars. |
country | char(2) | ISO 3166-1 alpha-2 (US, CA, GB…). Defaults to US. |
latitude | numeric(9,6) | Decimal latitude from the validation provider. Nullable. Paired with longitude (both set or both null). |
longitude | numeric(9,6) | Decimal longitude from the validation provider. Nullable. |
validation_status | enum | One of unchecked, valid, invalid, unverifiable. |
validation_provider | enum | One of smarty, google, usps, melissa, manual. Nullable. |
validated_at | timestamptz | When the current validation_status was recorded. Nullable. |
validation_payload | jsonb | Raw provider response (standardized components, suggestions, match codes). Nullable. |
created_at · updated_at | timestamptz | Set automatically. |
created_by · updated_by | bigint | FK to users.id. |
deleted_at | timestamptz | Soft-delete marker. Nullable. |
ZIP is text, not numeric
US ZIP codes can start with 0 (02134), and many non-US postal codes include letters and spaces. Store and compare zip as text.
Foreign-key pattern
Records that own exactly one address carry a nullable address_id bigint column that references
addresses.id. The platform ships with this pattern on records like:
users.mailing_address_id— a user's preferred mailing address.billing_settings.address_id— the billing address attached to an organization's billing profile.employees.home_address_id— an employee's home address (sensitive; access-controlled).
Records that own many addresses (an organization with a headquarters, a shipping dock, and a mailing PO box;
a client with multiple site locations) keep their *_addresses link tables. Each link-table row carries
a role discriminator (headquarters, billing, shipping…) and an
address_id FK.
-- One-to-one
SELECT u.id, u.email, a.street1, a.city, a.state
FROM users u
LEFT JOIN addresses a ON a.id = u.mailing_address_id
WHERE u.org_id = $1;
-- One-to-many via link table
SELECT o.name, oa.role, a.street1, a.city
FROM organizations o
JOIN organization_addresses oa ON oa.organization_id = o.id
JOIN addresses a ON a.id = oa.address_id
WHERE o.id = $1
AND oa.deleted_at IS NULL; The address form element
The form engine provides a single address element type. It renders all six address sub-fields
inline with label, autocomplete, and layout handled for you, and submits a JSON object whose shape exactly
matches the addresses row.
{
name: "employee.home_address",
label: "Home address",
element_type: "address",
collection_key: "employee.identification",
sensitive: true,
}
On submit, the form engine upserts an addresses row, sets the owning record's address_id
FK, and emits an address.created or address.updated lifecycle event.
Anywhere you used to define six scalar elements (street1, street2, city, state,
zip, country), you now drop in one address element.
Autocomplete is opt-in
Google Places / Smarty autocomplete is wired through the platform's AI settings. If no provider is configured, the element degrades to plain inputs with validation metadata set to unchecked.
REST API
The canonical surface lives at /api/addresses and follows the standard resource verb pattern:
| Method | Path | Purpose |
|---|---|---|
GET | /api/addresses | List addresses for the caller's org. Supports ?search=, ?limit=, ?offset=. |
GET | /api/addresses/:id | Fetch a single address. 404 if it belongs to another org. |
POST | /api/addresses | Create an address. |
PATCH | /api/addresses/:id | Partial update. |
PUT | /api/addresses/:id | Full update. |
DELETE | /api/addresses/:id | Soft-delete (sets deleted_at). |
Create request body
{
"street1": "123 Market Street",
"street2": "Suite 400",
"city": "San Francisco",
"state": "CA",
"zip": "94103",
"country": "US"
} Response
{
"id": 1284,
"org_id": 1,
"street1": "123 Market Street",
"street2": "Suite 400",
"city": "San Francisco",
"state": "CA",
"zip": "94103",
"country": "US",
"latitude": 37.789475,
"longitude": -122.399538,
"validation_status": "valid",
"validation_provider": "google",
"validated_at": "2026-04-21T14:44:12.890Z",
"validation_payload": { "verdict": { "...": "provider-specific" } },
"created_at": "2026-04-21T14:44:12.123Z",
"updated_at": "2026-04-21T14:44:12.123Z",
"created_by": 42,
"updated_by": 42
}
Every handler filters by org_id = req.user.orgId. Permissions are driven by the
addresses resource — see Request lifecycle
for how role grants resolve at request time.
Validation workflow
New addresses start at validation_status = 'unchecked'. When an org enables an
address-validation provider (Organization settings → Integrations →
Address validation provider), every address captured through the form engine — whether
on the canonical /addresses form, a CRM client / vendor address, or the billing
address — is run through the provider synchronously before the record is saved. The provider's
response is captured in validation_payload, validated_at is stamped,
and the geocode (latitude / longitude) is persisted alongside.
unchecked— never sent to a provider.valid— provider confirmed deliverability.validation_payloadincludes the standardized form.invalid— provider rejected the address as undeliverable.unverifiable— provider could not make a determination (rural route, new construction, outside coverage, or provider temporarily unavailable).
Use validation_status — not a hand-rolled regex on zip — to decide whether to trust the
address. A row with validation_status = 'valid' is the only guarantee the platform makes about deliverability.
Preview endpoint — POST /api/addresses/validate
Run an address through your org's configured validation provider without writing to the database. Use this when you want to check a candidate address (imports, bulk uploads, a custom integration) and decide whether to accept the provider's correction before storing it.
POST /api/addresses/validate
Authorization: Bearer <token>
Content-Type: application/json
{
"street1": "1600 Amphitheatre Pkwy",
"city": "Mountain View",
"state": "CA",
"zip": "94043",
"country": "US"
} The response is a validation envelope, not a full addresses row:
{
"status": "valid",
"provider": "google",
"validated_at": "2026-04-21T14:44:12.890Z",
"corrected": {
"street1": "1600 Amphitheatre Parkway",
"city": "Mountain View",
"state": "CA",
"zip": "94043",
"country": "US"
},
"geocode": { "latitude": 37.421998, "longitude": -122.084059 },
"payload": { "verdict": { "...": "provider-specific" } },
"notes": []
}
When no provider is configured (integrations.address_validation_provider = "none") or
the provider is temporarily unavailable, status is unverifiable and
corrected is null.
Enable the provider first
Validation only runs when the org has set integrations.address_validation_provider (currently "google") and configured the matching API key on the Integrations page. Without that, every address is persisted with validation_status = "unchecked".
Events
Address changes emit the following lifecycle events on the event stream:
| Event | When |
|---|---|
address.created | A new row is inserted (via REST or the form engine). |
address.updated | Any field is changed. The diff is in the event payload. |
address.validated | validation_status transitions away from unchecked. |
address.deleted | deleted_at is set. |
Subscribe through webhooks or SSE — see Webhooks and Events & SSE.