# Webhooks

> Subscribe to events and manage webhook deliveries

Source: https://docs.graph8.com/developers/webhooks/

> **Tip: Using the SDK?**
> The [JavaScript SDK](https://docs.graph8.com/developers/sdk/#webhooks) provides `g8.webhooks.on(event, callback)` for programmatic event listening.

Webhooks let you receive real-time HTTP callbacks when events occur in your organization. You can subscribe to specific event types and graph8 will POST a signed JSON payload to your endpoint.

---

## Available Events

`GET /webhooks/events`

Returns all event types you can subscribe to, each with a **category** and a human-readable **description** — the catalog you browse to decide what to listen for.

### Example

**cURL**

```bash
curl "https://be.graph8.com/api/v1/webhooks/events" \
  -H "Authorization: Bearer $API_KEY"
```

### Response

```json
{
  "data": [
    { "event": "campaign.launched", "category": "Campaign", "description": "A campaign was launched and is now sending." },
    { "event": "meeting.booked", "category": "Meetings", "description": "A meeting was booked." }
  ]
}
```

| Field | Type | Description |
|-------|------|-------------|
| `event` | string | The event type to subscribe to |
| `category` | string | Grouping for the event (e.g. `Campaign`, `Engagement`) |
| `description` | string \| null | What the event means / when it fires |

### Event catalog

| Event | Category | Description |
|-------|----------|-------------|
| `campaign.created` | Campaign | A campaign was created. |
| `campaign.updated` | Campaign | A campaign's configuration was updated. |
| `campaign.deleted` | Campaign | A campaign was deleted. |
| `campaign.launched` | Campaign | A campaign was launched and is now sending. |
| `campaign.paused` | Campaign | A campaign was paused. |
| `campaign.completed` | Campaign | A campaign finished all of its steps. |
| `campaign.status_changed` | Campaign | A campaign's status changed. |
| `document.generated` | Content | A campaign document finished generating. |
| `document.failed` | Content | A campaign document failed to generate. |
| `campaign.content_ready` | Content | All content for a campaign is ready. |
| `intelligence.completed` | Intelligence | An intelligence/research run completed. |
| `intelligence.failed` | Intelligence | An intelligence/research run failed. |
| `company.enriched` | Enrichment | A company record was enriched. |
| `company_intelligence.completed` | Enrichment | Company intelligence gathering completed. |
| `audience.ready` | Audience | An audience finished building and is ready to use. |
| `audience.failed` | Audience | An audience failed to build. |
| `sequence.draft_created` | Sequence | A campaign created a Sequencer draft; nothing has started sending. |
| `sequence.started` | Sequence | A sequence started running for a contact. |
| `sequence.paused` | Sequence | A sequence was paused. |
| `sequence.completed` | Sequence | A sequence completed all steps for a contact. |
| `engagement.email_sent` | Engagement | An email was sent to a contact. |
| `engagement.email_replied` | Engagement | A contact replied to an email. |
| `engagement.email_bounced` | Engagement | An email to a contact bounced. |
| `engagement.email_skipped` | Engagement | An email step was skipped for a contact. |
| `engagement.call_dispatched` | Engagement | A call task was dispatched to the dialer. |
| `engagement.sms_sent` | Engagement | An SMS was sent to a contact. |
| `engagement.sms_replied` | Engagement | A contact replied to an SMS. |
| `engagement.whatsapp_sent` | Engagement | A WhatsApp message was sent to a contact. |
| `engagement.linkedin_connection_sent` | Engagement | A LinkedIn connection request was sent. |
| `engagement.linkedin_message_sent` | Engagement | A LinkedIn message was sent. |
| `engagement.linkedin_inmail_sent` | Engagement | A LinkedIn InMail was sent. |
| `engagement.linkedin_reply_received` | Engagement | A LinkedIn reply was received from a contact. |
| `engagement.linkedin_connection_accepted` | Engagement | A LinkedIn connection request was accepted. |
| `meeting.booked` | Meetings | A meeting was booked. |
| `meeting.cancelled` | Meetings | A booked meeting was cancelled. |
| `meeting.rescheduled` | Meetings | A booked meeting was rescheduled. |
| `engagement.email_clicked` | Engagement | A contact first clicked a tracked link in a sequence email (email-security scanner clicks are filtered out). |
| `engagement.link_clicked` | Engagement | A contact first clicked a tracked link sent on a non-email channel (SMS, LinkedIn and others). |
| `meeting.no_show` | Meetings | A booked meeting's host or attendee was marked as a no-show. |
| `meeting.qualified` | Meetings | A host recorded a meeting that took place as a qualified lead. |
| `meeting.not_qualified` | Meetings | A host recorded a meeting that took place as not qualified, with a reason. |
| `voice_ai.call_started` | Voice AI | A Voice AI agent call was placed. |
| `voice_ai.call_completed` | Voice AI | A Voice AI agent call ended, with its duration, disposition and sentiment. |
| `voice_ai.voicemail_left` | Voice AI | A Voice AI agent left a voicemail. |
| `engagement.call_recording_ready` | Engagement | A dialer call recording is available. Fetch it with `GET /voice/calls/{call_id}/recording`. |
| `deal.created` | Deals | A deal was created. |
| `deal.updated` | Deals | A deal's fields were updated. |
| `deal.stage_changed` | Deals | A deal moved to a different pipeline stage. |
| `deal.deleted` | Deals | A deal was deleted. |
| `quote.created` | Quotes | A quote was created. |
| `quote.updated` | Quotes | A quote was updated. |
| `quote.sent` | Quotes | A quote was sent to the buyer. |
| `quote.resent` | Quotes | A quote was sent to the buyer again. |
| `quote.resend_failed` | Quotes | Re-sending a quote to the buyer failed. |
| `quote.link_generated` | Quotes | A shareable quote link was generated. |
| `quote.viewed` | Quotes | The buyer opened a quote. |
| `quote.accepted` | Quotes | The buyer accepted a quote. |
| `quote.declined` | Quotes | The buyer declined a quote. |
| `quote.expired` | Quotes | A quote passed its expiry date. |
| `quote.voided` | Quotes | A quote was voided. |
| `quote.superseded` | Quotes | A quote was replaced by a newer version. |
| `quote.archived` | Quotes | A quote was archived. |
| `quote.unarchived` | Quotes | An archived quote was restored. |
| `quote.payment_received` | Quotes | A payment for a quote was received. |
| `quote.payment_failed` | Quotes | A payment for a quote failed. |
| `quote.subscription_canceled` | Quotes | The subscription created from a quote was canceled. |
| `quote.provisioned` | Quotes | An accepted quote was provisioned. |
| `quote.approval_requested` | Quotes | A quote broke the discount or margin policy and was submitted for approval. |
| `quote.approved` | Quotes | An approver approved a quote, so it can be sent. |
| `quote.rejected` | Quotes | An approver rejected a quote, with a reason. |
| `quote.forwarded_for_signature` | Quotes | The buyer forwarded a quote to someone else to sign. |
| `quote.forwarded_for_payment` | Quotes | The buyer forwarded a quote to someone else to pay. |
| `quote.signer_change_requested` | Quotes | The buyer asked to hand the signature to someone else. Nothing changes until the rep approves. |
| `quote.signer_change_approved` | Quotes | The rep approved the buyer's signer change; the new signer was sent the signing link. |
| `quote.signer_change_rejected` | Quotes | The rep rejected the buyer's signer change; the signer is unchanged. |
| `form.submitted` | Website | A visitor submitted a tracked website or booking form. |
| `visitor.identified` | Website | A website visitor was identified as a contact (at most once per visitor per day). |
| `intent.signal` | Website | A contact showed buyer intent for one of your tracked keywords. |
| `task.created` | Tasks | A task was created. |
| `task.updated` | Tasks | A task was updated. |
| `task.completed` | Tasks | A task was marked as completed. |
| `task.deleted` | Tasks | A task was deleted. |
| `enrichment.job_completed` | Enrichment | An enrichment job finished processing all of its records. |
| `enrichment.job_failed` | Enrichment | An enrichment job failed. |
| `workflow.execution_completed` | Workflows | A workflow run finished. |
| `workflow.execution_failed` | Workflows | A workflow run failed. |

---

## List Webhooks

`GET /webhooks`

Returns all webhook subscriptions for your organization.

### Query Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `is_active` | boolean | — | Filter by active status |

### Example

**cURL**

```bash
curl "https://be.graph8.com/api/v1/webhooks" \
  -H "Authorization: Bearer $API_KEY"
```

**Python**

```python
response = requests.get(
    f"{BASE_URL}/webhooks",
    headers=HEADERS
)
```

### Response

```json
{
  "data": [
    {
      "id": "wh-abc",
      "name": "CRM Sync",
      "url": "https://example.com/webhooks/graph8",
      "events": ["campaign.created", "campaign.updated"],
      "is_active": true,
      "secret": null,
      "created_at": "2026-02-20T10:00:00",
      "updated_at": "2026-02-20T10:00:00"
    }
  ]
}
```

---

## Create Webhook

`POST /webhooks`

Create a new webhook subscription. The signing `secret` is returned only once in the response — store it securely.

Maximum 10 active webhooks per organization.

Returns `201 Created`.

### Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `url` | string | Yes | Target URL for delivery |
| `events` | string[] | Yes | Event types to subscribe to |
| `name` | string | No | Human-readable name |

### Example

**cURL**

```bash
curl -X POST "https://be.graph8.com/api/v1/webhooks" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/graph8",
    "events": ["campaign.created", "campaign.launched"],
    "name": "CRM Sync"
  }'
```

**Python**

```python
response = requests.post(
    f"{BASE_URL}/webhooks",
    headers=HEADERS,
    json={
        "url": "https://example.com/webhooks/graph8",
        "events": ["campaign.created", "campaign.launched"],
        "name": "CRM Sync"
    }
)
secret = response.json()["data"]["secret"]  # Store this!
```

### Response

```json
{
  "data": {
    "id": "wh-abc",
    "name": "CRM Sync",
    "url": "https://example.com/webhooks/graph8",
    "events": ["campaign.created", "campaign.launched"],
    "is_active": true,
    "secret": "whsec_a1b2c3d4e5f6..."
  }
}
```

### Verifying Signatures

Each delivery includes an `X-Studio-Signature` header (format: `sha256=<hex>`). Verify it with HMAC-SHA256 over `{X-Studio-Timestamp}.{raw_body}`:

```python
import hashlib, hmac

def verify_signature(body: bytes, timestamp: str, signature: str, secret: str) -> bool:
    signed = f"{timestamp}.".encode() + body
    expected = "sha256=" + hmac.new(
        secret.encode(), signed, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)
```

---

## Get Webhook

`GET /webhooks/{webhook_id}`

Returns webhook details. The `secret` is **not** included in the response.

---

## Update Webhook

`PATCH /webhooks/{webhook_id}`

Partial update — send only the fields you want to change.

### Request Body

| Field | Type | Description |
|-------|------|-------------|
| `url` | string | Target URL |
| `events` | string[] | Event types |
| `name` | string | Human-readable name |
| `is_active` | boolean | Enable or disable |

---

## List Deliveries

`GET /webhooks/{webhook_id}/deliveries`

Returns delivery attempts for a webhook, with pagination.

### Query Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `page` | integer | 1 | Page number |
| `limit` | integer | 50 | Items per page (max 200) |
| `status` | string | — | Filter by delivery status (`success`, `failed`, `pending`) |

### Example

**cURL**

```bash
curl "https://be.graph8.com/api/v1/webhooks/wh-abc/deliveries" \
  -H "Authorization: Bearer $API_KEY"
```

### Response

```json
{
  "data": [
    {
      "id": "del-1",
      "event": "campaign.created",
      "status": "success",
      "attempts": 1,
      "max_attempts": 3,
      "response_code": 200,
      "error_message": null,
      "created_at": "2026-02-25T10:00:00",
      "completed_at": "2026-02-25T10:00:01"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 50,
    "total": 1,
    "has_next": false
  }
}
```

Failed deliveries are retried up to 3 times with increasing delays (10s, 60s, 300s).

---

## Payload Envelope

Every webhook delivery uses the same outer envelope:

```json
{
  "event": "campaign.launched",
  "timestamp": "2026-05-25T12:34:56.789000Z",
  "org_id": "org_xxx123",
  "data": { /* event-specific payload below */ }
}
```

## Delivery Headers

graph8 sets the following headers on every webhook POST:

| Header | Example | Purpose |
|---|---|---|
| `Content-Type` | `application/json` | — |
| `X-Studio-Signature` | `sha256=8a91...c2f4` | HMAC-SHA256 over `{timestamp}.{raw_body}` |
| `X-Studio-Timestamp` | `1716624896` | Unix epoch seconds, also part of the signed message |
| `X-Studio-Event` | `campaign.launched` | Quick filter without parsing body |
| `X-Studio-Delivery-Id` | `del-abc-uuid` | Unique per delivery attempt (idempotency) |

## HMAC Verification

Verify every webhook before processing. The signed message is `{X-Studio-Timestamp}.{raw_body}`. Compare against `X-Studio-Signature` using a constant-time comparator.

**Python**

```python
import hmac, hashlib

def verify(body: bytes, timestamp: str, signature: str, secret: str) -> bool:
    signed = f"{timestamp}.".encode() + body
    expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)
```

**Node.js**

```javascript
import crypto from 'crypto';

function verify(body, timestamp, signature, secret) {
  const signed = `${timestamp}.${body}`;
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(signed).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
```

The `body` here is the **raw request body** (bytes / string), not the parsed JSON. Parse only after the signature passes.

## Replay Protection

Reject deliveries where `|now - X-Studio-Timestamp| > 5 minutes` to prevent replay attacks. graph8 retries failed deliveries but never resigns — every retry carries the original timestamp.

## Event Payload Schemas

### `campaign.launched`

Fires when a campaign transitions to `launched` / `active`.

```json
{
  "id": "camp_abc123",
  "name": "Q2 Outreach Campaign",
  "slug": "q2-outreach",
  "concept_slug": "saas-retargeting",
  "goal": "Drive trial signups",
  "target_persona": "VP Sales, B2B SaaS",
  "status": "launched",
  "created_at": "2026-05-20T10:00:00Z"
}
```

### `campaign.created` / `campaign.updated` / `campaign.deleted` / `campaign.paused` / `campaign.completed`

Same shape as `campaign.launched`, with `status` reflecting the new state.

### `company.enriched`

Fires after a successful Apollo (or fallback provider) enrichment of a company record.

```json
{
  "mashup_company_id": 12345,
  "company_ext_id": "cext_xyz789",
  "account_name": "Acme Corp",
  "domain": "acme.com",
  "fields_updated": ["phone", "industry", "headcount"],
  "enriched_at": "2026-05-25T12:34:56.789000Z"
}
```

### `intelligence.completed`

Global context or per-company intelligence generation finishes.

```json
{
  "website_url": "https://acme.com",
  "task_id": "task_uuid",
  "status": "completed",
  "success_count": 12,
  "fail_count": 0,
  "completed_at": "2026-05-25T12:34:56.789000Z"
}
```

### `audience.ready` / `audience.failed`

Audience build finishes.

```json
{
  "audience_id": 42,
  "list_id": 637,
  "platform": "meta",
  "status": "ready",
  "record_count": 8421
}
```

### `sequence.draft_created`

```json
{
  "sequence_id": "seq_abc123",
  "sequence_name": "Q2 Cold Outreach",
  "campaign_id": "camp_def456",
  "status": "drafted",
  "steps_created": 4,
  "created_at": "2026-05-25T12:34:56.789000Z"
}
```

Creating a draft does not activate the sequence or send anything. Activation is
reported separately as `sequence.started`.

### `sequence.started` / `sequence.paused` / `sequence.completed`

```json
{
  "sequence_id": "seq_abc123",
  "campaign_id": "camp_def456",
  "contacts_completed": 247,
  "completed_at": "2026-05-25T12:34:56.789000Z"
}
```

### `engagement.email_replied` (subscribers ask for this as `reply_received`)

Fires when a reply is detected on an outbound message via AI Inbox.

```json
{
  "contact_id": "contact_uuid",
  "email": "john@example.com",
  "reply_subject": "Re: Your Q2 offer",
  "sequence_id": "seq_abc123",
  "campaign_id": "camp_def456",
  "replied_at": "2026-05-25T12:34:56.789000Z",
  "is_positive": true
}
```

Related engagement events follow the same per-channel shape:
- `engagement.email_sent` / `email_bounced` / `email_skipped`
- `engagement.call_dispatched`
- `engagement.sms_sent` / `sms_replied`
- `engagement.whatsapp_sent`
- `engagement.linkedin_connection_sent` / `linkedin_message_sent` / `linkedin_inmail_sent` / `linkedin_reply_received` / `linkedin_connection_accepted`

### `meeting.booked` / `meeting.cancelled` / `meeting.rescheduled`

```json
{
  "contact_id": "contact_uuid",
  "email": "john@example.com",
  "meeting_id": "meeting_uuid",
  "meeting_title": "Discovery Call",
  "scheduled_at": "2026-05-28T14:00:00Z",
  "duration_minutes": 30,
  "sequence_id": "seq_abc123",
  "campaign_id": "camp_def456",
  "booked_at": "2026-05-25T12:34:56.789000Z"
}
```

### Event data for bridged events

Events that come from graph8's event pipeline (engagement, meetings, deals, quotes,
tasks, enrichment jobs, workflow runs, Voice AI calls, forms and visitors) deliver the
producer's message as `data`, plus `eda_event` (the internal event name),
`g8_correlation` and `idempotency_key`. Each of them can also be read through
`GET /events` under its `eda_event` name, with the same message (filter with `name`,
`type`, `contact_id`, `since` and `cursor`).

