Webhooks

Signed HTTP callbacks that carry Revenue Moments out of agent threads and into your email platform, CRM, or warehouse.

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:

  1. The API — the /api/v1/webhooks/subscriptions endpoints below.
  2. The dashboardSettings › Webhooks: create endpoints, choose events, send tests, browse the delivery log, and re-enable disabled endpoints.
  3. MCP — or ask your agent: create_webhook_subscription, send_test_webhook, and list_webhook_deliveries are tools on mcp.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"
  }'
  • url must be HTTPS and publicly reachable.
  • event_types is optional — omit it (or pass an empty array) to receive all events.
  • label is 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"
    }
  }
}
FieldDescription
apiVersionEnvelope version. Currently 2026-07. Breaking changes ship under a new version.
eventEvent type (see the catalog). Also sent in the X-Webhook-Event header.
webhookIdUnique event id (evt_<uuid>). Stable across retries — this is your dedup key.
timestampISO 8601 time the moment occurred (not the delivery time).
data.subject_refPseudonymous reference to the end user, minted by your SDK integration.
data.account_refPseudonymous reference to the account/organization, or null.
data.momentWhere in the flow the moment happened. Every field is nullable.
data.lifecycleSubscription lifecycle state of the subject.
data.commercePlan and amount details. Present only on conversion.* events.
data.enrichmentIntent-graph signals. Every field is optional.
data.attributionUTM 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:

HeaderValue
Content-Typeapplication/json
X-Inception-Signaturet=<unix_seconds>,v1=<hex signature> — see Signature verification
X-Webhook-EventThe event type, e.g. trial.expiring
Webhook-IdThe webhookId, e.g. evt_9c1f...
Webhook-TimestampThe 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...
  • t is a Unix timestamp (seconds) set when the delivery was signed.
  • v1 is the hex HMAC-SHA256 of `${t}.${rawBody}` computed with your whsec_... 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 seconds3 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_ref to 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.