Skip to content

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, each with a category and a human-readable description — the catalog you browse to decide what to listen for.

Example

Terminal window
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." }
]
}
FieldTypeDescription
eventstringThe event type to subscribe to
categorystringGrouping for the event (e.g. Campaign, Engagement)
descriptionstring | nullWhat the event means / when it fires

Event catalog

EventCategoryDescription
campaign.createdCampaignA campaign was created.
campaign.updatedCampaignA campaign’s configuration was updated.
campaign.deletedCampaignA campaign was deleted.
campaign.launchedCampaignA campaign was launched and is now sending.
campaign.pausedCampaignA campaign was paused.
campaign.completedCampaignA campaign finished all of its steps.
campaign.status_changedCampaignA campaign’s status changed.
document.generatedContentA campaign document finished generating.
document.failedContentA campaign document failed to generate.
campaign.content_readyContentAll content for a campaign is ready.
intelligence.completedIntelligenceAn intelligence/research run completed.
intelligence.failedIntelligenceAn intelligence/research run failed.
company.enrichedEnrichmentA company record was enriched.
company_intelligence.completedEnrichmentCompany intelligence gathering completed.
audience.readyAudienceAn audience finished building and is ready to use.
audience.failedAudienceAn audience failed to build.
sequence.draft_createdSequenceA campaign created a Sequencer draft; nothing has started sending.
sequence.startedSequenceA sequence started running for a contact.
sequence.pausedSequenceA sequence was paused.
sequence.completedSequenceA sequence completed all steps for a contact.
engagement.email_sentEngagementAn email was sent to a contact.
engagement.email_repliedEngagementA contact replied to an email.
engagement.email_bouncedEngagementAn email to a contact bounced.
engagement.email_skippedEngagementAn email step was skipped for a contact.
engagement.call_dispatchedEngagementA call task was dispatched to the dialer.
engagement.sms_sentEngagementAn SMS was sent to a contact.
engagement.sms_repliedEngagementA contact replied to an SMS.
engagement.whatsapp_sentEngagementA WhatsApp message was sent to a contact.
engagement.linkedin_connection_sentEngagementA LinkedIn connection request was sent.
engagement.linkedin_message_sentEngagementA LinkedIn message was sent.
engagement.linkedin_inmail_sentEngagementA LinkedIn InMail was sent.
engagement.linkedin_reply_receivedEngagementA LinkedIn reply was received from a contact.
engagement.linkedin_connection_acceptedEngagementA LinkedIn connection request was accepted.
meeting.bookedMeetingsA meeting was booked.
meeting.cancelledMeetingsA booked meeting was cancelled.
meeting.rescheduledMeetingsA booked meeting was rescheduled.
engagement.email_clickedEngagementA contact first clicked a tracked link in a sequence email (email-security scanner clicks are filtered out).
engagement.link_clickedEngagementA contact first clicked a tracked link sent on a non-email channel (SMS, LinkedIn and others).
meeting.no_showMeetingsA booked meeting’s host or attendee was marked as a no-show.
meeting.qualifiedMeetingsA host recorded a meeting that took place as a qualified lead.
meeting.not_qualifiedMeetingsA host recorded a meeting that took place as not qualified, with a reason.
voice_ai.call_startedVoice AIA Voice AI agent call was placed.
voice_ai.call_completedVoice AIA Voice AI agent call ended, with its duration, disposition and sentiment.
voice_ai.voicemail_leftVoice AIA Voice AI agent left a voicemail.
engagement.call_recording_readyEngagementA dialer call recording is available. Fetch it with GET /voice/calls/{call_id}/recording.
deal.createdDealsA deal was created.
deal.updatedDealsA deal’s fields were updated.
deal.stage_changedDealsA deal moved to a different pipeline stage.
deal.deletedDealsA deal was deleted.
quote.createdQuotesA quote was created.
quote.updatedQuotesA quote was updated.
quote.sentQuotesA quote was sent to the buyer.
quote.resentQuotesA quote was sent to the buyer again.
quote.resend_failedQuotesRe-sending a quote to the buyer failed.
quote.link_generatedQuotesA shareable quote link was generated.
quote.viewedQuotesThe buyer opened a quote.
quote.acceptedQuotesThe buyer accepted a quote.
quote.declinedQuotesThe buyer declined a quote.
quote.expiredQuotesA quote passed its expiry date.
quote.voidedQuotesA quote was voided.
quote.supersededQuotesA quote was replaced by a newer version.
quote.archivedQuotesA quote was archived.
quote.unarchivedQuotesAn archived quote was restored.
quote.payment_receivedQuotesA payment for a quote was received.
quote.payment_failedQuotesA payment for a quote failed.
quote.subscription_canceledQuotesThe subscription created from a quote was canceled.
quote.provisionedQuotesAn accepted quote was provisioned.
quote.approval_requestedQuotesA quote broke the discount or margin policy and was submitted for approval.
quote.approvedQuotesAn approver approved a quote, so it can be sent.
quote.rejectedQuotesAn approver rejected a quote, with a reason.
quote.forwarded_for_signatureQuotesThe buyer forwarded a quote to someone else to sign.
quote.forwarded_for_paymentQuotesThe buyer forwarded a quote to someone else to pay.
quote.signer_change_requestedQuotesThe buyer asked to hand the signature to someone else. Nothing changes until the rep approves.
quote.signer_change_approvedQuotesThe rep approved the buyer’s signer change; the new signer was sent the signing link.
quote.signer_change_rejectedQuotesThe rep rejected the buyer’s signer change; the signer is unchanged.
form.submittedWebsiteA visitor submitted a tracked website or booking form.
visitor.identifiedWebsiteA website visitor was identified as a contact (at most once per visitor per day).
intent.signalWebsiteA contact showed buyer intent for one of your tracked keywords.
task.createdTasksA task was created.
task.updatedTasksA task was updated.
task.completedTasksA task was marked as completed.
task.deletedTasksA task was deleted.
enrichment.job_completedEnrichmentAn enrichment job finished processing all of its records.
enrichment.job_failedEnrichmentAn enrichment job failed.
workflow.execution_completedWorkflowsA workflow run finished.
workflow.execution_failedWorkflowsA workflow run failed.