### `meeting.qualified` / `meeting.not_qualified`

Fires when a host records the result of a meeting that has ended: a qualified lead, or
not qualified with a reason. Recording the same result again, editing only its note, or
clearing it sends nothing; marking the meeting as a no-show replaces the result. For
`meeting.not_qualified`, `outcome_reason` is one of `no_budget`, `wrong_person`,
`no_need`, `bad_timing`, `not_a_fit` or `other`; for `meeting.qualified` it is `null`.
`outcome_set_by` is the email of the person who recorded it. The data also carries the
booking fields every meeting event carries (title, times, host, attendees).

```json
{
  "eda_event": "booking_outcome_set",
  "booking_uid": "3b8c1f0e-6d2a-4e51-9c1a-2f7b8d9e0a11",
  "outcome": "not_qualified",
  "outcome_reason": "bad_timing",
  "outcome_set_by": "ae@acme.com",
  "outcome_set_at": "2026-11-03T15:42:10.123000+00:00",
  "start_time": "2026-11-03T14:00:00+00:00",
  "end_time": "2026-11-03T14:30:00+00:00",
  "host_email": "ae@acme.com",
  "attendee_email": "john@example.com"
}
```

### `form.submitted`

Fires once per submission of a tracked website form or booking form, after
duplicate submissions from the tracker are collapsed.

