Skip to content

Database webhooks: leads, appointments, deals

A B2C sales team runs a marketing funnel — leads come in from various sources, agents follow up and book appointments at the showroom, some appointments turn into deals. The whole funnel lives in the team's own database — a Supabase project, a Cube.dev backend, an internal application, anything that can fire webhooks when rows are inserted or updated. The team wants SalesDash to track agent performance across the funnel: leads handled, appointments booked, appointments held, deals closed, conversion rates between each step.

The integration's defining shape is one provider with three webhook sources, each delivering one row of the team's database as a single payload. Three processors, one per source. The pattern works for any database that can fire webhooks per row (Supabase row-change webhooks, Cube.dev triggers, hooks from the team's own backend) — the example shows the spec shape; the source-side plumbing is up to the platform.

This example also introduces a pattern that hasn't appeared in the previous three: dual attribution, where a single source record produces multiple activities credited to different agents (a setter who books an appointment and a closer who runs it).

What gets measured

The activities the spec writes feed funnel metrics like:

  • Leads — count of "Lead" activities per setter.
  • Appointments booked — a metric counting every "Appointment Created | <status>" activity per setter. Since the activity type embeds the status, the metric definition explicitly lists each status value (scheduled, confirmed, won, lost, …) that should count.
  • Appointments held — count of "Appointment Setter | won" and "Appointment Setter | lost" activities per setter (the "held" statuses, excluding upcoming and no-shows).
  • Deals — count of "Deal Closer | accepted" activities per closer.
  • Setter → appointment rate — appointments booked / leads.
  • Appointment → deal rate — deals / appointments held.
  • Revenue — sum of value on "Deal Closer | accepted" activities.

Each step's metric is configured by listing the relevant activity_type values in the metric definition. Status mapping happens metric-side, not in the spec — see the field-marketing example's "many states" variation for why.

The integration shape

A single "Webhooks" provider with three sources. Each source has its own webhook URL that the team's database fires when a row in the corresponding table is inserted or updated. The payload is the row.

SourceSource modelProcessorFired when
Lead webhookpayloadLead Processora lead row is created
Appointment webhookpayloadAppointment Processoran appointment row is created or updated
Deal webhookpayloadDeal Processora deal row is created or updated

About the payload source model

The "Webhooks" provider's source is generic — the source-model type is payload, which means the webhook body becomes the source model directly. Fields are referenced by their keys: if the webhook body has an id field and a status field, the spec sees payload.id and payload.status. No predefined schema, no enrichments — whatever your database puts in the body is what the spec gets.

The flip side: the validator can't type-check fields on a payload source the way it does for typed source models like ghl_opportunity. Take extra care to make sure the field paths you reference actually exist in the bodies your database sends.

A useful workflow: before you start writing the spec, fire each webhook source once against an empty processor. The Spec Builder's variable autocomplete reads field paths from past executions, so once a webhook has fired once, every field your database actually sends autocompletes correctly even though the source has no static schema.

Lead Processor

The webhook fires on each new lead row. The payload is the lead — a unique id, the agent who handled it, the date it came in, and whatever else the database has on it.

Spec walkthrough

  1. upsert_agent_external_identity with:

    • external_idpayload.setter_user_id
    • external_namepayload.setter_user_name
  2. upsert_activity for the lead:

    • external_id — the literal "lead | " joined with payload.id via concat. The "lead | " prefix matters: row IDs are unique within their table but not across tables — lead 42 and appointment 42 are different rows that happen to share a numeric ID. Without the prefix, both would land at the same activity record and overwrite each other. Every processor in this example prefixes its external_id with the record type for the same reason.
    • activity_type — literal "Lead".
    • agent_external_identity — from step 1.
    • momentpayload.created_at wrapped in parse_datetime.

That's it. The simplest of the three processors — every lead becomes one activity attributed to the setter who handled it.

Appointment Processor

The webhook fires when an appointment row is created or updated (status change, reschedule, outcome). The payload contains the appointment ID, the customer, the setter who booked it, the closer assigned to run it, the booked date, the event date, and a status that captures both timing (scheduled, confirmed) and outcome (held, no_show, cancelled, won, lost).

This processor produces three activities per appointment row, demonstrating the dual-attribution pattern.

Spec walkthrough

  1. upsert_agent_external_identity for the setter, with:

    • external_idpayload.setter_user_id
    • external_namepayload.setter_user_name
  2. upsert_agent_external_identity for the closer, with:

    • external_idpayload.closer_user_id
    • external_namepayload.closer_user_name

    Two distinct identities, captured under different return_as names (e.g. setter and closer) so steps 4–6 can each pick the right one.

  3. define_variable named status_suffix, set to the literal " | " joined with payload.status via concat — producing values like " | scheduled", " | won". Captured once here so all three activities below can reuse it without recomposing.

  4. upsert_activity for the booking, attributed to the setter. Prefixed with "appointment | " so the appointment row's IDs don't collide with the lead's:

    • external_id — the literal "appointment | " joined with payload.id and " | created" via concat. Produces "appointment | 42 | created".
    • activity_type — the literal "Appointment Created" joined with status_suffix via concat. Produces types like "Appointment Created | scheduled", "Appointment Created | confirmed", "Appointment Created | won" — same activity, type updates as status updates.
    • agent_external_identity — the setter from step 1.
    • momentpayload.created_at wrapped in parse_datetime. The booking moment.
  5. upsert_activity for the appointment-from-the-setter's-perspective, attributed to the setter:

    • external_id — the literal "appointment | " joined with payload.id and " | setter" via concat.
    • activity_type — the literal "Appointment Setter" joined with status_suffix via concat.
    • agent_external_identity — the setter from step 1.
    • momentpayload.event_at wrapped in parse_datetime. The appointment moment, not the booking moment.
  6. upsert_activity for the appointment-from-the-closer's-perspective, attributed to the closer:

    • external_id — the literal "appointment | " joined with payload.id and " | closer" via concat.
    • activity_type — the literal "Appointment Closer" joined with status_suffix via concat.
    • agent_external_identity — the closer from step 2.
    • momentpayload.event_at wrapped in parse_datetime.

Dual attribution

Steps 5 and 6 illustrate the dual-attribution pattern. The same source record (one appointment row) produces two activities with different external_id suffixes ( | setter, | closer) and different agents — so when SalesDash counts "appointments held" per agent, the setter sees their appointments alongside the closer's, and a held appointment counts for both. Reach for this pattern any time a single business event has more than one person who deserves credit for it.

The setter additionally gets a third activity (step 4, | created) — the moment the booking happened, distinct from the moment the appointment took place. That separates "I booked an appointment today" from "an appointment I booked happened today" — useful when the metrics are time-windowed and you want to attribute effort at the moment it happened rather than retroactively.

Deal Processor

The webhook fires when a deal row is created or updated. The payload contains the deal ID, the setter who originally booked the appointment that led to the deal, the closer who closed it, the deal value, the date the deal was accepted, and a status (pending, accepted, cancelled, refunded).

Two activities per deal row — one for the setter (assist credit), one for the closer (close credit).

Spec walkthrough

  1. upsert_agent_external_identity for the setter — same pattern as the Appointment Processor's step 1.

  2. upsert_agent_external_identity for the closer — same pattern as step 2.

  3. upsert_activity for the setter's assist credit:

    • external_id — the literal "deal | " joined with payload.id and " | setter" via concat.
    • activity_type — the literal "Deal Setter | " joined with payload.status via concat.
    • agent_external_identity — the setter.
    • momentpayload.accepted_at wrapped in parse_datetime.
    • valuepayload.value.
  4. upsert_activity for the closer's close credit:

    • external_id — the literal "deal | " joined with payload.id and " | closer" via concat.
    • activity_type — the literal "Deal Closer | " joined with payload.status via concat.
    • agent_external_identity — the closer.
    • momentpayload.accepted_at wrapped in parse_datetime.
    • valuepayload.value.

Both activities carry the same value — the deal's value. When the team builds revenue metrics, they choose whose attribution wins: counting "Deal Closer | accepted" gives revenue per closer; counting "Deal Setter | accepted" gives revenue per setter (the same euros, different attribution).

How the three processors combine

All three processors use the same external_id scheme for agents (the database's setter_user_id and closer_user_id), so an agent who acts as a setter on one row and a closer on another ends up with one identity record receiving activities from all three processors. Linked once in SalesDash, that identity sees the agent's full funnel — leads handled, appointments booked, appointments held, deals closed.

The three webhooks don't need to fire in order, and they don't need to know about each other. Each processor handles its own row type independently. As long as the database fires a webhook for every relevant row update, the activities reflect the latest state.

Variations to consider

When adapting this for your own integration:

  • Missing setters or closers. If your database sometimes has no setter or no closer on a row (a lead that came in via self-service, an appointment that got reassigned), the agent upsert will fail because external_id is required. Two options: (a) skip the agent upsert and the dependent activity entirely with an if_else gate on is_empty, or (b) fall back to a placeholder external_id like "[unassigned]" so a marker identity gets created instead and the activity still lands. The placeholder approach keeps the activity counts honest; the skip approach keeps the agent roster cleaner.

  • More record types. Marketing funnels often have stages this example doesn't cover — a "qualified lead" step before booking, a "proposal sent" step before closing. Each is its own webhook source and processor, structured the same way.

  • Different status values per record type. Each row type might have its own status enum — leads use qualified/unqualified/junk, appointments use timing+outcome, deals use payment-state values. Build the activity_type by joining a record-type-specific literal ("Lead | ", "Appointment Setter | ", "Deal Closer | ") with the row's status field via concat. This way the metric definitions own which statuses count where, and changes don't require re-running the spec against historical rows.

  • Initial backfill. Webhooks only fire going forward; they don't replay history. The simplest way to get existing rows into SalesDash is to add a temporary column to each table and populate it — that fires an UPDATE event on every row, which triggers the row-update webhook for each one. The processor then runs against the full backlog as if it had been delivered live. Drop the column afterwards. Source-side plumbing only, no spec change.