Destinations

REST endpoints for outbound destinations. Configure warehouse tables and cloud file drops, push golden records on demand or stream a one-shot browser export.

Destinations

Endpoints for managing outbound destinations — where the funnel pushes golden records after matching + clustering completes. Symmetric to the /sources API on the ingest side.

Two families ship in v1:

  • Warehouses: postgres_dest, mysql_dest, snowflake_dest, bigquery_dest. Require a target_table that already exists; writers TRUNCATE then INSERT (overwrite) or just INSERT (append).
  • Cloud file drops: s3_dest, gcs_dest, azure_blob_dest. Format inferred from URL suffix (.csv → CSV; anything else → Parquet).

The browser-side /export endpoint streams CSV / Parquet / Excel directly without persisting a destination row.

Note: All connection strings are encrypted at rest via the same envelope-encryption envelope credentials.encrypt_payload uses for source credentials. GET responses mask the password (user:***@host); only the /run path decrypts the plaintext to hand to the writer.

GET/api/golden/destinationsAUTH

List all destinations visible to the authenticated user. Org members see destinations owned by anyone in their org plus their own user-scoped ones.

Response

[
  {
    "id": "abc-123-...",
    "clerk_user_id": "user_2abc...",
    "org_id": "01234567-89ab-cdef-0123-456789abcdef",
    "name": "Warehouse · golden_customers",
    "destination_type": "postgres_dest",
    "connection_string": "postgres://user:***@warehouse.example.com:5432/analytics",
    "target_table": "public.golden_customers",
    "entity_type": "person",
    "config_json": {},
    "write_mode": "overwrite",
    "status": "idle",
    "error_message": null,
    "last_run_at": "2026-05-22T18:30:00Z",
    "last_row_count": 14238,
    "credential_type": null,
    "has_credential": false,
    "version": 7,
    "created_at": "2026-05-20T09:00:00Z"
  }
]
FieldTypeDescription
idstringDestination identifier (UUID)
org_idstring | nullUUID of the owning org, or null for solo users
destination_typestringOne of the 7 registered writer types
connection_stringstringMasked on read — passwords replaced with ***
target_tablestring | nullRequired for warehouses; null for cloud file drops
entity_typestringWhich entity_type's golden records to push
config_jsonobjectPer-writer config (e.g. format: "csv" for file writers)
write_modestringoverwrite (truncate first) or append
statusstringidle, running, or error
versionintegerMonotonically increments on every status change; ETag handle
has_credentialbooleanTrue iff an inline encrypted credential is stored
GET/api/golden/destinations/types

List the destination_type strings the backend will accept on POST. Useful for the frontend connector registry to detect drift.

Response

{
  "types": [
    "azure_blob_dest",
    "bigquery_dest",
    "gcs_dest",
    "mysql_dest",
    "postgres_dest",
    "s3_dest",
    "snowflake_dest"
  ]
}
POST/api/golden/destinationsAUTH

Create a new destination. The connection_string is encrypted server-side before persistence.

Request

{
  "name": "Warehouse · golden_customers",
  "destination_type": "postgres_dest",
  "connection_string": "postgres://user:pw@warehouse.example.com:5432/analytics",
  "target_table": "public.golden_customers",
  "entity_type": "person",
  "config_json": {},
  "write_mode": "overwrite"
}
FieldTypeRequiredDescription
namestringYes1–100 chars, display name
destination_typestringYesMust match a registered writer
connection_stringstringYesURL or bucket URI. Encrypted before persist.
target_tablestringNoRequired for warehouses; ignored by file writers
entity_typestringYesWhich entity_type to push on each /run
config_jsonobjectNoPer-writer extras (e.g. format)
write_modestringNooverwrite (default) or append

Response 201 Created — same shape as the list endpoint (with masked connection string).

Errors

StatusBodyMeaning
400 Bad Request{detail: "unknown destination_type: ..."}Writer not registered
402 Payment Required{detail: {error: "quota_exceeded", gate, limit, current}}Hit plan's max_destinations
DELETE/api/golden/destinations/{id}AUTH

Delete a destination. Org members can delete each other's org-scoped destinations.

Response 204 No Content

Errors

StatusMeaning
404 Not FoundDestination doesn't exist or caller is in a different org

POST /api/golden/destinations/{id}/run [AUTH] [RATE-LIMITED 30/hr]

Push the golden records for the configured entity_type to this destination.

Request

{
  "write_mode": "overwrite"
}

write_mode is optional; if omitted, the destination's stored default is used.

Response 200 OK

{
  "ok": true,
  "rows_written": 14238,
  "target": "public.golden_customers"
}

Errors

