# The webhook

> The top-level structure shared by every delivery, and the shared object shapes inside data.

---
title: The webhook
description: The top-level structure shared by every delivery, and the shared object shapes inside data.
---

Every event shares one top-level structure. Only `event` and `data` vary. Every `id` in the payload is a stable UUID that is safe to store and reference. All timestamps are ISO 8601 in UTC and end in `Z`.

We announce every payload change in advance. New fields are additive and never change the `webhookVersion`; build your integration to ignore fields it does not recognize, so additions never affect it. A field is only removed or renamed with a new `webhookVersion`, announced ahead of time with a migration period.

<ApiSection>
<div>
<Attributes title="Attributes">
  <Attribute name="id" type="uuid">
    Unique delivery id, stable across retries. Use it to deduplicate.
  </Attribute>
  <Attribute
    name="event"
    type="string"
    values={["form_submission", "form_step", "completed_lead", "partial_lead", "booked_call"]}
  >
    The event type.
  </Attribute>
  <Attribute name="webhookVersion" type="string">
    The payload version this webhook is pinned to. You control it from the webhook's settings.
  </Attribute>
  <Attribute name="createdAtUtc" type="timestamp">
    When the event happened, for example the moment the form was submitted.
    If a delivery is retried, this keeps its original value, so it is not the
    time the HTTP request was sent.
  </Attribute>
  <Attribute name="agency" type="object">
    The agency the subaccount belongs to.
    <ChildAttributes>
      <Attribute name="id" type="uuid" />
      <Attribute name="name" type="string" />
    </ChildAttributes>
  </Attribute>
  <Attribute name="subaccount" type="object">
    The subaccount that owns the webhook and the form.
    <ChildAttributes>
      <Attribute name="id" type="uuid" />
      <Attribute name="name" type="string" />
    </ChildAttributes>
  </Attribute>
  <Attribute name="form" type="object">
    The form the event happened on.
    <ChildAttributes>
      <Attribute name="id" type="uuid" />
      <Attribute name="name" type="string" />
      <Attribute name="url" type="string">Link to the live form.</Attribute>
    </ChildAttributes>
  </Attribute>
  <Attribute name="data" type="object">
    Event-specific content. See the page for each event.
  </Attribute>
</Attributes>
</div>
<ExamplePanel title="The webhook">

```json
{
  "id": "4b8e2f6a-9c3d-4e7b-a1f5-8d2c6b9e0a47",
  "event": "completed_lead",
  "webhookVersion": "2026-07-01",
  "createdAtUtc": "2026-07-28T14:32:11.482Z",
  "agency": {
    "id": "c1a2b3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
    "name": "Acme Marketing"
  },
  "subaccount": {
    "id": "7f3e9d21-4b8a-4c5e-9f60-2d8b1a7c3e54",
    "name": "Acme Fitness Client"
  },
  "form": {
    "id": "e8b4c672-9a1d-4f3b-b7c8-5d2e9f0a1b3c",
    "name": "Fitness Coaching Application",
    "url": "https://forms.roasform.com/fitness-coaching"
  },
  "data": {}
}
```

</ExamplePanel>
</ApiSection>

## Request headers

| Header | Example | Meaning |
| --- | --- | --- |
| `Content-Type` | `application/json` | Body is JSON. |
| `User-Agent` | `ROASForm-Webhooks/1` | Identifies ROASForm. |
| `X-ROASForm-Event` | `completed_lead` | The event type. |
| `X-ROASForm-Delivery` | `4b8e2f6a-…` | Same as the webhook's `id`. |
| `X-ROASForm-Attempt` | `2` | 1 on the first try, higher on retries. |
| `X-ROASForm-Signature` | `t=1753712000,v1=5f8a…` | HMAC signature. See [Verifying signatures](/docs/webhooks/signatures). |

## Shared objects

These shapes appear inside `data` across events. A field that has no value is `null`, never omitted.

