Webhooks
Revenue Moments happen inside agent threads — a user hits a usage wall, starts a trial, accepts an upgrade — in a surface your email platform, CRM, and warehouse cannot see. Webhooks carry those moments out: each event is delivered to your endpoint as a signed HTTP POST within minutes of the moment occurring, not in a nightly export.
Point a subscription at your own receiver, an automation platform, or a relay into your email tooling, and react while the moment is current. The flagship use case — starting an email sequence the minute a trial moment happens — is walked through end to end in Trigger email campaigns from Revenue Moments.
All API examples below use $INCEPTION_API for the base URL:
export INCEPTION_API="https://inception-agents-api.inception-agents.workers.dev"
Managing subscriptions
You can manage webhook subscriptions from three surfaces:
- The API — the
/api/v1/webhooks/subscriptionsendpoints below. - The dashboard — Settings › Webhooks: create endpoints, choose events, send tests, browse the delivery log, and re-enable disabled endpoints.
- MCP — or ask your agent:
create_webhook_subscription,send_test_webhook, andlist_webhook_deliveriesare tools onmcp.inceptionagents.com.
Create a subscription
curl -X POST "$INCEPTION_API/api/v1/webhooks/subscriptions" \
-H "Authorization: Bearer sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/inception",
"event_types": ["trial.started", "trial.expiring", "trial.expired", "conversion.trial_converted"],
"label": "trial-lifecycle-to-esp"
}'
urlmust be HTTPS and publicly reachable.event_typesis optional — omit it (or pass an empty array) to receive all events.labelis optional and only for your own bookkeeping.
Response 201 Created
{
"id": "9b2f6c1e-4a8d-4f0b-a3c7-51e29d8f0b64",
"url": "https://example.com/webhooks/inception",
"event_types": ["trial.started", "trial.expiring", "trial.expired", "conversion.trial_converted"],
"label": "trial-lifecycle-to-esp",
"active": true,
"signing_secret": "whsec_9a1f4c7e2b8d5f0a3c6e9b2d5f8a1c4e"
}
The signing_secret is shown once, in this response only. Store it immediately — listing subscriptions later returns a secret_hint (the last few characters), never the full secret. If you lose it, rotate it.
Limits: a tenant can have at most 10 endpoints. Creating a subscription with the same URL as an existing active subscription returns 409 Conflict.
List subscriptions
curl "$INCEPTION_API/api/v1/webhooks/subscriptions" \
-H "Authorization: Bearer sk_your_api_key"
Each entry includes secret_hint in place of the signing secret.
Update, disable, re-enable
curl -X PATCH "$INCEPTION_API/api/v1/webhooks/subscriptions/9b2f6c1e-4a8d-4f0b-a3c7-51e29d8f0b64" \
-H "Authorization: Bearer sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "event_types": ["trial.expiring", "conversion.trial_converted"], "active": true }'
PATCH accepts any of url, event_types, label, and active. Re-enabling a disabled endpoint with "active": true also resets its failure counter (see Delivery semantics).
Delete
curl -X DELETE "$INCEPTION_API/api/v1/webhooks/subscriptions/9b2f6c1e-4a8d-4f0b-a3c7-51e29d8f0b64" \
-H "Authorization: Bearer sk_your_api_key"
Rotate the signing secret
curl -X POST "$INCEPTION_API/api/v1/webhooks/subscriptions/9b2f6c1e-4a8d-4f0b-a3c7-51e29d8f0b64/rotate-secret" \
-H "Authorization: Bearer sk_your_api_key"
Response 200 OK
{
"id": "9b2f6c1e-4a8d-4f0b-a3c7-51e29d8f0b64",
"signing_secret": "whsec_3e8b1d4f7a0c2e5b8d1f4a7c0e3b6d9f"
}
Again, the new secret appears once. Update your receiver before rotating in production.
The envelope
Every delivery is a JSON envelope with a stable core and an enrichment object:
{
"apiVersion": "2026-07",
"event": "trial.expiring",
"webhookId": "evt_9c1f2a34-6b7d-4e2f-9a10-3d5c8e714f26",
"timestamp": "2026-07-18T14:02:11.000Z",
"data": {
"subject_ref": "u_8f3a94c17b2d40aa",
"account_ref": "acct_4b21c9e0d7f34e19",
"moment": {
"wall_event_id": null,
"wall_key": "projects.max",
"action": null,
"trigger_class": "trial_gate",
"moment_type": null,
"reason_code": "trial_window_closing",
"agent_environment": null
},
"lifecycle": {
"state": "trial",
"trial_ends_at": "2026-07-21T00:00:00.000Z",
"trial_days_remaining": 3,
"usage_remaining_bucket": "some"
},
"enrichment": {
"intent_state": "deciding",
"conversion_likelihood": "medium",
"journey_momentum": "advancing",
"journey_depth": 2,
"is_returning": true,
"ai_provider": "claude"
},
"attribution": {
"params": "utm_source=inception_moment&utm_campaign=trial.expiring"
}
}
}
| Field | Description |
|---|---|
apiVersion | Envelope version. Currently 2026-07. Breaking changes ship under a new version. |
event | Event type (see the catalog). Also sent in the X-Webhook-Event header. |
webhookId | Unique event id (evt_<uuid>). Stable across retries — this is your dedup key. |
timestamp | ISO 8601 time the moment occurred (not the delivery time). |
data.subject_ref | Pseudonymous reference to the end user, minted by your SDK integration. |
data.account_ref | Pseudonymous reference to the account/organization, or null. |
data.moment | Where in the flow the moment happened. Every field is nullable. |
data.lifecycle | Subscription lifecycle state of the subject. |
data.commerce | Plan and amount details. Present only on conversion.* events. |
data.enrichment | Intent-graph signals. Every field is optional. |
data.attribution | UTM parameters to append to campaign links. |
The rule, plainly: code against the core; treat every enrichment field as optional. The core fields (apiVersion, event, webhookId, timestamp, subject_ref, and the moment/lifecycle/attribution objects) are stable within an apiVersion. Enrichment fields can be "unknown", null, or absent on any given delivery — never write a receiver that fails when they are.
Field-by-field types and nullability for every event are in the webhook event reference.
Each delivery also carries these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Inception-Signature | t=<unix_seconds>,v1=<hex signature> — see Signature verification |
X-Webhook-Event | The event type, e.g. trial.expiring |
Webhook-Id | The webhookId, e.g. evt_9c1f... |
Webhook-Timestamp | The envelope timestamp |
Event catalog
Nineteen event types across four families. The flagship set for email automation is trial.* + conversion.* + value.delivered. The moment.* family covers wall and guidance moments inside agent threads — useful for CRM timelines, sales alerts, and sequence routing.
Every delivery carries the full envelope shown above; the examples below are trimmed to the fields that distinguish each event.
moment.wall_hit
A subject hit a wall — a free-tier limit, usage cap, feature gate, or trial gate — inside an agent thread.
{
"event": "moment.wall_hit",
"webhookId": "evt_1f6d2c58-90ab-4c3e-8f21-7a4b5d6e9c01",
"timestamp": "2026-07-18T09:14:22.000Z",
"data": {
"subject_ref": "u_8f3a94c17b2d40aa",
"account_ref": "acct_4b21c9e0d7f34e19",
"moment": {
"wall_event_id": "we_5c8e714f",
"wall_key": "seats.limit",
"action": "upgrade_offer",
"trigger_class": "usage_wall",
"moment_type": "at_gate",
"reason_code": "seat_cap_reached",
"agent_environment": "claude"
},
"lifecycle": { "state": "active", "usage_remaining_bucket": "none" },
"enrichment": { "conversion_likelihood": "high", "journey_momentum": "advancing" }
}
}
Trigger: route the subject into the sequence for that wall family (seats walls get the seats sequence), and alert an account owner when conversion_likelihood is high.
moment.upgrade_offered
An upgrade was quoted to the subject in-thread.
{
"event": "moment.upgrade_offered",
"webhookId": "evt_2a7e3d19-45cf-4b8a-9d02-6e1f8c3b5a74",
"timestamp": "2026-07-18T09:14:25.000Z",
"data": {
"subject_ref": "u_8f3a94c17b2d40aa",
"moment": {
"wall_key": "seats.limit",
"action": "upgrade_offer",
"trigger_class": "usage_wall",
"moment_type": "at_gate"
},
"lifecycle": { "state": "active" }
}
}
Trigger: log the offer on the CRM timeline and suppress generic upsell emails while an in-thread offer is live.
moment.guidance_offered
The subject was shown guidance (help, not a paid offer) at a moment where a wall was near.
{
"event": "moment.guidance_offered",
"webhookId": "evt_3b8f4e2a-56d0-4c9b-ae13-7f209d4c6b85",
"timestamp": "2026-07-18T10:02:41.000Z",
"data": {
"subject_ref": "u_2c9d51e8a7f4b360",
"moment": {
"wall_key": "exports.monthly",
"action": "guidance",
"trigger_class": "usage_wall",
"moment_type": "pre_gate"
},
"lifecycle": { "state": "free", "usage_remaining_bucket": "low" }
}
}
Trigger: queue a help-content follow-up for the relevant feature area instead of a sales touch.
moment.free_tier_reached
A subject exhausted the free tier.
{
"event": "moment.free_tier_reached",
"webhookId": "evt_4c905f3b-67e1-4dac-bf24-8031ae5d7c96",
"timestamp": "2026-07-18T11:30:05.000Z",
"data": {
"subject_ref": "u_2c9d51e8a7f4b360",
"moment": {
"wall_key": "exports.monthly",
"trigger_class": "free_tier_limit",
"moment_type": "at_gate",
"reason_code": "quota_exhausted"
},
"lifecycle": { "state": "free", "usage_remaining_bucket": "none" }
}
}
Trigger: start the free-to-paid conversion sequence.
moment.post_value_offered
An offer was presented after the product delivered a completed outcome (a post_value moment), rather than at a blocking gate.
{
"event": "moment.post_value_offered",
"webhookId": "evt_5da1604c-78f2-4ebd-a035-9142bf6e8da7",
"timestamp": "2026-07-18T12:12:19.000Z",
"data": {
"subject_ref": "u_8f3a94c17b2d40aa",
"moment": {
"action": "upgrade_offer",
"moment_type": "post_value",
"reason_code": "post_value_window"
},
"lifecycle": { "state": "active" },
"enrichment": { "journey_momentum": "advancing", "journey_depth": 3 }
}
}
Trigger: route into an expansion journey timed to follow delivered value, not a blocked task.
moment.upgrade_abandoned
The subject was offered an upgrade in-thread and declined or walked away.
{
"event": "moment.upgrade_abandoned",
"webhookId": "evt_6eb27150-89a3-4fce-b146-a253c07f9eb8",
"timestamp": "2026-07-18T13:45:52.000Z",
"data": {
"subject_ref": "u_5e1fa8c34b9d2706",
"moment": {
"wall_key": "api.rate_limit",
"action": "upgrade_offer",
"moment_type": "at_gate",
"reason_code": "offer_declined"
},
"lifecycle": { "state": "active" },
"enrichment": { "conversion_likelihood": "medium", "journey_momentum": "stalled" }
}
}
Trigger: apply a cooldown, then a single win-back touch; suppress immediate re-offers in other channels.
moment.fallback_accepted
The subject declined the primary offer but accepted a fallback (a smaller plan, an add-on, or an alternative path).
{
"event": "moment.fallback_accepted",
"webhookId": "evt_7fc38261-9ab4-4a0f-c257-b364d18a0fc9",
"timestamp": "2026-07-18T14:20:33.000Z",
"data": {
"subject_ref": "u_5e1fa8c34b9d2706",
"moment": {
"wall_key": "api.rate_limit",
"action": "fallback_offer",
"moment_type": "at_gate",
"reason_code": "fallback_accepted"
},
"lifecycle": { "state": "active" }
}
}
Trigger: onboard for what they took, and schedule a later check-in on the option they passed on.
moment.admin_upgrade_requested
An end user requested an upgrade that needs approval from their account admin.
{
"event": "moment.admin_upgrade_requested",
"webhookId": "evt_80d49372-abc5-4b10-d368-c475e29b10da",
"timestamp": "2026-07-18T15:08:44.000Z",
"data": {
"subject_ref": "u_9a4c72e15d8f3b06",
"account_ref": "acct_4b21c9e0d7f34e19",
"moment": {
"wall_key": "seats.limit",
"action": "admin_request",
"trigger_class": "usage_wall",
"moment_type": "at_gate"
},
"lifecycle": { "state": "active" }
}
}
Trigger: resolve account_ref to the account admin and send an approval nudge — the requester is waiting on them.
moment.sales_route_captured
The moment was routed to sales instead of self-serve (large seat counts, enterprise plans, custom terms).
{
"event": "moment.sales_route_captured",
"webhookId": "evt_91e5a483-bcd6-4c21-e479-d586f3ac21eb",
"timestamp": "2026-07-18T16:41:07.000Z",
"data": {
"subject_ref": "u_9a4c72e15d8f3b06",
"account_ref": "acct_4b21c9e0d7f34e19",
"moment": {
"wall_key": "plan.enterprise",
"action": "sales_route",
"moment_type": "at_gate"
},
"lifecycle": { "state": "active" },
"enrichment": { "conversion_likelihood": "high" }
}
}
Trigger: create a CRM lead or task and alert the account owner while the thread is still open.
moment.blocker_reported
The subject reported a blocker the flow could not resolve (billing confusion, missing capability, an error).
{
"event": "moment.blocker_reported",
"webhookId": "evt_a2f6b594-cde7-4d32-f58a-e697a4bd32fc",
"timestamp": "2026-07-18T17:19:28.000Z",
"data": {
"subject_ref": "u_2c9d51e8a7f4b360",
"moment": {
"wall_key": "billing.payment_method",
"moment_type": "at_gate",
"reason_code": "blocker_reported"
},
"lifecycle": { "state": "trial" }
}
}
Trigger: open a support ticket with the moment context attached.
trial.started
A subject started a trial.
{
"event": "trial.started",
"webhookId": "evt_b3a7c6a5-def8-4e43-a69b-f7a8b5ce43ad",
"timestamp": "2026-07-14T08:00:12.000Z",
"data": {
"subject_ref": "u_8f3a94c17b2d40aa",
"lifecycle": {
"state": "trial",
"trial_ends_at": "2026-07-28T08:00:12.000Z",
"trial_days_remaining": 14,
"usage_remaining_bucket": "some"
}
}
}
Trigger: start the welcome and activation sequence.
trial.activated
A trialing subject reached your activation milestone.
{
"event": "trial.activated",
"webhookId": "evt_c4b8d7b6-ef09-4f54-b7ac-a8b9c6df54be",
"timestamp": "2026-07-16T19:33:50.000Z",
"data": {
"subject_ref": "u_8f3a94c17b2d40aa",
"lifecycle": {
"state": "trial",
"trial_ends_at": "2026-07-28T08:00:12.000Z",
"trial_days_remaining": 12
},
"enrichment": { "journey_depth": 2, "journey_momentum": "advancing" }
}
}
Trigger: switch the sequence from activation tips to conversion messaging.
trial.expiring
A trial is approaching its end date. This is a sweeper event — see Delivery semantics for timing.
{
"event": "trial.expiring",
"webhookId": "evt_9c1f2a34-6b7d-4e2f-9a10-3d5c8e714f26",
"timestamp": "2026-07-25T14:02:11.000Z",
"data": {
"subject_ref": "u_8f3a94c17b2d40aa",
"lifecycle": {
"state": "trial",
"trial_ends_at": "2026-07-28T08:00:12.000Z",
"trial_days_remaining": 3,
"usage_remaining_bucket": "some"
},
"enrichment": { "intent_state": "deciding", "conversion_likelihood": "medium" }
}
}
Trigger: start the end-of-trial nurture, routed by the wall family the subject hit during the trial.
trial.expired
The trial ended without converting.
{
"event": "trial.expired",
"webhookId": "evt_d5c9e8c7-f01a-4a65-c8bd-b9cad7ea65cf",
"timestamp": "2026-07-28T08:05:00.000Z",
"data": {
"subject_ref": "u_8f3a94c17b2d40aa",
"lifecycle": {
"state": "free",
"trial_ends_at": "2026-07-28T08:00:12.000Z",
"trial_days_remaining": 0
}
}
}
Trigger: after a short grace window, start the win-back sequence.
conversion.trial_converted
A trial converted to a paid plan. commerce is present on all conversion.* events.
{
"event": "conversion.trial_converted",
"webhookId": "evt_e6daf9d8-a12b-4b76-d9ce-cadbe8fb76da",
"timestamp": "2026-07-26T10:15:44.000Z",
"data": {
"subject_ref": "u_8f3a94c17b2d40aa",
"lifecycle": { "state": "converted" },
"commerce": {
"plan_key": "growth_monthly",
"amount_cents": 4900,
"currency": "usd",
"branch": "acquisition",
"rail": "in_thread"
}
}
}
Trigger: suppress every trial and win-back sequence immediately, then start paid onboarding for plan_key.
conversion.upgrade_accepted
An existing paying subject accepted an upgrade.
{
"event": "conversion.upgrade_accepted",
"webhookId": "evt_f7eb0ae9-b23c-4c87-eadf-dbecf90c87eb",
"timestamp": "2026-07-18T09:16:02.000Z",
"data": {
"subject_ref": "u_9a4c72e15d8f3b06",
"account_ref": "acct_4b21c9e0d7f34e19",
"lifecycle": { "state": "active" },
"commerce": {
"plan_key": "scale_monthly",
"amount_cents": 19900,
"currency": "usd",
"branch": "expansion",
"rail": "in_thread"
}
}
}
Trigger: suppress upgrade nurture for the subject (and usually the account), and confirm the change with an onboarding touch for the new plan.
conversion.entitlement_resumed
A lapsed or paused entitlement resumed — for example after a payment issue was resolved.
{
"event": "conversion.entitlement_resumed",
"webhookId": "evt_a8fc1bfa-c34d-4d98-fbea-ecfda01d98fc",
"timestamp": "2026-07-19T07:40:29.000Z",
"data": {
"subject_ref": "u_5e1fa8c34b9d2706",
"lifecycle": { "state": "active" },
"commerce": {
"plan_key": "growth_monthly",
"amount_cents": 4900,
"currency": "usd",
"branch": "expansion",
"rail": "in_thread"
}
}
}
Trigger: stop dunning and pause-related sequences.
value.delivered
The product completed a meaningful outcome for the subject inside an agent thread.
{
"event": "value.delivered",
"webhookId": "evt_b90d2c0b-d45e-4ea9-acfb-fda0b12ea90d",
"timestamp": "2026-07-18T12:11:58.000Z",
"data": {
"subject_ref": "u_8f3a94c17b2d40aa",
"moment": { "moment_type": "post_value", "agent_environment": "claude" },
"lifecycle": { "state": "trial" },
"enrichment": { "journey_momentum": "advancing", "journey_depth": 3 }
}
}
Trigger: use it as a send-time gate — expansion and review asks land better right after delivered value.
subject.activation_reached
The subject crossed your activation threshold, in any lifecycle state.
{
"event": "subject.activation_reached",
"webhookId": "evt_ca1e3d1c-e56f-4fba-bd0c-ab1c23fba1e0",
"timestamp": "2026-07-17T21:55:10.000Z",
"data": {
"subject_ref": "u_2c9d51e8a7f4b360",
"lifecycle": { "state": "free" },
"enrichment": { "journey_depth": 2, "is_returning": true }
}
}
Trigger: graduate the subject out of activation drips into the next stage of the journey.
Signature verification
Every delivery is signed. The X-Inception-Signature header has the form:
X-Inception-Signature: t=1752934931,v1=5f2d8a1c...
tis a Unix timestamp (seconds) set when the delivery was signed.v1is the hex HMAC-SHA256 of`${t}.${rawBody}`computed with yourwhsec_...signing secret — the timestamp, a literal., then the raw request body.
Verify against the raw body bytes, before any JSON parsing or re-serialization, and use a timing-safe comparison.
Node.js
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
function verifySignature(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
(signatureHeader ?? "").split(",").map((kv) => kv.split("=", 2))
);
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) {
return false;
}
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const provided = Buffer.from(parts.v1 ?? "", "hex");
const computed = Buffer.from(expected, "hex");
return (
provided.length === computed.length &&
crypto.timingSafeEqual(provided, computed)
);
}
// Express example — express.raw preserves the raw body for signing
app.post(
"/webhooks/inception",
express.raw({ type: "application/json" }),
(req, res) => {
const ok = verifySignature(
req.body.toString("utf-8"),
req.headers["x-inception-signature"],
process.env.INCEPTION_WEBHOOK_SECRET
);
if (!ok) return res.status(401).json({ error: "invalid signature" });
const event = JSON.parse(req.body.toString("utf-8"));
// Acknowledge fast; process asynchronously.
res.status(200).json({ received: true });
}
);
Python
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 5 * 60
def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
parts = dict(kv.split("=", 1) for kv in (signature_header or "").split(",") if "=" in kv)
t = int(parts.get("t", 0) or 0)
if not t or abs(time.time() - t) > TOLERANCE_SECONDS:
return False
signed_payload = f"{t}.".encode("utf-8") + raw_body
expected = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(parts.get("v1", ""), expected)
Timestamp tolerance and replay protection
Reject deliveries whose t is more than 5 minutes from your clock. Because the timestamp is part of the signed payload, an attacker who captures a valid request cannot replay it later without invalidating the signature — the stale timestamp fails your tolerance check, and any attempt to freshen it breaks the HMAC. The v1= scheme prefix exists so a future signing scheme can ship alongside the current one without breaking verifiers.
Delivery semantics
At-least-once. A delivery can arrive more than once. Deduplicate on webhookId, which is stable across retries. Store processed ids in a persistent store, not an in-memory set.
Ordering is not guaranteed. Events can arrive out of order, especially around retries. Use the envelope timestamp (when the moment occurred) when sequence matters.
Timeout and retries. Your endpoint must respond 2xx within 10 seconds. A failed or timed-out delivery gets one attempt per drain pass with backoff: retry after +10 seconds, then +60 seconds — 3 attempts total. Respond fast and process asynchronously.
Latency. Wall-interactive events (moment.*, conversion.*, value.delivered, subject.activation_reached, trial.started, trial.activated) deliver near-real-time. Sweeper events (trial.expiring, trial.expired) deliver within about 10 minutes of the condition being detected.
Expiry. Events older than 24 hours that still have not been delivered are dropped.
Auto-disable. An endpoint that fails 10 consecutive deliveries is automatically disabled. Re-enable it in Settings › Webhooks or with PATCH { "active": true }, which also resets the failure counter.
Testing
Send a test delivery
curl -X POST "$INCEPTION_API/api/v1/webhooks/subscriptions/9b2f6c1e-4a8d-4f0b-a3c7-51e29d8f0b64/test" \
-H "Authorization: Bearer sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "event": "trial.expiring" }'
event is optional and defaults to trial.expiring. The call is synchronous: it sends a clearly synthetic sample payload (the subject is subject_test_0000) to your endpoint, signs it with your real secret, and returns the outcome:
Response 200 OK
{
"delivered": true,
"webhookId": "evt_test_4d2f6b1e-8a3c-4e0f-9b17-2c5d8e0f1a63",
"statusCode": 200,
"responseTimeMs": 142
}
Test sends are recorded in the deliveries log with an is_test flag, so they never pollute production delivery metrics.
The same tester is available in Settings › Webhooks in the dashboard, and as the send_test_webhook MCP tool.
Inspect deliveries
# Recent deliveries, filterable
curl "$INCEPTION_API/api/v1/webhooks/deliveries?subscription_id=9b2f6c1e-4a8d-4f0b-a3c7-51e29d8f0b64&status=failed&event=trial.expiring&limit=25" \
-H "Authorization: Bearer sk_your_api_key"
# One delivery, including the full payload that was sent
curl "$INCEPTION_API/api/v1/webhooks/deliveries/DELIVERY_ID" \
-H "Authorization: Bearer sk_your_api_key"
The list endpoint paginates with a cursor: pass the next_before value from a response as before= on the next request. The detail endpoint includes the complete payload as sent, which is the fastest way to debug a receiver that rejects real deliveries but accepts your local fixtures.
subject_ref and PII
Webhook payloads are pseudonymous by construction. subject_ref and account_ref are opaque references minted by your own SDK integration — Inception never learns, stores, or sends an email address or a name, so a webhook payload cannot leak one. Your receiver resolves refs against your own user store; nobody else can.
Two consequences worth designing around:
- To act on an event in an email platform or CRM, your receiver (or a mapping you maintain in the destination tool) translates
subject_refto your user. The campaign recipe shows the standard pattern. - Enrichment fields describe intent, not identity — and they are routing inputs, not email copy. The webhook event reference covers the field contract and the routing rule in full.