Webhooks
The inbound Stripe webhook the platform receives, and the outbound Slack and JSON webhooks that Competitor Monitor and rank alerts send.
Last updated 2026-08-06
Summary#
Metric Vault receives one inbound webhook, from Stripe, which is what keeps plan entitlements in step with subscriptions. It sends two kinds of outbound webhook, both belonging to the alerting features: a formatted Slack message and a generic JSON payload you can point at any HTTPS endpoint. There are no webhooks for tool completion, and the public API is synchronous, so nothing calls you back after an /api/v1/analyze request.
Overview#
| Direction | Endpoint or destination | Trigger | Configured where |
|---|---|---|---|
| Inbound | POST /api/stripe/webhook | Subscription lifecycle events | The Stripe dashboard, plus a signing secret in the environment |
| Outbound | Your Slack incoming-webhook URL | A monitored page changes, or a keyword rank drops | Per monitored URL, or per rank alert |
| Outbound | Any HTTPS endpoint you own | Same triggers | Per monitored URL, or per rank alert |
| Outbound | An email address | Same triggers | Per monitored URL, or per rank alert |
Inbound: the Stripe webhook#
Endpoint#
| Property | Value |
|---|---|
| Path | POST /api/stripe/webhook |
| Authentication | HMAC-SHA256 signature in the stripe-signature header |
| Body | The raw Stripe event, read before any other processing so the bytes stay intact |
| Purpose | Grants and revokes plan entitlements as subscriptions change |
Signature verification#
The signature is computed over the string <timestamp>.<raw body> using the webhook signing secret, then compared in constant time against the v1= values in the stripe-signature header. Events older than 300 seconds are rejected as replays.
Important: If the signing secret is not configured, the endpoint fails closed: every call is answered 503 with code: "webhook_not_configured" and the message Webhook is not configured (STRIPE_WEBHOOK_SECRET is not set). Entitlements then drift until the reconciliation job catches them. This is one of the first things to check when a customer pays and does not get their plan.
Handled events#
| Event type | Action |
|---|---|
checkout.session.completed | Grants the purchased plan to the customer's email |
customer.subscription.created | Grants the plan implied by the subscription |
customer.subscription.updated | Grants or revokes according to the new status |
customer.subscription.deleted | Revokes the plan |
| Anything else | Acknowledged and ignored |
A subscription is treated as entitling when its status is active, trialing or past_due. Keeping past_due deliberately preserves access during dunning rather than cutting a paying customer off on the first failed charge. See Subscription states and Failed payments.
Responses#
| HTTP | Body | Cause |
|---|---|---|
| 200 | {"received":true,"type":"<event>","action":"granted"} | Plan granted |
| 200 | {"received":true,"type":"<event>","action":"revoked"} | Plan revoked |
| 200 | {"received":true,"type":"<event>","action":"ignored"} | An event type that is not handled |
| 400 | {"error":"Invalid signature"} | Signature mismatch or a stale timestamp |
| 400 | {"error":"unreadable body"} | The raw body could not be read |
| 400 | {"error":"invalid JSON"} | The body was not valid JSON |
| 500 | {"error":"DB unavailable"} | The database was unreachable. Returned deliberately so Stripe retries |
| 503 | {"error":"Webhook is not configured (STRIPE_WEBHOOK_SECRET is not set).","code":"webhook_not_configured"} | No signing secret |
Safety property#
Subscription events write the plan only when the existing record was itself set by Stripe. A manually granted or comped plan is never overwritten by an incoming webhook, so a support-issued upgrade survives billing traffic.
The reconciliation backstop#
Webhooks can be missed. POST /api/billing/sync pages through live Stripe subscriptions and reconciles stored plans against them. It also runs as the billing_sync background job, so entitlements self-heal within a scheduling cycle even if a webhook never arrives. It grants freely but only revokes when it has completed a full scan, and it only ever revokes records that Stripe set. See Internal endpoints.
Outbound: alert webhooks#
Two features send outbound webhooks: Competitor Monitor and rank alerts. Both use the same dispatcher, so the payloads are identical in shape.
Where they are configured#
| Feature | Field | Set on |
|---|---|---|
| Competitor Monitor | slack_webhook_url, generic_webhook_url, notify_email_addr | Each monitored URL, at POST /api/monitor/add or POST /api/monitor/update |
| Rank alerts | The same three fields | Each alert, at POST /api/rank-alerts/create |
All three channels are optional and independent. Supplying none means the change is recorded in the app and appears in the notifications feed, but nothing is pushed anywhere.
Note: Webhook URLs are validated when you save them. A URL pointing at a loopback, private, link-local or otherwise reserved address is rejected with 400 Webhook host is not allowed. Use a public HTTPS endpoint.
The severity gate#
Each monitored URL carries a min_severity of Low, Medium or High. Changes below that level are recorded but never dispatched. Rank alerts always dispatch at Low, so every rank drop that passes the drop threshold is sent.
Slack payload#
Sent as POST with Content-Type: application/json to your Slack incoming webhook URL. It uses Slack's Block Kit format: a header block reading Watchtower alert, a section naming the monitored label and linking the URL, a section listing up to 8 changes as • *Severity* — description with an …and N more line when there are more, and a context line showing the severity gate, the check interval, and a link back to Competitor Monitor.
The fallback text field reads *Watchtower* — change detected on <label>.
Generic JSON payload#
Sent as POST with Content-Type: application/json to your endpoint. This is the one to point at Zapier, Make, or your own service.
{
"source": "Metric Vault Watchtower",
"event": "changes_detected",
"monitored": {
"id": 128,
"url": "https://competitor.example.com/pricing",
"label": "Competitor pricing page",
"competitor_domain": "competitor.example.com"
},
"changes": [
{
"severity": "High",
"change_type": "price_drop",
"description": "Pro plan price fell from $99 to $79",
"old_value": "99",
"new_value": "79",
"numeric_delta": -20
}
],
"top_change": {
"severity": "High",
"description": "Pro plan price fell from $99 to $79"
},
"change_count": 1,
"detected_at": "2026-08-06T09:15:00.000Z",
"dashboard_url": "https://metricvaultai.com/dashboard#competitor-monitor"
}| Field | Type | Notes |
|---|---|---|
source | string | Always Metric Vault Watchtower |
event | string | Always changes_detected |
monitored.id | number | The monitored URL's identifier |
monitored.url | string | For a rank alert this is the Google results URL for the keyword |
monitored.label | string | Your label, or for a rank alert Rank drop: "<keyword>" |
monitored.competitor_domain | string | The domain, or for a rank alert the domain being tracked |
changes | array | Only changes at or above the severity gate |
changes[].change_type | string | For rank alerts this is rank_drop |
changes[].numeric_delta | number or null | The numeric movement where one exists |
top_change | object | The first change, for a one-line summary |
change_count | number | Length of changes |
detected_at | string | ISO 8601 timestamp |
dashboard_url | string | Deep link back into the app |
Rank-alert specifics#
A rank alert fires when the position drops by at least the alert's threshold (1 to 20, default 5) or when a keyword that was in the top 10 leaves it. The change carries:
| Field | Value |
|---|---|
change_type | rank_drop |
severity | High when the keyword left page one, or when the drop is at least twice the threshold. Otherwise Medium |
description | "<keyword>" dropped from #4 to #11 on Google for example.com, or "<keyword>" no longer ranks on page 1 for example.com (was #4) |
old_value | The previous position |
new_value | The new position, or not ranked |
numeric_delta | Positions lost, or null when the keyword left the results |
Enabled rank alerts are re-checked roughly every 24 hours.
Email channel#
If an address is set and email sending is configured, an HTML message is sent from Watchtower <alerts@metricvaultai.com> with the subject Watchtower: <N> change(s) on <label>, listing up to 12 changes.
Delivery semantics#
These are the properties that matter when you build a receiver:
| Property | Behavior |
|---|---|
| Retries | None. One attempt per channel per detection |
| Signing | None. Outbound payloads are not signed. Treat the webhook URL itself as the secret |
| Ordering | Not guaranteed |
| Delivery log | Not exposed. Failures are logged server-side and swallowed |
| Failure impact | A failing webhook never blocks or fails the underlying check |
| Timeouts | The dispatcher does not wait indefinitely; a slow endpoint is simply counted as not delivered |
| Duplicates | Possible if a check runs twice over the same change window |
Tip: Because there are no retries and no signatures, build your receiver to be idempotent on monitored.id plus detected_at, keep the webhook URL secret and unguessable, and treat a missed delivery as normal rather than exceptional. The app's own notifications feed is the authoritative record.
Testing a webhook#
POST /api/monitor/test-alert dispatches a synthetic price_drop change at High severity to whichever channels you supply in the body, with the description Test alert — your alert channels are wired correctly. It does not require an existing monitored URL and does not cost credits.
Other callback-shaped mechanisms#
These are not webhooks in the usual sense, but they are HTTP callbacks and it helps to know they exist.
| Mechanism | What it is |
|---|---|
| OAuth callbacks | /auth/google/callback and /api/social/callback/<platform> complete an authorisation flow. State is stored server-side, is single use, and expires after 15 minutes |
| Scheduler trigger | POST /api/cron/run is called by an external scheduler to run due background jobs |
What does not exist#
Being explicit saves an integration built on a wrong assumption:
- No webhooks for tool completion. Running a tool does not call you back. The dashboard tools are request-response, including long article jobs.
- No webhooks for the public API.
POST /api/v1/analyzereturns its result in the response. - No subscription-management API for webhooks. Outbound endpoints are configured per monitored URL or per rank alert, not through a webhook registry.
- No signing secret or signature header on outbound payloads.
- No delivery history, retry queue or dead-letter view.
- No webhooks for billing, usage or team events.
See also
Was this article helpful?
Thanks — feedback noted for the docs team.