### lead

The lead is **the person, not the run**: leads are matched across submissions by
email or phone within the subaccount. Contact fields captured by this submission
are merged into the saved record before delivery, so the object arrives as
complete as we know the person — a returning visitor's `company` from an earlier
form shows up even if this form never asked for it, `tags` accumulate across
runs, and `createdAtUtc` is when the person was *first* seen. For what this
specific run captured, read `submission.answers`.

<ApiSection>
<div>
<Attributes>
  <Attribute name="id" type="uuid">Lead public id.</Attribute>
  <Attribute name="name" type="nullable string" />
  <Attribute name="email" type="nullable string" />
  <Attribute name="phone" type="nullable string">E.164 where available.</Attribute>
  <Attribute name="company" type="nullable string" />
  <Attribute name="location" type="nullable string" />
  <Attribute name="tags" type="string[]">The lead's accumulated tags.</Attribute>
  <Attribute name="timezone" type="nullable string">IANA zone, e.g. `America/Chicago`.</Attribute>
  <Attribute name="country" type="nullable string">ISO country code.</Attribute>
  <Attribute name="createdAtUtc" type="timestamp">When the lead record was created.</Attribute>
</Attributes>
</div>
<ExamplePanel title="lead">

```json
{
  "id": "3d9f7a25-6c1b-4e8d-a2f4-8b0c5d7e9f13",
  "name": "Jane Doe",
  "email": "jane@example.com",
  "phone": "+15555550100",
  "company": "Doe Fitness LLC",
  "location": "Austin, TX",
  "tags": ["hot-lead"],
  "timezone": "America/Chicago",
  "country": "US",
  "createdAtUtc": "2026-07-28T14:29:03.114Z"
}
```

</ExamplePanel>
</ApiSection>

### submission

<ApiSection>
<div>
<Attributes>
  <Attribute name="id" type="uuid" />
  <Attribute name="status" type="string" values={["partial", "complete"]} />
  <Attribute
    name="qualification"
    type="string"
    values={["none", "qualified", "unqualified"]}
  >
    The qualification outcome of this submission, set by the ending reached;
    `none` while partial.
  </Attribute>
  <Attribute name="score" type="number">Score at delivery time.</Attribute>
  <Attribute name="startedAtUtc" type="timestamp" />
  <Attribute name="completedAtUtc" type="nullable timestamp">`null` while partial.</Attribute>
  <Attribute name="ending" type="nullable object">
    Which ending screen the visitor reached — the exit path's identity.
    Screen content and redirect URLs are builder configuration and are not
    delivered. `null` while partial. If the ending was later deleted in the
    builder, `id` is `null` and `name`/`type` come from the values saved with
    the submission — so route on `type`, match exact endings on `id`, and treat
    `id: null` as an ending that no longer exists in the builder.
    <ChildAttributes>
      <Attribute name="id" type="nullable uuid" />
      <Attribute name="name" type="string">The ending's name in the builder.</Attribute>
      <Attribute name="type" type="string" values={["calendar", "redirect", "success"]} />
    </ChildAttributes>
  </Attribute>
  <Attribute name="lastSeenElement" type="nullable object">
    The question the visitor was on most recently, in the same shape as an
    `answers` entry. For abandoned submissions this is where they stopped;
    `value`, `values`, and `score` are `null` when that question was never
    answered.
  </Attribute>
  <Attribute name="answers" type="array">
    One entry per answered question, in the order each question was *first*
    answered (the visitor's path through the form, which can differ from the
    builder order when logic jumps). An answer edited later keeps its original
    position. See the answer shape below.
  </Attribute>
  <Attribute name="appliedTags" type="string[]">
    Tags this submission applied.
  </Attribute>
  <Attribute name="utmData" type="nullable object">
    UTM parameters captured with the submission.
  </Attribute>
  <Attribute name="ipAddress" type="nullable string">
    The visitor's IP address.
  </Attribute>
  <Attribute name="userAgent" type="nullable string">
    The visitor's browser user agent.
  </Attribute>