```json
{
  "eda_event": "form_submitted",
  "form_id": "demo-request",
  "form_group_id": null,
  "form_kind": "web",
  "url": "https://acme.com/demo",
  "referrer": "https://www.google.com/",
  "submitter_email": "john@example.com",
  "fields": [{ "name": "email", "value": "john@example.com" }, { "name": "company", "value": "Acme" }],
  "submission_id": "3f1c9a2b7d4e5f60718293a4b5c6d7e8"
}
```

### `visitor.identified` / `intent.signal`

`visitor.identified` fires when a website visitor is matched to a contact, at most
once per contact per day. `intent.signal` fires when a contact shows intent for one
of your tracked keywords, at most once per contact, keyword and page per day.

```json
{
  "eda_event": "visitor_identified",
  "contact_id": 46699,
  "company_id": 812,
  "signal_type": "visitor_identified",
  "signal_group": "website"
}
```

### `engagement.email_clicked` / `engagement.link_clicked`

The first human click on a tracked link. Clicks from email-security scanners are
filtered out. `email_clicked` is for sequence emails; `link_clicked` covers SMS,
WhatsApp, LinkedIn and other channels.

```json
{
  "eda_event": "sequence_email_clicked",
  "utm_id": "AbCd1234",
  "contact_id": 46699,
  "campaign_id": "8f0d0b52-5a1e-4a55-9c3e-2f7d1b0c9e11",
  "step_id": "step_uuid",
  "channel": "email",
  "sent_at": "2026-09-24T14:58:12+00:00",
  "clicked_at": "2026-09-25T09:02:44+00:00",
  "detected_by": "click_resolver"
}
```

