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

ColumnTypeNotes
idbigserialPrimary key.
org_idbigintTenant scope. Every query filters by this.
street1textRequired. First line of the street address. Max 500 chars.
street2textOptional. Apartment, suite, unit, floor. Max 500 chars.
citytextRequired. Max 200 chars.
statetextState, province, or administrative region. Max 100 chars.
ziptextZIP or postal code. Stored as text to preserve leading zeros. Max 32 chars.
countrychar(2)ISO 3166-1 alpha-2 (US, CA, GB…). Defaults to US.
latitudenumeric(9,6)Decimal latitude from the validation provider. Nullable. Paired with longitude (both set or both null).
longitudenumeric(9,6)Decimal longitude from the validation provider. Nullable.
validation_statusenumOne of unchecked, valid, invalid, unverifiable.
validation_providerenumOne of smarty, google, usps, melissa, manual. Nullable.
validated_attimestamptzWhen the current validation_status was recorded. Nullable.
validation_payloadjsonbRaw provider response (standardized components, suggestions, match codes). Nullable.
created_at · updated_attimestamptzSet automatically.
created_by · updated_bybigintFK to users.id.
deleted_attimestamptzSoft-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:

MethodPathPurpose
GET/api/addressesList addresses for the caller's org. Supports ?search=, ?limit=, ?offset=.
GET/api/addresses/:idFetch a single address. 404 if it belongs to another org.
POST/api/addressesCreate an address.
PATCH/api/addresses/:idPartial update.
PUT/api/addresses/:idFull update.
DELETE/api/addresses/:idSoft-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_payload includes 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:

EventWhen
address.createdA new row is inserted (via REST or the form engine).
address.updatedAny field is changed. The diff is in the event payload.
address.validatedvalidation_status transitions away from unchecked.
address.deleteddeleted_at is set.

Subscribe through webhooks or SSE — see Webhooks and Events & SSE.