Webhooks
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.
Example
curl "https://api.graph8.com/api/v1/webhooks/events" \ -H "Authorization: Bearer $API_KEY"Response
{ "data": [ { "event": "campaign.created" }, { "event": "campaign.updated" }, { "event": "campaign.deleted" }, { "event": "campaign.launched" }, { "event": "campaign.status_changed" }, { "event": "document.created" }, { "event": "document.updated" }, { "event": "document.generated" }, { "event": "intelligence.completed" }, { "event": "research.completed" }, { "event": "company.enriched" }, { "event": "person.enriched" }, { "event": "company_intelligence.completed" } ]}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 "https://api.graph8.com/api/v1/webhooks" \ -H "Authorization: Bearer $API_KEY"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, "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 -X POST "https://api.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" }'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-Webhook-Signature header. Verify it with HMAC-SHA256:
import hashlib, hmac
def verify_signature(payload: bytes, signature: str, secret: str) -> bool: expected = hmac.new( secret.encode(), payload, 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 |
Delete Webhook
DELETE /webhooks/{webhook_id}
Returns 204 No Content on success.
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 "https://api.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.
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)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": "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_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": "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"}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": "john@example.com", "contact_name": "John Doe", "contact_company": "Acme", "submitted_at": "2026-05-25T12:34:56.789000Z", "fields": { "email": "john@example.com", "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": "john@example.com", "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.