> ## Documentation Index
> Fetch the complete documentation index at: https://docs.api.aura-app.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Choose a webhook setup path, retrieve lead form answers, and verify Partner API deliveries.

# Webhooks

Aura offers two webhook setup paths. Choose the one that supports the events
your integration needs.

| Setup | Events and payloads | Where to subscribe |
| - | - | - |
| Partner API webhooks | The [Partner API event catalog](#partner-api-event-catalog), including `lead.created`, call events and payment events. Deliveries use the `event`, `created_at`, `organization_id` and `data` wrapper below. | [`POST /v1/webhooks`](https://docs.api.aura-app.ai/openapi/v1/webhooks/post) with a Partner API key. |
| Settings and Zapier lifecycle events | Lead progress events, including `lead.interested` and `lead.qualified`. Their payloads include the lead and booking intent identifiers described below. | Settings > Webhooks, or the corresponding Zapier trigger. |

`lead.interested` and `lead.qualified` are not subscription options on the
Partner API's `/v1/webhooks` endpoint. A Partner API key lets you retrieve the
lead's intent and answers through [Calls](https://docs.api.aura-app.ai/openapi/v1/calls/get.md#intent-calls-and-form-answers),
regardless of which webhook setup path you use.

## Lead lifecycle events in Settings and Zapier

A lead is the person. A booking intent records their progress before they book
a meeting. Each has its own ID. A lead can finish the form and qualify without
booking a meeting.

| Event | Intent ID and form answers |
| - | - |
| `lead.interested` | Includes `lead_id`, `intent_id` and `form_answers` available when the event fires. It can fire before the form is complete. |
| `lead.qualified` | Includes `lead_id`, `intent_id` and `form_answers` from the submitted form. `call_id` can be null before booking. |
| `lead.created` | Uses a separate lead envelope. It does not include the intent ID or custom form answers. |

Use `intent_id` to request
[`GET /v1/calls/{id}/answers`](https://docs.api.aura-app.ai/openapi/v1/calls/id/answers/get). The response
contains `data.answers`, with stable `field_id` values, question labels and
answers. If you only have a lead ID, [list that lead's intent calls](https://docs.api.aura-app.ai/openapi/v1/calls/get.md#intent-calls-and-form-answers)
first. `GET /v1/leads/{id}` does not expose the intent ID or its answers.

The lifecycle payload's `form_answers` array uses the same answer fields.
Ordinary question-label fields remain in the payload for existing integrations.
Use `form_answers` when you need stable field IDs or a question label matches a
reserved key such as `intent_id`.

Webhooks capture a point in time. Retrieve the intent again when you need
answers entered after the event fired.

## Partner API webhook delivery

Partner API webhooks send signed HTTPS `POST` requests to your endpoint.
Verify the signature, process the event and return a `2xx` response. The setup,
signature format and retry policy below describe Partner API webhook deliveries.

## Quick start

1. Create a subscription with [`POST /v1/webhooks`](https://docs.api.aura-app.ai/openapi/v1/webhooks/post).
   Choose an [event type](#partner-api-event-catalog) and your HTTPS endpoint URL.
2. Aura shows you a **signing secret once**. Copy it into a secure store on
   your side, such as an environment variable or secret manager. You cannot
   retrieve it again. Rotate it if you lose it.
3. In your endpoint, read the **raw request body**, then verify the signature
   using the secret. See [verifying signatures](#verifying-signatures) below.
4. Return a `2xx` response **within 10 seconds**. See the [retry policy](#retry-policy)
   for responses that Aura retries.
5. **Deduplicate** by your subscription ID and the payload's `idempotency_key`.
   The same event can arrive more than once. Retries keep the same key.

## Partner API event catalog

| Event | Fires when |
| - | - |
| `lead.created` | A new lead is created in Aura (via booking form, API, or integration). |
| `lead.updated` | A lead's profile fields change (name, email, company, etc.). |
| `lead.status_changed` | No longer offered. Sent only when a lead is archived through the API. |
| `call.booked` | A new call is scheduled through a booking link. |
| `call.updated` | A call's metadata changes (notes, assigned closer, outcome tags). |
| `call.started` | A call begins (detected via Nylas meeting state or closer check-in). |
| `call.completed` | A call finishes successfully with a recorded outcome. |
| `call.canceled` | A call is canceled before it takes place. |
| `call.rescheduled` | A call is moved to a new time. |
| `call.no_show` | A call is marked as a no-show after the scheduled start time. |
| `call.closer_reassigned` | A call's assigned closer changes (host swap / commission reassignment). Carries `previous_closer_*` + `new_closer_*`. |
| `call.analysis_completed` | The AI review of a call finishes. Carries the headline scores, verdict and primary objection, plus `analysis_url` for the full review. Arrives minutes to hours after `call.completed` for the same call. |
| `payment.succeeded` | A payment is successfully captured for a lead or call. |
| `payment.failed` | A payment attempt fails (declined card, insufficient funds, etc.). |
| `payment.refunded` | A previously captured payment is fully or partially refunded. |

## Payload shape

Every delivery uses the same top-level wrapper:

```json theme={null}
{
  "event": "call.booked",
  "created_at": "2026-04-15T17:52:10.000Z",
  "organization_id": "org_xxx",
  "idempotency_key": "cfd10484-f4db-4d21-8b4d-b5bf453ca046",
  "data": { /* entity-specific payload */ }
}
```

`data` carries the full entity with attribution fields (`utm_source`,
`utm_medium`, `utm_campaign`, `booking_link_id`, `referral`) so you don't
have to call the API to figure out where a lead came from.

### Example: `call.booked`

```json theme={null}
{
  "event": "call.booked",
  "created_at": "2026-04-15T17:52:10.000Z",
  "organization_id": "org_xxx",
  "idempotency_key": "cfd10484-f4db-4d21-8b4d-b5bf453ca046",
  "data": {
    "id": "call_abc123",
    "lead_id": "lead_xyz",
    "closer_id": "user_456",
    "booking_link_id": "bl_789",
    "scheduled_at": "2026-04-16T15:00:00.000Z",
    "duration": 30,
    "status": "scheduled",
    "form_answers": [
      {
        "field_id": "budget",
        "label": "What is your budget?",
        "value": "$10k",
        "type": "select"
      }
    ],
    "conferencing_url": "https://meet.google.com/...",
    "utm_source": "google",
    "utm_medium": "cpc",
    "utm_campaign": "spring-2026",
    "utm_term": null,
    "utm_content": null,
    "referral": null,
    "attribution": {
      "utm_source": "google",
      "utm_medium": "cpc",
      "utm_campaign": "spring-2026",
      "attribution_platform": "google",
      "am_wuid": "wuid_4o251b0b130v3z6d5q0n"
    },
    "guest_rsvp_status": null,
    "guest_rsvp_confirmed_at": null,
    "created_at": "2026-04-15T17:52:10.000Z",
    "updated_at": "2026-04-15T17:52:10.000Z"
  }
}
```

### Form answers on call events

Partner call event payloads include `data.form_answers`, the same normalized
answers returned by [Calls](https://docs.api.aura-app.ai/openapi/v1/calls/get.md#intent-calls-and-form-answers).
These answers belong to that call or booking intent. Each entry includes
`field_id`, `label` and `value`, with an optional `type`. Values can be a string,
an array of strings or a boolean. Basic contact and internal fields are excluded.

New call event deliveries include an array, with `[]` when there are no custom
answers. Historical payloads may omit `form_answers`. Settings and Zapier lead
lifecycle events use the `form_answers` array described above; historical
lifecycle payloads may also omit it. Retrieve the call when you need its current
answers.

### The `attribution` map

Every lead and call payload also carries `attribution`: one object holding
every attribution parameter Aura kept for that record. It includes the known
fields above and any other parameter your landing page passed, such as a
commerce or affiliate identifier (`am_wuid` in the example). Use it when you
need a value Aura has no dedicated field for.

* **Values are strings.** Aura only includes parameters that have a value.
  When nothing was captured, `attribution` is `null`.
* **Booking events keep what was captured at booking.** A call's attribution
  comes from the booking itself. If the lead's details change later, the
  attribution on that call does not. Where the booking did not capture a
  value, Aura fills it from the lead.
* **A call reports the same map everywhere.** Webhooks, the REST API,
  GraphQL, and Zapier all return the same `attribution` for a given call.
* **Aura returns what your links pass.** Every parameter in the booking URL
  is kept and returned under its own name, whatever it is called. If a link
  carries an email address or a token, it comes back in `attribution`, so
  only put in your links what you are happy to receive in your webhooks. The
  only parameters left out are the embed's own display settings, such as
  `theme`.
* **There are limits.** Up to 25 parameters beyond the known fields are kept.
  Parameter names can be up to 100 characters and values up to 500. Longer
  values are shortened.
* **New keys can appear at any time.** Read the keys you need and ignore the
  rest. A new key in `attribution` is not a breaking change.

The flat fields (`utm_source`, `utm_medium`, and the others) stay in every
payload, so existing integrations keep working without changes.

### Consent on lead events

Every lead payload carries `consent`: each consent box the person saw and
what they chose. This includes your own boxes on the booking form. Aura's box
for appointment texts is in the list once the person has seen it, or replied
STOP or START. Use it to see which phone numbers agreed to get
texts before you send any. Phone companies and CRMs often ask for this
before they let you send texts.

```json theme={null}
"consent": [
  {
    "id": "6f1b2c3d-0000-4000-8000-000000000001",
    "kind": "custom",
    "label": "I agree to receive appointment reminder texts from Acme Inc.",
    "disclosure": null,
    "agreed": true,
    "state": "granted",
    "at": "2026-10-08T10:00:00.000Z",
    "granted_at": null,
    "phone": "+15551234567",
    "source": "booking_form",
    "links": [{ "text": "SMS Terms", "url": "https://acme.com/sms-terms" }]
  }
]
```

* **`agreed` is true only while they agree, for the lead's phone now.** It
  is false if they never ticked the box, or took it back later. It is also
  false if they agreed for a different phone number.
* **`phone` is the number they agreed for.** If it is not the lead's phone
  now, `agreed` is false. It is `null` for a choice made before Aura kept
  the number, and that choice counts for the lead's phone now.
* **`state` says what happened.** `granted` means they ticked the box.
  `withdrawn` means they agreed, then took it back, by unticking the box or
  replying STOP. `declined` means they finished the form without ticking
  the box.
* **`source` says how the choice reached Aura**: `booking_form`,
  `sms_reply_stop`, `sms_reply_start`, `carrier_block` (their phone company
  blocked texts), `admin` (someone on your team changed it) or `import`. It
  is `null` when Aura did not record how.
* **`at` is when they made that choice.** For a `withdrawn` box,
  `granted_at` is when they first agreed. It is `null` for every other state.
* **`label`, `disclosure` and `links` are what the person saw**: the words
  on the box, the fine print beside it (Aura's box for appointment texts has
  one), and the links next to it.
* **Match a box on `id`, not on `label`.** `id` stays the same when your team
  changes the wording: it is the box's own id for a box you added, `aura_sms`
  for Aura's box for appointment texts, and `data_consent` for the
  data-processing box. `kind` says which of the three it is.
* **An empty list means no consent is on file.** That is not the same as
  `declined`, but it is still not a yes. Treat it as no consent.
* **A box they did not tick is only recorded once they finish the form.**
  Someone who leaves halfway has no `declined` entry yet.
* **Consent can change after the webhook is sent.** For example, the person
  may reply STOP. Read the lead again with `GET /v1/leads/:id` right before
  you text them.
* **The phone number is sent either way.** Aura never hides it. Check
  `consent` before you text.

The REST API returns the same `consent` on every lead, and GraphQL returns it
as `Lead.consent` (with `grantedAt`).

### Consent by type

Every lead payload also carries `consent_by_type`: one answer for each kind of
message, so you can skip people who did not agree without reading each box's
wording. Your team chooses which consent box asks about each kind, in
Settings.

```json theme={null}
"consent_by_type": {
  "sms_booking": { "state": "yes", "at": "2026-10-08T10:00:00.000Z", "source": "box", "id": "6f1b2c3d-0000-4000-8000-000000000001" },
  "sms_marketing": { "state": "no", "at": "2026-10-08T10:00:00.000Z", "source": "box", "id": "6f1b2c3d-0000-4000-8000-000000000002" },
  "email_marketing": { "state": "not_asked", "at": null, "source": null, "id": null },
  "data_processing": { "state": "not_asked", "at": null, "source": null, "id": null }
}
```

* **The kinds are** `sms_booking` (texts about their booked call),
  `sms_marketing` (texts with offers), `email_marketing` (emails with offers
  and newsletters) and `data_processing` (agreeing to your privacy policy, in
  a box your team added).
* **`state` is `yes`, `no` or `not_asked`.** `no` means they left the box
  unticked or took it back (for example, by replying STOP). For texts, `no`
  also means they agreed for a different phone number. `not_asked` means
  nothing they answered asks about it yet. Treat it as no consent.
* **Booking texts can come from Aura's own box** for appointment texts, even
  before your boxes have a kind.
* **`source` says where the answer came from**: `box` (one of your boxes) or
  `aura_sms_opt_in` (Aura's own box for appointment texts, which a STOP reply
  turns off). `id` is the box, the same `id` as in `consent`. Both are `null`
  when not asked, and so is `at`.
* **Every surface sends the same answers for the same lead**: the REST API,
  GraphQL (`Lead.consentByType`), the lead webhooks, Zapier and your CRM.
* **If Aura cannot check your booking page's settings at that moment**, for
  example during an outage, Booking texts reads Aura's own box first for a
  lead who answered it. That way a STOP reply never reads as `yes`.

## Where a lead stands

Every lead payload also carries `pipeline_stage`: the lead's column on the
Opportunities board when the event was sent, such as `booked`, `confirmed`,
`no_show` or `won`. Aura works it out from the lead's calls, the same way the
board does. One exception: for up to an hour after you change a
disqualification rule, the two can differ, because the board applies the new
rule on its next hourly sync.

Use `pipeline_stage`, not `status`, to show where a lead stands. `status` is a
stored value that can lag behind. When a guest accepts the invite,
`pipeline_stage` says `confirmed` at once, while `status` can still say
`booked` until Aura's status sync updates it. It is `null` only if Aura could
not read the lead's calls.

The REST API returns the same `pipeline_stage` on every lead, and GraphQL
returns it as `Lead.pipelineStage`. It is the same value as `stage` on the
lead's opportunities. In both, it is `null` only if Aura could not read the
lead's calls.

## Verifying signatures

Aura signs every request with two headers so you can pick the verification
scheme you prefer:

### `X-Aura-Signature-V1` (recommended)

Stripe-style format:

```
X-Aura-Signature-V1: t=1744739530123,v1=8f3a1b...
```

* `t=<unix_ms>` contains the delivery timestamp. You **must** reject requests whose
  timestamp drifts more than **5 minutes** from your clock, or an attacker
  who captures one delivery could replay it indefinitely.
* `v1=<sha256_hex>` contains HMAC-SHA256 of the string `${t}.${rawBody}`, hex-encoded.

During the 24-hour secret rotation grace window, the header will carry **two**
`v1=` segments. One uses the current secret and the other uses the previous
secret. Accept the request if **either** verifies. That lets you rotate secrets
without dropping in-flight deliveries.

### `X-Aura-Signature` (legacy, deprecated)

Bare hex digest of `HMAC-SHA256(rawBody)`:

```
X-Aura-Signature: 8f3a1b...
```

During rotation it becomes `current,previous`. Split on `,` and accept if
either matches. This header is kept for backwards compatibility and offers
no replay protection. New integrations should verify `X-Aura-Signature-V1`.

### `X-Zapier-Signature` (deprecated, do not rely on)

Subscriptions created through the booking funnel also receive
`X-Zapier-Signature`. Its value is the **subscription secret itself**, echoed
back to you.

It is not a signature. It proves nothing about the body, and it discloses a
working credential on every delivery: any proxy, APM, or request log between
Aura and your handler captures a secret that can be used to forge deliveries.

**Every one of these deliveries now also carries `X-Aura-Signature-V1`.**
Verify that instead. The raw-secret header will be removed once delivery
telemetry shows subscribers have migrated; comparing it to your stored secret
is not a safe substitute in the meantime.

### Node.js signature verification

```ts theme={null}
import crypto from "node:crypto";

const FRESHNESS_MS = 5 * 60 * 1000;

export function verifyAuraWebhook(
  rawBody: string,      // MUST be the raw request bytes, not JSON.parse'd
  headerValue: string,  // X-Aura-Signature-V1
  secret: string,
): boolean {
  if (!headerValue) return false;

  // Parse "t=<ms>,v1=<sig>[,v1=<sig>]"
  const pairs = headerValue.split(",").map((s) => s.trim());
  let timestamp: number | null = null;
  const signatures: string[] = [];
  for (const pair of pairs) {
    const eq = pair.indexOf("=");
    if (eq < 0) continue;
    const [key, value] = [pair.slice(0, eq), pair.slice(eq + 1)];
    if (key === "t") timestamp = Number.parseInt(value, 10);
    else if (key === "v1") signatures.push(value);
  }
  if (timestamp === null || signatures.length === 0) return false;

  // Reject requests outside the freshness window.
  if (Math.abs(Date.now() - timestamp) > FRESHNESS_MS) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  return signatures.some((sig) => {
    if (sig.length !== expected.length) return false;
    return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  });
}
```

Then in your handler (Next.js / Hono / Express all the same pattern):

```ts theme={null}
// Next.js App Router example
export async function POST(request: Request) {
  const rawBody = await request.text(); // critical: NOT request.json()
  const header = request.headers.get("x-aura-signature-v1");
  const valid = verifyAuraWebhook(rawBody, header ?? "", process.env.AURA_WEBHOOK_SECRET!);
  if (!valid) return new Response("Invalid signature", { status: 401 });

  const event = JSON.parse(rawBody);
  // Deduplicate by your subscription ID and event.idempotency_key, then process.
  return new Response("ok", { status: 200 });
}
```

### Python signature verification

```python theme={null}
import hmac, hashlib, time

FRESHNESS_MS = 5 * 60 * 1000

def verify_aura_webhook(raw_body: bytes, header_value: str, secret: str) -> bool:
    if not header_value:
        return False

    timestamp: int | None = None
    signatures: list[str] = []
    for pair in header_value.split(","):
        if "=" not in pair:
            continue
        key, value = pair.split("=", 1)
        key, value = key.strip(), value.strip()
        if key == "t":
            try:
                timestamp = int(value)
            except ValueError:
                return False
        elif key == "v1":
            signatures.append(value)

    if timestamp is None or not signatures:
        return False

    now_ms = int(time.time() * 1000)
    if abs(now_ms - timestamp) > FRESHNESS_MS:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return any(hmac.compare_digest(sig, expected) for sig in signatures)
```

### Why the raw body?

HMAC must cover the **exact bytes Aura signed**. If you `JSON.parse` and then
re-stringify, key ordering or whitespace may drift and your signature will
silently fail to verify. Always capture the raw body first (`await request.text()`
in modern Node, `request.body` as a string/buffer elsewhere), verify, *then*
parse.

## Retry policy

If your endpoint doesn't return a 2xx within **10 seconds**, Aura retries
with exponential backoff.

| Outcome | Retried? |
| - | - |
| `2xx` | No, success |
| `5xx` (server error) | Yes |
| `408` Request Timeout | Yes |
| `429` Too Many Requests | Yes |
| `4xx` other (`400`/`401`/`403`/`404`/...) | No, permanent |
| Network error, DNS failure, TLS error | Yes |
| Timeout after 10s | Yes |

Retries happen at `1s`, `5s`, `15s` after the first failure (3 retries max,
4 attempts total). After the last retry, the delivery is marked failed in
`webhook_delivery_logs`. There is no automatic re-send. Fix your endpoint
and use [`POST /v1/webhooks/test`](https://docs.api.aura-app.ai/openapi/v1/webhooks/test/post) to send a fresh
test delivery. To recover the missed events themselves, re-fetch the affected
records via the REST API (e.g. `GET /v1/calls`, `GET /v1/payments`) and
reconcile against your own store.

### Permanent 4xx responses are NOT retried

Aura treats most `4xx` responses as permanent failures and does not retry.
Returning `404` because your endpoint path is wrong, or `401` because your
signature check rejected the request, tells Aura the delivery can never
succeed. Retrying waits another 21 seconds and creates noise in your logs.
If you want a retry, return `500` or `503`.

## Security model

* **HTTPS only.** Aura refuses to create subscriptions for non-HTTPS URLs.
* **SSRF-guarded target URLs.** Aura rejects subscriptions pointing at
  `localhost`, any RFC1918 private range (`10/8`, `172.16/12`, `192.168/16`),
  link-local addresses in `169.254/16`, including AWS IMDS, GCP and Azure
  metadata endpoints, carrier-grade NAT, multicast, and broadcast ranges. DNS is
  re-resolved at delivery time to defend against DNS-rebinding attacks.
* **HMAC-SHA256.** Signing secrets are 32 bytes of `crypto.randomBytes`
  (256 bits of entropy). Compare signatures with a timing-safe comparison
  (`crypto.timingSafeEqual` in Node, `hmac.compare_digest` in Python).
* **One-time secret reveal.** The signing secret is shown once at
  subscription create time and once at rotation time. If you lose it,
  rotate to generate a new one.
* **Per-host concurrency limit.** Aura caps outbound deliveries to a
  single partner hostname at 5 concurrent requests so a burst of events
  can't accidentally DoS your endpoint.
* **PII-scrubbed error logs.** Your endpoint's error response text is
  stored in `webhook_delivery_logs.error_message` for debugging, but
  Aura scrubs email addresses and phone numbers from the message and
  truncates to 500 chars before persisting.

## Secret rotation

To rotate a secret without downtime:

1. Call [`POST /v1/webhooks/{id}/rotate-secret`](https://docs.api.aura-app.ai/openapi/v1/webhooks/id/rotate-secret/post).
   Aura generates a new secret and shows it to you once.
2. For the next **24 hours**, Aura sends both the new and old signatures
   in `X-Aura-Signature-V1` (two `v1=` segments) and `X-Aura-Signature`
   (comma-separated).
3. Deploy the new secret to your endpoint. Your existing verification
   code continues to work during the grace window because it accepts
   either signature.
4. After 24 hours, Aura stops sending the old signature. Deliveries
   signed only with the old secret will fail verification.

## Delivery logs

Aura records every delivery attempt, successful or failed, in
`webhook_delivery_logs` with its status, HTTP status code, response time,
attempt count, and (sanitized) error message. Retrieve delivery attempts programmatically:

```bash theme={null}
curl -H "Authorization: Bearer aura_pk_live_xxxxx" \
  "https://api.aura-app.ai/v1/webhooks/deliveries?subscription_id=sub_xyz"
```

Logs are retained for 30 days.

## Webhooks vs. the Partner API

These two surfaces complement each other:

| | **Webhooks** | **Partner API** |
| - | - | - |
| Direction | Aura → your app (push) | Your app → Aura (pull) |
| Auth | HMAC signature on each request | `Authorization: Bearer aura_pk_live_...` |
| Setup | Paste HTTPS URL + store secret | Create API key with scopes |
| Best for | Reacting to events in real time | Queries, backfills, reconciliation, mutations |

Most integrations want **both**. For example: on `lead.created` react by
enriching the lead in your CRM; then call
[`GET /v1/leads/:id`](https://docs.api.aura-app.ai/openapi/v1/leads/id/get) on-demand to pull the full
history and attribution chain.

## Reference

* [`POST /v1/webhooks`](https://docs.api.aura-app.ai/openapi/v1/webhooks/post) - create a subscription programmatically
* [`GET /v1/webhooks`](https://docs.api.aura-app.ai/openapi/v1/webhooks/get) - list subscriptions
* [`PATCH /v1/webhooks/{id}`](https://docs.api.aura-app.ai/openapi/v1/webhooks/id/patch) - update target URL
* [`DELETE /v1/webhooks/{id}`](https://docs.api.aura-app.ai/openapi/v1/webhooks/id/delete) - delete a subscription
* [`POST /v1/webhooks/test`](https://docs.api.aura-app.ai/openapi/v1/webhooks/test/post) - send a test event to an endpoint


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.