List Webhooks

GET /webhooks

Returns all webhook subscriptions for your organization.

Query Parameters

ParameterTypeDefaultDescription
is_activeboolean—Filter by active status

Example

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

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

FieldTypeRequiredDescription
urlstringYesTarget URL for delivery
eventsstring[]YesEvent types to subscribe to
namestringNoHuman-readable name

Example

Terminal window
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"
}'

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

FieldTypeDescription
urlstringTarget URL
eventsstring[]Event types
namestringHuman-readable name
is_activebooleanEnable or disable

List Deliveries

GET /webhooks/{webhook_id}/deliveries

Returns delivery attempts for a webhook, with pagination.

Query Parameters

ParameterTypeDefaultDescription
pageinteger1Page number
limitinteger50Items per page (max 200)
statusstring—Filter by delivery status (success, failed, pending)

Example

Terminal window
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:

HeaderExamplePurpose
Content-Typeapplication/json—
X-Studio-Signaturesha256=8a91...c2f4HMAC-SHA256 over {timestamp}.{raw_body}
X-Studio-Timestamp1716624896Unix epoch seconds, also part of the signed message
X-Studio-Eventcampaign.launchedQuick filter without parsing body
X-Studio-Delivery-Iddel-abc-uuidUnique 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)

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.draft_created

{
"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

{
"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.

{
"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_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

{
"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"
}

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).

{
"eda_event": "booking_outcome_set",
"booking_uid": "3b8c1f0e-6d2a-4e51-9c1a-2f7b8d9e0a11",
"outcome": "not_qualified",
"outcome_reason": "bad_timing",
"outcome_set_by": "[email protected]",
"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": "[email protected]",
"attendee_email": "[email protected]"
}

form.submitted

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

{
"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": "[email protected]",
"fields": [{ "name": "email", "value": "[email protected]" }, { "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.

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

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.

{
"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.

{
"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:

FieldTypeDescription
approval.statusstringpending_approval, approved or rejected
approval.requested_bystringUser ID of the rep who submitted the quote
approval.requested_atstringWhen it was submitted (ISO 8601, UTC)
approval.approverstringUser ID of the approver who decided; empty on quote.approval_requested
approval.reasonstringThe approver’s comment on quote.approved (optional) or the rejection reason on quote.rejected; empty on quote.approval_requested
approval.thresholds_hitarrayEach policy rule the quote broke: rule (discount_pct or margin_pct), the enforced threshold and the quote’s value, in percent
{
"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.

{
"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.