Webhooks
Mrs. NetShow is an AI agent.
Subscribe to signed MCP Events and integrate every verified provider webhook NetShow receives.
Audience: a developer, or an AI agent, that wants NetShow to tell it when something happens, or that runs a service NetShow listens to (Stripe, Paddle, Twilio, OpenAI, Slack, Telegram, Linear, Meta). Scope: the events NetShow sends to your HTTPS address (MCP Events), and every inbound webhook NetShow receives: what calls it, how the call is proved, which events it acts on and what it answers. How this guide was written: from the code on main (the class and method are named under each door), with the host shown as https://app.netshow.ai. A door marked off until your administrator turns it on sits behind a switch that ships off; while it is off the door answers 404 (an MCP method answers Method not found). Signing secrets live in NetShow's server configuration and are never shown in a page or a response.
Which webhook do I need?
| You want to | Use | Section |
|---|---|---|
| Hear when one of your agents gets a lead, a quote request or finishes a job | MCP Events: events/subscribe on your private MCP connection |
Events NetShow sends you |
| Hear when an A2A task you sent finishes | The task's push notification config | The A2A Protocol guide |
| Point Stripe, Paddle, Twilio, OpenAI, Slack, Telegram, Linear or Meta at NetShow | The inbound door for that provider | Webhooks NetShow receives |
What you can do without a key
Nothing here is keyless for you as a caller. Events go only to the owner of the agent, over the owner's own MCP connection. Every inbound door is proved by the provider's own signature (or a token the provider echoes), not by a NetShow key; a call without that proof is refused.
Events NetShow sends you (MCP Events)
Classes: AliveMcpEvents (subscribe), McpEventDelivery (raise and send), McpEventWebhook (sign and post). An agent's owner subscribes an HTTPS address to one event for one agent, and NetShow posts a small signed JSON body to it each time that event happens. Off until your administrator turns it on: ai.features.chatgpt_mcp_events, ai.features.chatgpt_plugin_extensions and ai.features.alive_plugin_owner_demo must all be on, and the delivery lane ai.spend.lanes.chatgpt_mcp_events must have its daily ceiling and its daily attempt allowance set (both ship 0, which is closed).
The events
| Event | When | data.status |
|---|---|---|
netshow.lead.created |
A new lead reached your agent. | new |
netshow.quote.requested |
A visitor asked your agent for a quote. | requested |
netshow.agent.work_completed |
A job your agent ran finished. | completed |
An event carries an opaque reference, a status and a link that opens NetShow. It never carries a visitor's name, email, phone, words or a screenshot, and it never asks you to send or charge anything: mode is always draft_only.
Subscribe: POST /api/mcp/workboard, method events/subscribe
Use the owner's MCP connection token (the Authentication and Keys guide shows how the owner makes one) and the MCP revision 2026-07-28. A connection made for one agent can subscribe for that agent only.
curl --max-time 30 https://app.netshow.ai/api/mcp/workboard \
-H 'Authorization: Bearer <MCP_CONNECTION_TOKEN>' -H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Content-Type: application/json' -H 'Accept: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"events/subscribe","params":{"name":"netshow.lead.created","arguments":{"agent_id":"42"},"delivery":{"mode":"webhook","url":"https://hooks.example.com/netshow","secret":"whsec_<base64 of 24 to 64 random bytes>"},"ttlMs":2592000000}}'
Rules the request must meet:
-
nameis one event from the table;argumentsis exactly{"agent_id": "<your agent's id>"}. -
delivery.modeiswebhook;delivery.urlis a publichttpsaddress on port 443, at most 512 characters, with no fragment and no user name or password in it. NetShow resolves the host and refuses any private address, now and again at each send, and never follows a redirect. -
delivery.secretiswhsec_followed by base64 of 24 to 64 bytes. You keep it; NetShow stores it encrypted. -
ttlMsis how long the subscription lives, at most 30 days (the default). Subscribe again beforerefreshBeforeto keep it. - An owner may hold 5 live subscriptions.
Before anything is stored, NetShow proves the address is yours. It posts (signed exactly like an event, below):
{"type": "verification", "challenge": "<48 hex characters>"}
and your address must answer 200 with the same challenge, in at most 4 KB, within 5 seconds:
{"challenge": "<the same 48 hex characters>"}
Response:
{"jsonrpc": "2.0", "id": 1, "result": {"id": "mcpsub_<32 hex>", "refreshBefore": "2026-11-04T11:00:00+00:00", "cursor": null, "truncated": false}}
Subscribing the same event, agent and address again with a new secret rotates it: for the next 5 minutes each delivery is signed with both secrets.
events/list (same door, same revision) returns the three events with their inputSchema and payloadSchema. events/unsubscribe takes the same name, arguments and delivery and answers {}.
| Error code | Message | Why |
|---|---|---|
-32601 |
Method not found |
A switch above is off. |
-32602 |
MCP Events requires protocol 2026-07-28. |
The MCP-Protocol-Version header is missing or older. |
-32602 |
Use an authorized agent and a public HTTPS webhook. |
Unknown event, an agent that is not yours, or an address that is not a public https address. |
-32602 |
Use a valid signing secret, finite lifetime and no replay cursor. |
A bad whsec_ secret, ttlMs under 1000, or a cursor. |
-32602 |
Too many active subscriptions. |
You already hold 5. |
-32015 |
The callback did not verify. |
Your address did not echo the challenge; data.reason is timeout or challenge_failed. |
-32015 |
Callback verification is unavailable. |
The delivery lane is closed (data.reason is lane_closed). |
-32001 |
Use your private MCP connection. |
The token is not an MCP connection token. |
The plain /api/mcp door also answers events/list, events/subscribe and events/unsubscribe (class McpEventSubscriptions, NetShow's earlier draft shape, switch ai.features.chatgpt_mcp_events). Its callback check is not wired to a sender yet, so a subscription made there never becomes active: build on /api/mcp/workboard.
What a delivery looks like
POST to your address, Content-Type: application/json, at most 2 KB:
{
"eventId": "mcpevt_<32 hex>",
"name": "netshow.lead.created",
"timestamp": "2026-10-05T11:00:00+00:00",
"data": {
"reference": "mcpevt_<32 hex>",
"status": "new",
"link": "https://app.netshow.ai/...",
"mode": "draft_only",
"next_step": "Draft a follow-up plan for the owner. Open NetShow to review the lead; send nothing."
},
"cursor": null
}
Headers (the Standard Webhooks shape):
| Header | Value |
|---|---|
webhook-id |
The message id: the eventId, or msg_verification_... for the challenge. |
webhook-timestamp |
Unix seconds when it was sent. |
webhook-signature |
One or more v1,<base64> entries separated by spaces. |
X-MCP-Subscription-Id |
Your mcpsub_... id. |
To check a delivery: base64-decode the part of your secret after whsec_ to get the key; compute HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<raw body> with that key; base64-encode it and compare (in constant time) with each v1, entry. Refuse a timestamp more than 5 minutes from your clock.
import base64, hashlib, hmac
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
ok = any(hmac.compare_digest(expected, s.split(",", 1)[1]) for s in signature_header.split())
Answer any 2xx to accept. A 408, 429 or 5xx (or no answer in 5 seconds) is tried again, up to 3 attempts, 30 and then 60 seconds apart, within the event's 24 hours; any other answer stops it. Delivery is at least once: use eventId to drop a repeat. A subscription that was revoked, has expired, or whose MCP connection was revoked stops receiving at once.
Webhooks NetShow receives
Each door below is for one provider. Point the provider at the path, give NetShow the matching signing secret through your administrator, and the provider's own signature does the rest. Every door under the api prefix answers JSON.
| Provider | Door | Proof | Switch |
|---|---|---|---|
| Stripe (membership billing) | POST /webhook/stripe |
Stripe-Signature |
Always on |
| Stripe (plan tier checkout) | POST /api/stripe/webhook/tier |
Stripe-Signature |
Always on |
| Stripe (pay to play: AI employees and one-time services) | POST /webhooks/stripe/commerce |
Stripe-Signature |
ai.features.commerce_pay_to_play |
| Stripe (Agentic Commerce) | POST /api/agent-commerce/webhook |
Stripe-Signature |
Always on (with its own kill switch) |
| Paddle | POST /api/paddle/webhook |
Paddle-Signature |
ai.features.paddle_webhook |
| OpenAI Realtime (phone calls over SIP) | POST /api/realtime/webhook |
webhook-signature |
Always on |
| Twilio (calls, keypad, in-call actions) | The twilio/ws and dtmf doors below, POST /twilio/call, POST /twilio/forward |
X-Twilio-Signature |
Always on |
| Twilio ConversationRelay | POST /api/twilio/voice/conversation-relay, POST /telephony/twilio/answer |
X-Twilio-Signature |
ai.features.phone_conversation_relay |
| Messaging channels (SMS, Telegram) | POST /api/channels/{channel}/inbound |
The channel's own signature | ai.features.agent_channels |
| Slack | POST /api/channels/slack/events |
X-Slack-Signature |
ai.features.agent_channels |
| Telegram (live chat operator bot) | POST /live-chat/telegram/webhook |
X-Telegram-Bot-Api-Secret-Token |
ai.features.live_chat_telegram |
| Meta (Facebook page messages) | /webhook/meta/verifyWebhook |
hub.verify_token, then X-Hub-Signature-256 |
Always on |
| Linear | POST /api/linear/webhook |
Linear-Signature |
Always on |
| A dial request from your system | POST /webhook/create_twilio_call/{id} |
X-Dial-Token |
Always on |
| Google Sheets check-in | POST /api/daily_checkin/sheet/webhook |
None (stores nothing) | Always on |
Stripe
All four Stripe doors check Stripe-Signature over the raw body with Stripe's own library (Stripe\Webhook::constructEvent); a bad signature answers 400, and a door with no secret configured refuses every call.
POST /webhook/stripe (class StripeController, method webhook). Membership billing. It reads the checkout type from the event's metadata: a membership event goes to the membership handler, an AI sales employee checkout to its own fulfilment, and anything else answers 200 {"status": "ignored", "message": "Not a membership event"}. Bad signature: {"error": "Invalid signature"}.
POST /api/stripe/webhook/tier (class StripeWebhookController, method handle, 120 a minute). Plan tier checkout and its lifecycle: checkout.session.completed and checkout.session.async_payment_succeeded grant the tier; customer.subscription.updated, customer.subscription.deleted and invoice.payment_failed end it when the subscription has ended.
POST /webhooks/stripe/commerce (class PayToPlayWebhookController, method handle, 300 a minute). Off until your administrator turns it on (ai.features.commerce_pay_to_play). It acts on checkout.session.completed, checkout.session.async_payment_succeeded, invoice.paid, invoice.payment_failed, customer.subscription.updated, customer.subscription.deleted, charge.succeeded and charge.dispute.created; charge.dispute.closed needs ai.features.chargeback_desk, and charge.refunded, charge.refund.updated, refund.created and refund.updated need ai.features.commerce_refund_rows. Each event is applied once ({"status": "duplicate"} on a repeat). A failure answers 500 so Stripe tries again.
POST /api/agent-commerce/webhook (class AgentCommerceWebhookController, method handle, 60 a minute). Stripe's Agentic Commerce: checkout.session.completed for an agent-started checkout, data_management.import_set.succeeded and data_management.import_set.failed for catalog imports. A missing header answers 400 {"error": "Missing signature"}. While the service's kill switch is set, every call answers 200 {"status": "disabled"}. A checkout that an agent did not start, or that is not paid, answers {"status": "skipped"}. With ai.features.agent_commerce_fulfilment on, a paid agent order is accepted for fulfilment and answers {"status": "fulfilled"} (or the order's current status).
Paddle
POST /api/paddle/webhook (class PaddleWebhookController, method index, 60 a minute). Off until your administrator turns it on (ai.features.paddle_webhook). The Paddle-Signature header is ts=<10 digits>;h1=<64 hex>, an HMAC-SHA256 of <ts>:<raw body>. Bodies over 1 MB answer 413. PaddleEventRecorder acts on transaction.completed and subscription.canceled.
| Status | status |
Why |
|---|---|---|
200 |
the recorded outcome | Payment update received. |
401 |
unverified |
The signature did not match. |
409 |
unmatched or reconciliation |
The payment could not be matched to a subscription. |
422 |
invalid |
The event or plan is not recognized. |
503 |
unavailable |
No secret is configured, or the update could not be recorded (retry). |
OpenAI Realtime (phone calls over SIP)
POST /api/realtime/webhook (class RealtimeWebhookController, method handleWebhook, 60 a minute). The Standard Webhooks headers webhook-id, webhook-timestamp and webhook-signature: an HMAC-SHA256 of <id>.<timestamp>.<raw body> with the decoded whsec_ secret, a timestamp within 5 minutes. A failure answers 401 {"status": "invalid_signature"}. It acts on realtime.call.incoming: it accepts the call on the NetShow voice when the phone bridge is on, else answers {"status": "sip_bridge_off"}. A repeated event answers {"status": "duplicate_ignored"}; any other event {"status": "ignored"}.
Twilio
Every Twilio door runs the VerifyTwilioSignature middleware, which checks X-Twilio-Signature with Twilio's own RequestValidator against the account's auth token. No token configured, a missing header or a wrong signature: 403.
| Door | Class and method | What Twilio sends |
|---|---|---|
POST /api/twilio/ws/agent-data |
SmsController@getAgentDataForInboundCall |
Which agent answers an inbound call. |
POST /api/twilio/ws/agent-voice-for-inbound |
SmsController@getSelectedVoiceForInboundCall |
Which voice it speaks in. |
POST /api/twilio/ws/openai/realtime/agentdata |
SmsController@getDataForOpenAiRealTimeInboundCall |
The realtime call's agent setup. |
POST /api/twilio/ws/openai/realtime/agentdata/deepgram |
SmsController@getDataForOpenAiRealTimeInboundCallDeepgram |
The same, for the Deepgram lane. |
POST /api/twilio/ws/openai/deepgram/sendSms |
SmsController@toolAction_SendSmsAPI |
An in-call "send a text" action. |
POST /api/twilio/ws/openai/deepgram/callForward |
SmsController@toolAction_ForwardCallAPI |
An in-call "forward this call" action. |
POST /api/twilio/ws/openai/deepgram/sendEmail |
SmsController@toolAction_SendEmailAPI |
An in-call "send an email" action. |
POST /api/dtmf/gather and GET /api/dtmf/menu |
DtmfController@handleGather, DtmfController@menu |
Keypad digits and the phone menu (10 a minute). |
POST /twilio/call |
SmsController@twilio_call |
The live call leg. |
POST /twilio/forward |
SmsController@handleForwardedCall |
The forwarded call leg. |
POST /api/twilio/voice/conversation-relay |
ConversationRelayVoiceWebhookController |
An inbound call handed to ConversationRelay; answers TwiML. Off until your administrator turns it on (ai.features.phone_conversation_relay). |
POST /telephony/twilio/answer |
TwilioRelayAnswerController |
The relay worker's answer. Needs ai.features.voice_runtime, ai.features.phone_conversation_relay and ai.features.phone_conversation_relay_worker. |
Messaging channels and Slack
POST /api/channels/{channel}/inbound (class ChannelWebhookController, 30 a minute). One door for each messaging channel: sms (switch ai.features.channel_sms) and telegram (switch ai.features.channel_telegram), both also behind ai.features.agent_channels. A channel that is off, or not built, answers 404 before anything else runs. ChannelBridge checks the channel's own signature (Twilio's for SMS, Telegram's secret token), then answers the sender with the agent's reply on the same channel only.
POST /api/channels/slack/events (class SlackEventsController, method handle). Off until your administrator turns it on (ai.features.agent_channels). The VerifySlackSignature middleware checks X-Slack-Signature against X-Slack-Request-Timestamp and the raw body (403 on a mismatch). It answers Slack's url_verification with {"challenge": "..."} and handles event_callback; anything else answers {"ok": true}.
POST /live-chat/telegram/webhook (class LiveChatTelegramController, method webhook, 300 a minute). Off until your administrator turns it on (ai.features.live_chat_telegram). Telegram sends the secret token you gave setWebhook in X-Telegram-Bot-Api-Secret-Token; a mismatch answers 403 {"ok": false}. A verified update always answers 200 {"ok": true}, and acts only for the configured operators in the configured chat.
Meta (Facebook page messages)
/webhook/meta/verifyWebhook (class SocialSettingController, method verifyWebhook). GET is Meta's handshake: with hub.mode=subscribe and the matching hub.verify_token it echoes hub.challenge, else 403 Verification failed. POST delivers page messages: it needs X-Hub-Signature-256: sha256=<hex>, an HMAC of the raw body with the Meta app secret (403 when it does not match), a body under 256 KB, and answers 200 Event queued; the reply is worked by a background job.
Linear
POST /api/linear/webhook (class LinearWebhookController, method handle, 120 a minute). Linear-Signature is an HMAC-SHA256 of the raw body (401 {"status": "invalid_signature"}); a body whose webhookTimestamp is missing or stale answers 401 {"status": "stale_or_missing_timestamp"}. Each delivery is accepted once (by Linear-Delivery) for 6 hours; a repeat answers {"status": "duplicate_ignored"}. It records the event for NetShow's operations board and starts nothing on its own.
A dial request from your system
POST /webhook/create_twilio_call/{id} (class TwilioController, method twilio_webhook, 5 a minute). Asks the phone agent {id} to place a call. It needs the dial token made for that agent, as X-Dial-Token or ?token= (class DialWebhookToken); without it: 403 {"error": "A valid dial token for this agent is required."}. The token is made from NetShow's own server key for that one agent id and is shown on no page yet: ask your NetShow administrator for it.
Google Sheets check-in
POST /api/daily_checkin/sheet/webhook (class GoogleSheetCheckinController, method handleWebhook, 10 a minute). A sheet automation may call it; it logs only the shape of the row (never its cells) and answers 202 {"received": true, "stored": false}. Nothing is stored yet.
How to get a key
For events, the owner of the agent makes an MCP connection (the Authentication and Keys guide, "The owner's MCP connection card" and "MCP sign-in") and subscribes with it; the whsec_ secret is yours to make and keep. For inbound doors there is no NetShow key: give the provider's signing secret to your NetShow administrator, who stores it in the server configuration, and the provider's signature proves each call.
Was this helpful?
We can turn this into interactive help, search, and guided checklists next.
Previous guide