StatusBodyMeaning
402 Payment Required{detail: {error: "quota_exceeded", gate: "max_concurrent_destination_runs", ...}}Hit plan's concurrent-run cap
404 Not FoundDestination doesn't exist or visible to caller
409 Conflict{detail: "destination is already running ..."}Another /run already holds the row (optimistic lock)
422 Unprocessable Entity{detail: {error: "destination_table_missing", message, suggested_ddl}}Warehouse target table doesn't exist; body carries a copyable CREATE TABLE
500 Internal Server ErrorWriter raised a non-timeout exception
503 Service Unavailable{detail: "<...>_dest connect timed out after Ns ..."}Writer's connect or write timeout (default 10s / 300s)

Tip: Each successful run emits a destination.run row in the audit_log table (org users only). Failures emit destination.run.failed with the truncated error.

POST/api/golden/destinations/{id}/credentialsAUTH

Attach or rotate the encrypted credential for a destination. Used by all writers except plain Postgres / MySQL where the credential lives inline in the connection string.

Request

{
  "credential_type": "aws_iam",
  "payload": {
    "access_key_id": "AKIA...",
    "secret_access_key": "...",
    "region": "us-east-1"
  }
}

Recognised credential_type values:

TypeUsed byPayload shape
aws_iams3_dest{access_key_id, secret_access_key, region}
service_accountgcs_dest, bigquery_dest{key_json} (full JSON blob as string)
azure_account_keyazure_blob_dest{account_name, account_key}

Response 200 OK {ok: true}

DELETE/api/golden/destinations/{id}/credentialsAUTH

Clear the inline credential. The destination row remains; just unauth'd until a new credential is POSTed.

Response 204 No Content

GET /api/golden/destinations/export [AUTH] [RATE-LIMITED 60/hr]

Stream a one-shot download of every golden record for an entity_type. No destination row, no credentials.

Query parameters

ParamTypeRequiredDescription
entity_typestringYesWhich entity_type's golden records to export
formatstringNocsv (default), parquet, or excel
limitintegerNoCap on rows returned (default 100,000)

Response 200 OK — file stream with Content-Disposition: attachment and the appropriate Content-Type:

  • csvtext/csv
  • parquetapplication/x-parquet
  • excelapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet

Errors

StatusMeaning
503 Service Unavailableexcel format requested but xlsxwriter not installed on the server

Note: Each export emits a destination.export audit_log row for org users with {row_count, format, entity_type} in the after field.

Per-destination_type config reference

postgres_dest

  • connection_string: postgres://user:pw@host:5432/dbname
  • target_table: required, e.g. public.golden_customers
  • config_json: none
  • credentials: none (embedded in connection string)

Uses COPY FROM STDIN for bulk insert. overwrite runs TRUNCATE first.

mysql_dest

  • connection_string: mysql://user:pw@host:3306/dbname
  • target_table: required
  • config_json: none
  • credentials: none

Batched INSERTs (1000 rows per round-trip). LOAD DATA LOCAL INFILE is faster but most managed MySQL offerings disable it.

snowflake_dest

  • connection_string: snowflake://user:pw@account/DB/SCHEMA?warehouse=WH&role=ROLE
  • target_table: required, e.g. GOLDEN_CUSTOMERS (case preserved)
  • config_json: none
  • credentials: none

v1 uses batched INSERTs. PUT + COPY INTO would be faster — that's roadmap once volume justifies the pandas dependency.

bigquery_dest

  • connection_string: bigquery://project/dataset
  • target_table: required, table name only
  • config_json: none
  • credentials: service_account with {key_json} (full JSON blob)

Uses load_table_from_file with a Parquet payload. Faster than streaming inserts; respects free-tier load quotas.

s3_dest

  • connection_string: s3://bucket/path/to/file.parquet (or .csv)
  • target_table: ignored
  • config_json: {format: "csv" | "parquet"} — auto-set from URL suffix
  • credentials: aws_iam with {access_key_id, secret_access_key, region}

IAM user needs s3:PutObject on the bucket.

gcs_dest

  • connection_string: gs://bucket/path/to/file.parquet
  • target_table: ignored
  • config_json: {format: ...} auto-set from URL suffix
  • credentials: service_account with {key_json}

Service account needs storage.objectCreator on the bucket.

azure_blob_dest

  • connection_string: azure://account.blob.core.windows.net/container/path/file.parquet
  • target_table: ignored
  • config_json: {format: ...} auto-set from URL suffix
  • credentials: azure_account_key with {account_name, account_key}

For least privilege, consider a stored access policy on the container instead of the master account key.

TypeScript client

The repo ships a thin typed client at frontend/lib/destinations.ts:

import {
  listDestinations,
  createDestination,
  runDestination,
  exportEntities,
  storeDestinationCredential,
} from '@/lib/destinations'

// Push every golden person to the warehouse
const destinations = await listDestinations(token)
const warehouseDest = destinations.find(d => d.name === 'Warehouse · golden_customers')
const result = await runDestination(token, warehouseDest!.id)
console.log(`wrote ${result.rows_written} rows`)

// Or a one-shot browser download
const blob = await exportEntities(token, 'person', 'parquet')

The client handles 402 → quota-exceeded window event dispatch automatically so the existing <QuotaExceededModal /> lights up without per-callsite wiring.