Email opens are not tracked. graph8 does not put a tracking pixel in outbound mail,
so there is no open event.

### `engagement.call_recording_ready`

A dialer call recording is available. It carries no recording URL: download the audio
with `GET /voice/calls/{call_id}/recording`. The event includes that path.
`recording_sid` identifies the recording: it starts with `RE` when Twilio recorded the
call and with `MZ` when graph8 recorded it from the call's own audio stream.

```json
{
  "eda_event": "sdr.recording_ready",
  "call_id": "dialer_46699_sess_1790261892",
  "session_id": "sess_uuid",
  "contact_id": "46699",
  "recording_sid": "RE0123456789abcdef",
  "recording_duration_seconds": 37,
  "recording_api_path": "/voice/calls/dialer_46699_sess_1790261892/recording"
}
```

### `quote.approval_requested` / `quote.approved` / `quote.rejected`

A quote that breaks your discount or margin policy (set in Quote settings) must be
approved before it can be sent. `quote.approval_requested` fires when it is submitted,
`quote.approved` and `quote.rejected` when an approver decides. The data is the same
as every other `quote.*` event, plus one `approval` object:

| Field | Type | Description |
|-------|------|-------------|
| `approval.status` | string | `pending_approval`, `approved` or `rejected` |
| `approval.requested_by` | string | User ID of the rep who submitted the quote |
| `approval.requested_at` | string | When it was submitted (ISO 8601, UTC) |
| `approval.approver` | string | User ID of the approver who decided; empty on `quote.approval_requested` |
| `approval.reason` | string | The approver's comment on `quote.approved` (optional) or the rejection reason on `quote.rejected`; empty on `quote.approval_requested` |
| `approval.thresholds_hit` | array | Each policy rule the quote broke: `rule` (`discount_pct` or `margin_pct`), the enforced `threshold` and the quote's `value`, in percent |