</Attributes>

### answers[]

<Attributes>
  <Attribute name="elementId" type="uuid">The question element.</Attribute>
  <Attribute name="label" type="string">Question text as answered.</Attribute>
  <Attribute
    name="type"
    type="string"
    values={[
      "name", "email", "phone", "address", "website",
      "text", "long_text", "short_text",
      "multiple_choice", "single_choice", "image_choice", "dropdown", "yes_no",
      "opinion_scale", "rating", "number", "date", "file_upload"
    ]}
  >
    The question's element type. Forms created long ago may also send the legacy
    types `single_select` and `multi_select`.
  </Attribute>
  <Attribute name="value" type="nullable string">
    Single-value answers. `null` for multi-select answers.
  </Attribute>
  <Attribute name="values" type="nullable string[]">
    Multi-select answers. `null` otherwise.
  </Attribute>
  <Attribute name="score" type="nullable number">`null` for unscored questions.</Attribute>
</Attributes>
</div>
<ExamplePanel title="submission">

```json
{
  "id": "5a2c8e94-1f7d-4b3a-9e6c-4d8f0b2a6c71",
  "status": "complete",
  "qualification": "qualified",
  "score": 40,
  "startedAtUtc": "2026-07-28T14:26:40.207Z",
  "completedAtUtc": "2026-07-28T14:32:11.482Z",
  "ending": {
    "id": "9c4b7d13-8e2a-4f6c-b1d9-7a3e5c8f2d46",
    "name": "Qualified - Book a call",
    "type": "calendar"
  },
  "lastSeenElement": {
    "elementId": "a1d4c7f2-9b3e-4c6a-8d2f-5e9b1c4a7d30",
    "label": "What is your email?",
    "type": "email",
    "value": "jane@example.com",
    "values": null,
    "score": null
  },
  "answers": [
    {
      "elementId": "b7e2f9a4-3c8d-4a1e-9f5b-6d0c2e8a4f17",
      "label": "What is your monthly budget?",
      "type": "single_choice",
      "value": "$2,000 - $5,000",
      "values": null,
      "score": 30
    },
    {
      "elementId": "d2c8f5b1-7e4a-4d9c-a6f3-9b0e5d8c2a64",
      "label": "What are your goals?",
      "type": "multiple_choice",
      "value": null,
      "values": ["Lose weight", "Build muscle"],
      "score": 10
    }
  ],
  "appliedTags": ["hot-lead"],
  "utmData": {
    "utm_source": "facebook",
    "utm_medium": "cpc",
    "utm_campaign": "summer-2026"
  },
  "ipAddress": "203.0.113.42",
  "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15"
}
```

</ExamplePanel>
</ApiSection>

## Data freshness

Deliveries are built by reading the database **at send time**, not from a frozen
snapshot — so most payloads (delivered within seconds of the event) are
effectively point-in-time, but a retried delivery reflects anything that changed
in between. What that means per field:

| Frozen at the event | Live at delivery time |
| --- | --- |
| `id`, `createdAtUtc`, `webhookVersion` | the whole `lead` object (current state of the person) |
| `submission.startedAtUtc` / `completedAtUtc`, `qualification`, `utmData`, `ipAddress`, `userAgent` | `form.name` / `form.url`, `agency` and `subaccount` names |
| answer texts and values (saved as answered — later question renames don't rewrite them) | `ending.name` (renames show; deletion falls back to the saved snapshot) |
| the ending's `type` (survives even deletion via the snapshot) | `appointment` (a reschedule before delivery shows the new time) |

On events for still-partial submissions (`form_step`, `partial_lead`), a retried
delivery can also reflect further progress the visitor made in the meantime —
treat every delivery as a fresh snapshot keyed by `submission.id`.
