All docs
Start here
API basics
Agent setups
Resources
- Contacts
- Companies
- Lists
- Deals
- Tasks
- Notes
- Fields
- Quotes
- Stage Checklist Pipelines
- Sequences
- Sequence Lifecycle
- Inbox
- Meetings
- Appointments Management
- Voice & Dialer
- Skills (LLM + API)
- Workflows
- GTM Campaigns
- GTM Context
- GTM Knowledge Base
- Launch Helpers
- Landing Pages
- Intent & Signals
- Search
- Enrichment
- Assert / Upsert
- Agency Keys & Cross-Org Targeting
- CRM Syncs
- Audience Syncs
- Destinations
- Snippet
- Functions
Data pipeline
Webhooks
What's new
Webhooks
Subscribe to events and manage webhook deliveries
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
curl "https://be.graph8.com/api/v1/webhooks/events" \
-H "Authorization: Bearer $API_KEY" Response
{
"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.deployed | Sequence | A sequence was deployed. |
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. |
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
curl "https://be.graph8.com/api/v1/webhooks" \
-H "Authorization: Bearer $API_KEY" Python
response = requests.get(
f"{BASE_URL}/webhooks",
headers=HEADERS
) Response
{
"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
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
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
{
"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}:
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
curl "https://be.graph8.com/api/v1/webhooks/wh-abc/deliveries" \
-H "Authorization: Bearer $API_KEY" Response
{
"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:
{
"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
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
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.
{
"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.
{
"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.
{
"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.
{
"audience_id": 42,
"list_id": 637,
"platform": "meta",
"status": "ready",
"record_count": 8421
}
sequence.deployed / sequence.started / sequence.paused / sequence.completed
{
"sequence_id": "seq_abc123",
"sequence_name": "Q2 Cold Outreach",
"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.
{
"contact_id": "contact_uuid",
"email": "[email protected]",
"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_skippedengagement.call_dispatchedengagement.sms_sent/sms_repliedengagement.whatsapp_sentengagement.linkedin_connection_sent/linkedin_message_sent/linkedin_inmail_sent/linkedin_reply_received/linkedin_connection_accepted
meeting.booked / meeting.cancelled / meeting.rescheduled
{
"contact_id": "contact_uuid",
"email": "[email protected]",
"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"
}
form_submitted
Form submission captured by Jitsu (web form or appointments form).
{
"form_id": "form_uuid",
"form_name": "Lead Capture",
"submission_id": "sub_uuid",
"contact_email": "[email protected]",
"contact_name": "John Doe",
"contact_company": "Acme",
"submitted_at": "2026-05-25T12:34:56.789000Z",
"fields": { "email": "[email protected]", "name": "John Doe", "company": "Acme", "budget": "$50k-100k" }
}
visitor.identified (subscribers ask for this as visitor_identified)
Anonymous visitor matched to a known contact.
{
"contact_id": "contact_uuid",
"email": "[email protected]",
"visitor_id": "visitor_uuid",
"pages_visited": ["/pricing", "/features", "/signup"],
"identified_at": "2026-05-25T12:34:56.789000Z"
}
test
Verification ping. Use this when first wiring a subscription to confirm signature validation works.
{
"test": true,
"message": "This is a test webhook from Graph8 Studio"
}
Full Event List (33 types)
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.