```json
{
  "eda_event": "quote.rejected",
  "quote_id": "5b0e7c1a-6f0e-4d7a-9a51-3f2b9e8d4c10",
  "quote_number": "Q-0042",
  "action": "rejected",
  "status": "draft",
  "previous_status": "draft",
  "deal_id": "812",
  "owner_id": "user_uuid",
  "total_cents": 1275000,
  "subtotal_cents": 1500000,
  "currency": "usd",
  "source": "quote_approval",
  "invoked_by": "approver_user_uuid",
  "approval": {
    "status": "rejected",
    "requested_by": "user_uuid",
    "requested_at": "2026-10-12T09:15:02.123456",
    "approver": "approver_user_uuid",
    "reason": "Max 15% for this segment",
    "thresholds_hit": [{ "rule": "discount_pct", "threshold": 15.0, "value": 22.5 }]
  }
}
```

### `test`

Verification ping. Use this when first wiring a subscription to confirm signature validation works.

```json
{
  "test": true,
  "message": "This is a test webhook from Graph8 Studio"
}
```

## Full Event List

For the complete catalog (campaign lifecycle, content generation, intelligence, enrichment, audience, sequence deployment, engagement per channel, meetings), call `GET /webhooks/events` — it returns the live registry. Subscribe to all events via `events: ["*"]` if you want a single firehose listener.
